diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 1497b6a73f4..207f4b21417 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -611,6 +611,22 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); }); + it.effect("preserves the schema cause for invalid queue payloads", () => { + const attempts: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true })); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryJobQueuePayloadInvalid", + receivedType: "object", + message: "Invalid APNs delivery queue job with object payload.", + }); + expect(error.cause).toMatchObject({ _tag: "SchemaError" }); + }).pipe(Effect.provide(makeLayer({ attempts }))); + }); + it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; const payload = makeApnsDeliveryJobPayload({ diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index e0b652823ba..6c714d1d56d 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -640,7 +640,13 @@ export const make = Effect.gen(function* () { "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( - Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()), + Effect.mapError( + (cause) => + new ApnsDeliveryJobQueuePayloadInvalid({ + receivedType: Array.isArray(body) ? "array" : body === null ? "null" : typeof body, + cause, + }), + ), ); const now = yield* DateTime.now; const payload = verifySignedApnsDeliveryJob({ @@ -661,7 +667,14 @@ export const make = Effect.gen(function* () { case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing()); + return Effect.fail( + new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }), + ); } return sendLiveActivity({ target: { @@ -686,7 +699,13 @@ export const make = Effect.gen(function* () { }); case "push_notification": if (payload.notification === null) { - return Effect.fail(new ApnsDeliveryJobPushNotificationMissing()); + return Effect.fail( + new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }), + ); } return sendPushNotification({ target: { diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index d65587f0d19..a0a45b9ed72 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -70,6 +70,11 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobSignatureInvalid", + jobId: "job-1", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + message: "Invalid signature for APNs delivery job job-1.", }); }); @@ -94,7 +99,11 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobLiveActivityAggregateMissing", - message: "Live Activity start/update jobs require an aggregate.", + jobId: "job-start-invalid", + kind: "live_activity_start", + userId: "user-1", + deviceId: "device-1", + message: "APNs live activity start job job-start-invalid requires an aggregate.", }); }); @@ -120,7 +129,10 @@ describe("apnsDeliveryJobs", () => { expect(result).toMatchObject({ _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected", - message: "Push notification jobs must not carry aggregate state.", + jobId: "job-push-invalid", + userId: "user-1", + deviceId: "device-1", + message: "APNs push notification job job-push-invalid must not carry aggregate state.", }); }); @@ -195,7 +207,12 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobCreatedAtInvalid", - message: "Invalid APNs delivery job creation time.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "not-a-date", + message: "APNs delivery job job-window has invalid creation time not-a-date.", }); expect( verifySignedApnsDeliveryJob({ @@ -205,7 +222,14 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobTimeWindowInvalid", - message: "Invalid APNs delivery job time window.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-24T23:59:59.000Z", + message: + "APNs delivery job job-window has invalid time window 2026-05-25T00:00:00.000Z to 2026-05-24T23:59:59.000Z.", }); expect( verifySignedApnsDeliveryJob({ @@ -215,7 +239,14 @@ describe("apnsDeliveryJobs", () => { }), ).toMatchObject({ _tag: "ApnsDeliveryJobTimeWindowTooLong", - message: "APNs delivery job time window is too long.", + jobId: "job-window", + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:10:01.000Z", + message: + "APNs delivery job job-window time window 2026-05-25T00:00:00.000Z to 2026-05-25T00:10:01.000Z is too long.", }); }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 6e493943ab4..2af61085eab 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; const MAX_JOB_AGE_MS = 10 * 60 * 1_000; export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; -const ApnsDeliveryKind = Schema.Literals([ +const ApnsDeliveryKindSchema = Schema.Literals([ "live_activity_start", "live_activity_update", "live_activity_end", "push_notification", ]); +const LiveActivityStartOrUpdateKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", +]); +const LiveActivityKindSchema = Schema.Literals([ + "live_activity_start", + "live_activity_update", + "live_activity_end", +]); + +const ApnsDeliveryJobContext = { + jobId: Schema.String, + userId: Schema.String, + deviceId: Schema.String, +}; export const ApnsNotificationPayload = Schema.Struct({ title: Schema.String, @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, - kind: ApnsDeliveryKind, + kind: ApnsDeliveryKindSchema, target: Schema.Struct({ userId: Schema.String, deviceId: Schema.String, @@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobQueuePayloadInvalid", - {}, + { + receivedType: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Invalid APNs delivery queue job."; + return `Invalid APNs delivery queue job with ${this.receivedType} payload.`; } } export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( "ApnsDeliveryJobLiveActivityAggregateMissing", - {}, + { + ...ApnsDeliveryJobContext, + kind: LiveActivityStartOrUpdateKindSchema, + }, ) { override get message(): string { - return "Live Activity start/update jobs require an aggregate."; + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`; } } export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass()( "ApnsDeliveryJobLiveActivityNotificationUnexpected", - {}, + { + ...ApnsDeliveryJobContext, + kind: LiveActivityKindSchema, + }, ) { override get message(): string { - return "Live Activity jobs must not carry push notification payloads."; + return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`; } } export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass()( "ApnsDeliveryJobPushNotificationMissing", - {}, + ApnsDeliveryJobContext, ) { override get message(): string { - return "Push notification jobs require a notification payload."; + return `APNs push notification job ${this.jobId} requires a notification payload.`; } } export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( "ApnsDeliveryJobPushNotificationAggregateUnexpected", - {}, + ApnsDeliveryJobContext, ) { override get message(): string { - return "Push notification jobs must not carry aggregate state."; + return `APNs push notification job ${this.jobId} must not carry aggregate state.`; } } export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobCreatedAtInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job creation time."; + return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`; } } export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpiresAtInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job expiry."; + return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`; } } export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobTimeWindowInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "Invalid APNs delivery job time window."; + return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`; } } export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass()( "ApnsDeliveryJobTimeWindowTooLong", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + createdAt: Schema.String, + expiresAt: Schema.String, + }, ) { override get message(): string { - return "APNs delivery job time window is too long."; + return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`; } } export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass()( "ApnsDeliveryJobSignatureInvalid", - {}, + { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, + }, ) { override get message(): string { - return "Invalid APNs delivery job signature."; + return `Invalid signature for APNs delivery job ${this.jobId}.`; } } @@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass()( "ApnsDeliveryJobExpired", { + ...ApnsDeliveryJobContext, + kind: ApnsDeliveryKindSchema, expiresAt: Schema.String, }, ) { override get message(): string { - return `APNs delivery job expired at ${this.expiresAt}`; + return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`; } } @@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return new ApnsDeliveryJobLiveActivityAggregateMissing(); + return new ApnsDeliveryJobLiveActivityAggregateMissing({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } if (payload.notification !== null) { - return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; case "live_activity_end": if (payload.notification !== null) { - return new ApnsDeliveryJobLiveActivityNotificationUnexpected(); + return new ApnsDeliveryJobLiveActivityNotificationUnexpected({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; case "push_notification": if (payload.notification === null) { - return new ApnsDeliveryJobPushNotificationMissing(); + return new ApnsDeliveryJobPushNotificationMissing({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } if (payload.aggregate !== null) { - return new ApnsDeliveryJobPushNotificationAggregateUnexpected(); + return new ApnsDeliveryJobPushNotificationAggregateUnexpected({ + jobId: payload.jobId, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } return null; } @@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { readonly job: SignedApnsDeliveryJob; readonly nowMs: number; }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { - const invalidPayload = validatePayloadShape(input.job.payload); + const payload = input.job.payload; + const invalidPayload = validatePayloadShape(payload); if (invalidPayload !== null) { return invalidPayload; } - const createdAt = DateTime.make(input.job.payload.createdAt); + const createdAt = DateTime.make(payload.createdAt); if (Option.isNone(createdAt)) { - return new ApnsDeliveryJobCreatedAtInvalid(); + return new ApnsDeliveryJobCreatedAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + }); } - const expiresAt = DateTime.make(input.job.payload.expiresAt); + const expiresAt = DateTime.make(payload.expiresAt); if (Option.isNone(expiresAt)) { - return new ApnsDeliveryJobExpiresAtInvalid(); + return new ApnsDeliveryJobExpiresAtInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const createdAtMs = createdAt.value.epochMilliseconds; const expiresAtMs = expiresAt.value.epochMilliseconds; if (expiresAtMs <= createdAtMs) { - return new ApnsDeliveryJobTimeWindowInvalid(); + return new ApnsDeliveryJobTimeWindowInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobTimeWindowTooLong(); + return new ApnsDeliveryJobTimeWindowTooLong({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + }); } if (expiresAtMs <= input.nowMs) { - return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); + return new ApnsDeliveryJobExpired({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + expiresAt: payload.expiresAt, + }); } const expected = signatureForPayload({ secret: input.secret, - payload: input.job.payload, + payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobSignatureInvalid(); + return new ApnsDeliveryJobSignatureInvalid({ + jobId: payload.jobId, + kind: payload.kind, + userId: payload.target.userId, + deviceId: payload.target.deviceId, + }); } - return input.job.payload; + return payload; }