1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
2import * as NodeCrypto from "node:crypto";
3
4import {
5 RelayAgentActivityAggregateState,
6 RelayAgentAwarenessPhase,
7 type RelayDeliveryKind,
8} from "@t3tools/contracts/relay";
9import { stableStringify } from "@t3tools/shared/relaySigning";
10import * as DateTime from "effect/DateTime";
11import * as Option from "effect/Option";
12import * as Redacted from "effect/Redacted";
13import * as Schema from "effect/Schema";
14
15const MAX_JOB_AGE_MS = 10 * 60 * 1_000;
16export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256";
17
18const ApnsDeliveryKindSchema = Schema.Literals([
19 "live_activity_start",
20 "live_activity_update",
21 "live_activity_end",
22 "push_notification",
23]);
24const LiveActivityStartOrUpdateKindSchema = Schema.Literals([
25 "live_activity_start",
26 "live_activity_update",
27]);
28const LiveActivityKindSchema = Schema.Literals([
29 "live_activity_start",
30 "live_activity_update",
31 "live_activity_end",
32]);
33
34const ApnsDeliveryJobContext = {
35 jobId: Schema.String,
36 userId: Schema.String,
37 deviceId: Schema.String,
38};
39
40export const ApnsNotificationPayload = Schema.Struct({
41 title: Schema.String,
42 body: Schema.String,
43 environmentId: Schema.String,
44 threadId: Schema.String,
45 deepLink: Schema.String,
46 // Optional so delivery jobs queued by older relay builds still decode.
47 // New jobs use these fields to avoid delivering a stale Done/attention
48 // notification after the thread has moved to another phase.
49 phase: Schema.optional(RelayAgentAwarenessPhase),
50 updatedAt: Schema.optional(Schema.String),
51});
52export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
53
54// Alert copy attached to a Live Activity update/end push. Its presence makes
55// the update "alerting": iOS wakes the screen, plays the haptic, and briefly
56// expands the Dynamic Island instead of silently redrawing.
57export const ApnsLiveActivityAlert = Schema.Struct({
58 title: Schema.String,
59 body: Schema.String,
60});
61export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type;
62
63export const ApnsDeliveryJobPayload = Schema.Struct({
64 version: Schema.Literal(1),
65 jobId: Schema.String,
66 kind: ApnsDeliveryKindSchema,
67 target: Schema.Struct({
68 userId: Schema.String,
69 deviceId: Schema.String,
70 token: Schema.String,
71 // Per-device APNs routing; absent on jobs queued by older relay builds,
72 // which fall back to the configured defaults.
73 bundleId: Schema.optional(Schema.NullOr(Schema.String)),
74 apsEnvironment: Schema.optional(Schema.NullOr(Schema.Literals(["sandbox", "production"]))),
75 }),
76 aggregate: Schema.NullOr(RelayAgentActivityAggregateState),
77 notification: Schema.NullOr(ApnsNotificationPayload),
78 // Optional so jobs queued by older relay builds still decode.
79 alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)),
80 createdAt: Schema.String,
81 expiresAt: Schema.String,
82});
83export type ApnsDeliveryJobPayload = typeof ApnsDeliveryJobPayload.Type;
84
85export const SignedApnsDeliveryJob = Schema.Struct({
86 algorithm: Schema.Literal(APNS_DELIVERY_JOB_SIGNING_ALGORITHM),
87 payload: ApnsDeliveryJobPayload,
88 signature: Schema.String,
89});
90export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type;
91
92export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedError<ApnsDeliveryJobQueuePayloadInvalid>()(
93 "ApnsDeliveryJobQueuePayloadInvalid",
94 {
95 receivedType: Schema.String,
96 cause: Schema.Defect(),
97 },
98) {
99 override get message(): string {
100 return `Invalid APNs delivery queue job with ${this.receivedType} payload.`;
101 }
102}
103
104export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedError<ApnsDeliveryJobLiveActivityAggregateMissing>()(
105 "ApnsDeliveryJobLiveActivityAggregateMissing",
106 {
107 ...ApnsDeliveryJobContext,
108 kind: LiveActivityStartOrUpdateKindSchema,
109 },
110) {
111 override get message(): string {
112 return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} requires an aggregate.`;
113 }
114}
115
116export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedError<ApnsDeliveryJobLiveActivityNotificationUnexpected>()(
117 "ApnsDeliveryJobLiveActivityNotificationUnexpected",
118 {
119 ...ApnsDeliveryJobContext,
120 kind: LiveActivityKindSchema,
121 },
122) {
123 override get message(): string {
124 return `APNs ${this.kind.replaceAll("_", " ")} job ${this.jobId} must not carry a push notification payload.`;
125 }
126}
127
128export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedError<ApnsDeliveryJobPushNotificationMissing>()(
129 "ApnsDeliveryJobPushNotificationMissing",
130 ApnsDeliveryJobContext,
131) {
132 override get message(): string {
133 return `APNs push notification job ${this.jobId} requires a notification payload.`;
134 }
135}
136
137export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedError<ApnsDeliveryJobPushNotificationAggregateUnexpected>()(
138 "ApnsDeliveryJobPushNotificationAggregateUnexpected",
139 ApnsDeliveryJobContext,
140) {
141 override get message(): string {
142 return `APNs push notification job ${this.jobId} must not carry aggregate state.`;
143 }
144}
145
146export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedError<ApnsDeliveryJobCreatedAtInvalid>()(
147 "ApnsDeliveryJobCreatedAtInvalid",
148 {
149 ...ApnsDeliveryJobContext,
150 kind: ApnsDeliveryKindSchema,
151 createdAt: Schema.String,
152 },
153) {
154 override get message(): string {
155 return `APNs delivery job ${this.jobId} has invalid creation time ${this.createdAt}.`;
156 }
157}
158
159export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedError<ApnsDeliveryJobExpiresAtInvalid>()(
160 "ApnsDeliveryJobExpiresAtInvalid",
161 {
162 ...ApnsDeliveryJobContext,
163 kind: ApnsDeliveryKindSchema,
164 expiresAt: Schema.String,
165 },
166) {
167 override get message(): string {
168 return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`;
169 }
170}
171
172export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedError<ApnsDeliveryJobTimeWindowInvalid>()(
173 "ApnsDeliveryJobTimeWindowInvalid",
174 {
175 ...ApnsDeliveryJobContext,
176 kind: ApnsDeliveryKindSchema,
177 createdAt: Schema.String,
178 expiresAt: Schema.String,
179 },
180) {
181 override get message(): string {
182 return `APNs delivery job ${this.jobId} has invalid time window ${this.createdAt} to ${this.expiresAt}.`;
183 }
184}
185
186export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedError<ApnsDeliveryJobTimeWindowTooLong>()(
187 "ApnsDeliveryJobTimeWindowTooLong",
188 {
189 ...ApnsDeliveryJobContext,
190 kind: ApnsDeliveryKindSchema,
191 createdAt: Schema.String,
192 expiresAt: Schema.String,
193 },
194) {
195 override get message(): string {
196 return `APNs delivery job ${this.jobId} time window ${this.createdAt} to ${this.expiresAt} is too long.`;
197 }
198}
199
200export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedError<ApnsDeliveryJobSignatureInvalid>()(
201 "ApnsDeliveryJobSignatureInvalid",
202 {
203 ...ApnsDeliveryJobContext,
204 kind: ApnsDeliveryKindSchema,
205 },
206) {
207 override get message(): string {
208 return `Invalid signature for APNs delivery job ${this.jobId}.`;
209 }
210}
211
212export const ApnsDeliveryJobInvalid = Schema.Union([
213 ApnsDeliveryJobQueuePayloadInvalid,
214 ApnsDeliveryJobLiveActivityAggregateMissing,
215 ApnsDeliveryJobLiveActivityNotificationUnexpected,
216 ApnsDeliveryJobPushNotificationMissing,
217 ApnsDeliveryJobPushNotificationAggregateUnexpected,
218 ApnsDeliveryJobCreatedAtInvalid,
219 ApnsDeliveryJobExpiresAtInvalid,
220 ApnsDeliveryJobTimeWindowInvalid,
221 ApnsDeliveryJobTimeWindowTooLong,
222 ApnsDeliveryJobSignatureInvalid,
223]);
224export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type;
225
226export class ApnsDeliveryJobExpired extends Schema.TaggedError<ApnsDeliveryJobExpired>()(
227 "ApnsDeliveryJobExpired",
228 {
229 ...ApnsDeliveryJobContext,
230 kind: ApnsDeliveryKindSchema,
231 expiresAt: Schema.String,
232 },
233) {
234 override get message(): string {
235 return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`;
236 }
237}
238
239export const ApnsDeliveryJobVerificationError = Schema.Union([
240 ApnsDeliveryJobInvalid,
241 ApnsDeliveryJobExpired,
242]);
243export type ApnsDeliveryJobVerificationError = typeof ApnsDeliveryJobVerificationError.Type;
244
245export const isApnsDeliveryJobVerificationError = Schema.is(ApnsDeliveryJobVerificationError);
246
247export function makeApnsDeliveryJobPayload(input: {
248 readonly kind: RelayDeliveryKind;
249 readonly userId: string;
250 readonly deviceId: string;
251 readonly token: string;
252 readonly bundleId?: string | null | undefined;
253 readonly apsEnvironment?: "sandbox" | "production" | null | undefined;
254 readonly aggregate: ApnsDeliveryJobPayload["aggregate"];
255 readonly notification?: ApnsNotificationPayload | null;
256 readonly alert?: ApnsLiveActivityAlert | null | undefined;
257 readonly createdAt: string;
258 readonly expiresAt: string;
259 readonly jobId: string;
260}): ApnsDeliveryJobPayload {
261 return {
262 version: 1,
263 jobId: input.jobId,
264 kind: input.kind,
265 target: {
266 userId: input.userId,
267 deviceId: input.deviceId,
268 token: input.token,
269 ...(input.bundleId ? { bundleId: input.bundleId } : {}),
270 ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
271 },
272 aggregate: input.aggregate,
273 notification: input.notification ?? null,
274 // Omitted (not null) when absent so signatures stay identical to jobs from
275 // relay builds that predate the field.
276 ...(input.alert ? { alert: input.alert } : {}),
277 createdAt: input.createdAt,
278 expiresAt: input.expiresAt,
279 };
280}
281
282export function expiresAtForJob(createdAtMs: number): string {
283 return DateTime.formatIso(Option.getOrThrow(DateTime.make(createdAtMs + MAX_JOB_AGE_MS)));
284}
285
286function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobInvalid | null {
287 switch (payload.kind) {
288 case "live_activity_start":
289 case "live_activity_update":
290 if (payload.aggregate === null) {
291 return new ApnsDeliveryJobLiveActivityAggregateMissing({
292 jobId: payload.jobId,
293 kind: payload.kind,
294 userId: payload.target.userId,
295 deviceId: payload.target.deviceId,
296 });
297 }
298 if (payload.notification !== null) {
299 return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
300 jobId: payload.jobId,
301 kind: payload.kind,
302 userId: payload.target.userId,
303 deviceId: payload.target.deviceId,
304 });
305 }
306 return null;
307 case "live_activity_end":
308 if (payload.notification !== null) {
309 return new ApnsDeliveryJobLiveActivityNotificationUnexpected({
310 jobId: payload.jobId,
311 kind: payload.kind,
312 userId: payload.target.userId,
313 deviceId: payload.target.deviceId,
314 });
315 }
316 return null;
317 case "push_notification":
318 if (payload.notification === null) {
319 return new ApnsDeliveryJobPushNotificationMissing({
320 jobId: payload.jobId,
321 userId: payload.target.userId,
322 deviceId: payload.target.deviceId,
323 });
324 }
325 if (payload.aggregate !== null) {
326 return new ApnsDeliveryJobPushNotificationAggregateUnexpected({
327 jobId: payload.jobId,
328 userId: payload.target.userId,
329 deviceId: payload.target.deviceId,
330 });
331 }
332 return null;
333 }
334}
335
336function signatureForPayload(input: {
337 readonly secret: Redacted.Redacted<string>;
338 readonly payload: ApnsDeliveryJobPayload;
339}): string {
340 return NodeCrypto.createHmac("sha256", Redacted.value(input.secret))
341 .update(stableStringify(input.payload))
342 .digest("base64url");
343}
344
345function timingSafeEqualBase64Url(left: string, right: string): boolean {
346 const leftBuffer = Buffer.from(left, "base64url");
347 const rightBuffer = Buffer.from(right, "base64url");
348 if (leftBuffer.length !== rightBuffer.length) {
349 return false;
350 }
351 return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer);
352}
353
354export function signApnsDeliveryJob(input: {
355 readonly secret: Redacted.Redacted<string>;
356 readonly payload: ApnsDeliveryJobPayload;
357}): SignedApnsDeliveryJob {
358 return {
359 algorithm: APNS_DELIVERY_JOB_SIGNING_ALGORITHM,
360 payload: input.payload,
361 signature: signatureForPayload(input),
362 };
363}
364
365export function verifySignedApnsDeliveryJob(input: {
366 readonly secret: Redacted.Redacted<string>;
367 readonly job: SignedApnsDeliveryJob;
368 readonly nowMs: number;
369}): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError {
370 const payload = input.job.payload;
371 const invalidPayload = validatePayloadShape(payload);
372 if (invalidPayload !== null) {
373 return invalidPayload;
374 }
375 const createdAt = DateTime.make(payload.createdAt);
376 if (Option.isNone(createdAt)) {
377 return new ApnsDeliveryJobCreatedAtInvalid({
378 jobId: payload.jobId,
379 kind: payload.kind,
380 userId: payload.target.userId,
381 deviceId: payload.target.deviceId,
382 createdAt: payload.createdAt,
383 });
384 }
385 const expiresAt = DateTime.make(payload.expiresAt);
386 if (Option.isNone(expiresAt)) {
387 return new ApnsDeliveryJobExpiresAtInvalid({
388 jobId: payload.jobId,
389 kind: payload.kind,
390 userId: payload.target.userId,
391 deviceId: payload.target.deviceId,
392 expiresAt: payload.expiresAt,
393 });
394 }
395 const createdAtMs = createdAt.value.epochMilliseconds;
396 const expiresAtMs = expiresAt.value.epochMilliseconds;
397 if (expiresAtMs <= createdAtMs) {
398 return new ApnsDeliveryJobTimeWindowInvalid({
399 jobId: payload.jobId,
400 kind: payload.kind,
401 userId: payload.target.userId,
402 deviceId: payload.target.deviceId,
403 createdAt: payload.createdAt,
404 expiresAt: payload.expiresAt,
405 });
406 }
407 if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) {
408 return new ApnsDeliveryJobTimeWindowTooLong({
409 jobId: payload.jobId,
410 kind: payload.kind,
411 userId: payload.target.userId,
412 deviceId: payload.target.deviceId,
413 createdAt: payload.createdAt,
414 expiresAt: payload.expiresAt,
415 });
416 }
417 if (expiresAtMs <= input.nowMs) {
418 return new ApnsDeliveryJobExpired({
419 jobId: payload.jobId,
420 kind: payload.kind,
421 userId: payload.target.userId,
422 deviceId: payload.target.deviceId,
423 expiresAt: payload.expiresAt,
424 });
425 }
426 const expected = signatureForPayload({
427 secret: input.secret,
428 payload,
429 });
430 if (!timingSafeEqualBase64Url(input.job.signature, expected)) {
431 return new ApnsDeliveryJobSignatureInvalid({
432 jobId: payload.jobId,
433 kind: payload.kind,
434 userId: payload.target.userId,
435 deviceId: payload.target.deviceId,
436 });
437 }
438 return payload;
439}