authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:04:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:04:27-07:00
log5f860439e9c981c29fc4385ad505ffebfbb63343
treec9db3e68dde21e56c19da4546cd3f790dfe183b5
parentc9159b67b30340e12f75005e34faa7d1aca0f231
signature Signed by PGP key B5690EEEBB952194

[codex] Structure APNs delivery job errors (#3322)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 221 insertions(+), 47 deletions(-)

infra/relay/src/agentActivity/ApnsDeliveries.test.ts+16
......@@ -611,6 +611,22 @@ describe("ApnsDeliveries", () => {
611611 }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs })));
612612 });
613613
614 it.effect("preserves the schema cause for invalid queue payloads", () => {
615 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
616
617 return Effect.gen(function* () {
618 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
619 const error = yield* Effect.flip(deliveries.processSignedJob({ invalid: true }));
620
621 expect(error).toMatchObject({
622 _tag: "ApnsDeliveryJobQueuePayloadInvalid",
623 receivedType: "object",
624 message: "Invalid APNs delivery queue job with object payload.",
625 });
626 expect(error.cause).toMatchObject({ _tag: "SchemaError" });
627 }).pipe(Effect.provide(makeLayer({ attempts })));
628 });
629
614630 it.effect("processes signed jobs through APNs and records attempts", () => {
615631 const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = [];
616632 const payload = makeApnsDeliveryJobPayload({
infra/relay/src/agentActivity/ApnsDeliveries.ts+22-3
......@@ -640,7 +640,13 @@ export const make = Effect.gen(function* () {
640640 "relay.apns_deliveries.process_signed_job",
641641 )(function* (body) {
642642 const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe(
643 Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()),
643 Effect.mapError(
644 (cause) =>
645 new ApnsDeliveryJobQueuePayloadInvalid({
646 receivedType: Array.isArray(body) ? "array" : body === null ? "null" : typeof body,
647 cause,
648 }),
649 ),
644650 );
645651 const now = yield* DateTime.now;
646652 const payload = verifySignedApnsDeliveryJob({
......@@ -661,7 +667,14 @@ export const make = Effect.gen(function* () {
661667 case "live_activity_start":
662668 case "live_activity_update":
663669 if (payload.aggregate === null) {
664 return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing());
670 return Effect.fail(
671 new ApnsDeliveryJobLiveActivityAggregateMissing({
672 jobId: payload.jobId,
673 kind: payload.kind,
674 userId: payload.target.userId,
675 deviceId: payload.target.deviceId,
676 }),
677 );
665678 }
666679 return sendLiveActivity({
667680 target: {
......@@ -686,7 +699,13 @@ export const make = Effect.gen(function* () {
686699 });
687700 case "push_notification":
688701 if (payload.notification === null) {
689 return Effect.fail(new ApnsDeliveryJobPushNotificationMissing());
702 return Effect.fail(
703 new ApnsDeliveryJobPushNotificationMissing({
704 jobId: payload.jobId,
705 userId: payload.target.userId,
706 deviceId: payload.target.deviceId,
707 }),
708 );
690709 }
691710 return sendPushNotification({
692711 target: {
infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts+36-5
......@@ -70,6 +70,11 @@ describe("apnsDeliveryJobs", () => {
7070
7171 expect(result).toMatchObject({
7272 _tag: "ApnsDeliveryJobSignatureInvalid",
73 jobId: "job-1",
74 kind: "live_activity_end",
75 userId: "user-1",
76 deviceId: "device-1",
77 message: "Invalid signature for APNs delivery job job-1.",
7378 });
7479 });
7580
......@@ -94,7 +99,11 @@ describe("apnsDeliveryJobs", () => {
9499
95100 expect(result).toMatchObject({
96101 _tag: "ApnsDeliveryJobLiveActivityAggregateMissing",
97 message: "Live Activity start/update jobs require an aggregate.",
102 jobId: "job-start-invalid",
103 kind: "live_activity_start",
104 userId: "user-1",
105 deviceId: "device-1",
106 message: "APNs live activity start job job-start-invalid requires an aggregate.",
98107 });
99108 });
100109
......@@ -120,7 +129,10 @@ describe("apnsDeliveryJobs", () => {
120129
121130 expect(result).toMatchObject({
122131 _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected",
123 message: "Push notification jobs must not carry aggregate state.",
132 jobId: "job-push-invalid",
133 userId: "user-1",
134 deviceId: "device-1",
135 message: "APNs push notification job job-push-invalid must not carry aggregate state.",
124136 });
125137 });
126138
......@@ -195,7 +207,12 @@ describe("apnsDeliveryJobs", () => {
195207 }),
196208 ).toMatchObject({
197209 _tag: "ApnsDeliveryJobCreatedAtInvalid",
198 message: "Invalid APNs delivery job creation time.",
210 jobId: "job-window",
211 kind: "live_activity_end",
212 userId: "user-1",
213 deviceId: "device-1",
214 createdAt: "not-a-date",
215 message: "APNs delivery job job-window has invalid creation time not-a-date.",
199216 });
200217 expect(
201218 verifySignedApnsDeliveryJob({
......@@ -205,7 +222,14 @@ describe("apnsDeliveryJobs", () => {
205222 }),
206223 ).toMatchObject({
207224 _tag: "ApnsDeliveryJobTimeWindowInvalid",
208 message: "Invalid APNs delivery job time window.",
225 jobId: "job-window",
226 kind: "live_activity_end",
227 userId: "user-1",
228 deviceId: "device-1",
229 createdAt: "2026-05-25T00:00:00.000Z",
230 expiresAt: "2026-05-24T23:59:59.000Z",
231 message:
232 "APNs delivery job job-window has invalid time window 2026-05-25T00:00:00.000Z to 2026-05-24T23:59:59.000Z.",
209233 });
210234 expect(
211235 verifySignedApnsDeliveryJob({
......@@ -215,7 +239,14 @@ describe("apnsDeliveryJobs", () => {
215239 }),
216240 ).toMatchObject({
217241 _tag: "ApnsDeliveryJobTimeWindowTooLong",
218 message: "APNs delivery job time window is too long.",
242 jobId: "job-window",
243 kind: "live_activity_end",
244 userId: "user-1",
245 deviceId: "device-1",
246 createdAt: "2026-05-25T00:00:00.000Z",
247 expiresAt: "2026-05-25T00:10:01.000Z",
248 message:
249 "APNs delivery job job-window time window 2026-05-25T00:00:00.000Z to 2026-05-25T00:10:01.000Z is too long.",
219250 });
220251 });
221252});
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+147-39
......@@ -10,12 +10,27 @@ import * as Schema from "effect/Schema";
1010const MAX_JOB_AGE_MS = 10 * 60 * 1_000;
1111export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256";
1212
13const ApnsDeliveryKind = Schema.Literals([
13const ApnsDeliveryKindSchema = Schema.Literals([
1414 "live_activity_start",
1515 "live_activity_update",
1616 "live_activity_end",
1717 "push_notification",
1818]);
19const LiveActivityStartOrUpdateKindSchema = Schema.Literals([
20 "live_activity_start",
21 "live_activity_update",
22]);
23const LiveActivityKindSchema = Schema.Literals([
24 "live_activity_start",
25 "live_activity_update",
26 "live_activity_end",
27]);
28
29const ApnsDeliveryJobContext = {
30 jobId: Schema.String,
31 userId: Schema.String,
32 deviceId: Schema.String,
33};
1934
2035export const ApnsNotificationPayload = Schema.Struct({
2136 title: Schema.String,
......@@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
2944export const ApnsDeliveryJobPayload = Schema.Struct({
3045 version: Schema.Literal(1),
3146 jobId: Schema.String,
32 kind: ApnsDeliveryKind,
47 kind: ApnsDeliveryKindSchema,
3348 target: Schema.Struct({
3449 userId: Schema.String,
3550 deviceId: Schema.String,
......@@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type;
5166
5267export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobQueuePayloadInvalid>()(
5368 "ApnsDeliveryJobQueuePayloadInvalid",
54 {},
69 {
70 receivedType: Schema.String,
71 cause: Schema.Defect(),
72 },
5573) {
5674 override get message(): string {
57 return "Invalid APNs delivery queue job.";
75 return `Invalid APNs delivery queue job with ${this.receivedType} payload.`;
5876 }
5977}
6078
6179export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityAggregateMissing>()(
6280 "ApnsDeliveryJobLiveActivityAggregateMissing",
63 {},
81 {
82 ...ApnsDeliveryJobContext,
83 kind: LiveActivityStartOrUpdateKindSchema,
84 },
6485) {
6586 override get message(): string {
66 return "Live Activity start/update jobs require an aggregate.";
87 return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`;
6788 }
6889}
6990
7091export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityNotificationUnexpected>()(
7192 "ApnsDeliveryJobLiveActivityNotificationUnexpected",
72 {},
93 {
94 ...ApnsDeliveryJobContext,
95 kind: LiveActivityKindSchema,
96 },
7397) {
7498 override get message(): string {
75 return "Live Activity jobs must not carry push notification payloads.";
99 return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`;
76100 }
77101}
78102
79103export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationMissing>()(
80104 "ApnsDeliveryJobPushNotificationMissing",
81 {},
105 ApnsDeliveryJobContext,
82106) {
83107 override get message(): string {
84 return "Push notification jobs require a notification payload.";
108 return `APNs push notification job ${this.jobId} requires a notification payload.`;
85109 }
86110}
87111
88112export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationAggregateUnexpected>()(
89113 "ApnsDeliveryJobPushNotificationAggregateUnexpected",
90 {},
114 ApnsDeliveryJobContext,
91115) {
92116 override get message(): string {
93 return "Push notification jobs must not carry aggregate state.";
117 return `APNs push notification job ${this.jobId} must not carry aggregate state.`;
94118 }
95119}
96120
97121export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobCreatedAtInvalid>()(
98122 "ApnsDeliveryJobCreatedAtInvalid",
99 {},
123 {
124 ...ApnsDeliveryJobContext,
125 kind: ApnsDeliveryKindSchema,
126 createdAt: Schema.String,
127 },
100128) {
101129 override get message(): string {
102 return "Invalid APNs delivery job creation time.";
130 return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`;
103131 }
104132}
105133
106134export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobExpiresAtInvalid>()(
107135 "ApnsDeliveryJobExpiresAtInvalid",
108 {},
136 {
137 ...ApnsDeliveryJobContext,
138 kind: ApnsDeliveryKindSchema,
139 expiresAt: Schema.String,
140 },
109141) {
110142 override get message(): string {
111 return "Invalid APNs delivery job expiry.";
143 return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`;
112144 }
113145}
114146
115147export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowInvalid>()(
116148 "ApnsDeliveryJobTimeWindowInvalid",
117 {},
149 {
150 ...ApnsDeliveryJobContext,
151 kind: ApnsDeliveryKindSchema,
152 createdAt: Schema.String,
153 expiresAt: Schema.String,
154 },
118155) {
119156 override get message(): string {
120 return "Invalid APNs delivery job time window.";
157 return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`;
121158 }
122159}
123160
124161export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowTooLong>()(
125162 "ApnsDeliveryJobTimeWindowTooLong",
126 {},
163 {
164 ...ApnsDeliveryJobContext,
165 kind: ApnsDeliveryKindSchema,
166 createdAt: Schema.String,
167 expiresAt: Schema.String,
168 },
127169) {
128170 override get message(): string {
129 return "APNs delivery job time window is too long.";
171 return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`;
130172 }
131173}
132174
133175export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobSignatureInvalid>()(
134176 "ApnsDeliveryJobSignatureInvalid",
135 {},
177 {
178 ...ApnsDeliveryJobContext,
179 kind: ApnsDeliveryKindSchema,
180 },
136181) {
137182 override get message(): string {
138 return "Invalid APNs delivery job signature.";
183 return `Invalid signature for APNs delivery job ${this.jobId}.`;
139184 }
140185}
141186
......@@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type;
156201export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()(
157202 "ApnsDeliveryJobExpired",
158203 {
204 ...ApnsDeliveryJobContext,
205 kind: ApnsDeliveryKindSchema,
159206 expiresAt: Schema.String,
160207 },
161208) {
162209 override get message(): string {
163 return `APNs delivery job expired at ${this.expiresAt}`;
210 return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`;
164211 }
165212}
166213
......@@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI
208255 case "live_activity_start":
209256 case "live_activity_update":
210257 if (payload.aggregate === null) {
211 return new ApnsDeliveryJobLiveActivityAggregateMissing();
258 return new ApnsDeliveryJobLiveActivityAggregateMissing({
259 jobId: payload.jobId,
260 kind: payload.kind,
261 userId: payload.target.userId,
262 deviceId: payload.target.deviceId,
263 });
212264 }
213265 if (payload.notification !== null) {
214 return new ApnsDeliveryJobLiveActivityNotificationUnexpected();
266 return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
267 jobId: payload.jobId,
268 kind: payload.kind,
269 userId: payload.target.userId,
270 deviceId: payload.target.deviceId,
271 });
215272 }
216273 return null;
217274 case "live_activity_end":
218275 if (payload.notification !== null) {
219 return new ApnsDeliveryJobLiveActivityNotificationUnexpected();
276 return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
277 jobId: payload.jobId,
278 kind: payload.kind,
279 userId: payload.target.userId,
280 deviceId: payload.target.deviceId,
281 });
220282 }
221283 return null;
222284 case "push_notification":
223285 if (payload.notification === null) {
224 return new ApnsDeliveryJobPushNotificationMissing();
286 return new ApnsDeliveryJobPushNotificationMissing({
287 jobId: payload.jobId,
288 userId: payload.target.userId,
289 deviceId: payload.target.deviceId,
290 });
225291 }
226292 if (payload.aggregate !== null) {
227 return new ApnsDeliveryJobPushNotificationAggregateUnexpected();
293 return new ApnsDeliveryJobPushNotificationAggregateUnexpected({
294 jobId: payload.jobId,
295 userId: payload.target.userId,
296 deviceId: payload.target.deviceId,
297 });
228298 }
229299 return null;
230300 }
......@@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: {
264334 readonly job: SignedApnsDeliveryJob;
265335 readonly nowMs: number;
266336}): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError {
267 const invalidPayload = validatePayloadShape(input.job.payload);
337 const payload = input.job.payload;
338 const invalidPayload = validatePayloadShape(payload);
268339 if (invalidPayload !== null) {
269340 return invalidPayload;
270341 }
271 const createdAt = DateTime.make(input.job.payload.createdAt);
342 const createdAt = DateTime.make(payload.createdAt);
272343 if (Option.isNone(createdAt)) {
273 return new ApnsDeliveryJobCreatedAtInvalid();
344 return new ApnsDeliveryJobCreatedAtInvalid({
345 jobId: payload.jobId,
346 kind: payload.kind,
347 userId: payload.target.userId,
348 deviceId: payload.target.deviceId,
349 createdAt: payload.createdAt,
350 });
274351 }
275 const expiresAt = DateTime.make(input.job.payload.expiresAt);
352 const expiresAt = DateTime.make(payload.expiresAt);
276353 if (Option.isNone(expiresAt)) {
277 return new ApnsDeliveryJobExpiresAtInvalid();
354 return new ApnsDeliveryJobExpiresAtInvalid({
355 jobId: payload.jobId,
356 kind: payload.kind,
357 userId: payload.target.userId,
358 deviceId: payload.target.deviceId,
359 expiresAt: payload.expiresAt,
360 });
278361 }
279362 const createdAtMs = createdAt.value.epochMilliseconds;
280363 const expiresAtMs = expiresAt.value.epochMilliseconds;
281364 if (expiresAtMs <= createdAtMs) {
282 return new ApnsDeliveryJobTimeWindowInvalid();
365 return new ApnsDeliveryJobTimeWindowInvalid({
366 jobId: payload.jobId,
367 kind: payload.kind,
368 userId: payload.target.userId,
369 deviceId: payload.target.deviceId,
370 createdAt: payload.createdAt,
371 expiresAt: payload.expiresAt,
372 });
283373 }
284374 if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) {
285 return new ApnsDeliveryJobTimeWindowTooLong();
375 return new ApnsDeliveryJobTimeWindowTooLong({
376 jobId: payload.jobId,
377 kind: payload.kind,
378 userId: payload.target.userId,
379 deviceId: payload.target.deviceId,
380 createdAt: payload.createdAt,
381 expiresAt: payload.expiresAt,
382 });
286383 }
287384 if (expiresAtMs <= input.nowMs) {
288 return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt });
385 return new ApnsDeliveryJobExpired({
386 jobId: payload.jobId,
387 kind: payload.kind,
388 userId: payload.target.userId,
389 deviceId: payload.target.deviceId,
390 expiresAt: payload.expiresAt,
391 });
289392 }
290393 const expected = signatureForPayload({
291394 secret: input.secret,
292 payload: input.job.payload,
395 payload,
293396 });
294397 if (!timingSafeEqualBase64Url(input.job.signature, expected)) {
295 return new ApnsDeliveryJobSignatureInvalid();
398 return new ApnsDeliveryJobSignatureInvalid({
399 jobId: payload.jobId,
400 kind: payload.kind,
401 userId: payload.target.userId,
402 deviceId: payload.target.deviceId,
403 });
296404 }
297 return input.job.payload;
405 return payload;
298406}