import * as NodeCrypto from "node:crypto"; import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay"; import { stableStringify } from "@t3tools/shared/relaySigning"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; 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 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, body: Schema.String, environmentId: Schema.String, threadId: Schema.String, deepLink: Schema.String, }); export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, kind: ApnsDeliveryKindSchema, target: Schema.Struct({ userId: Schema.String, deviceId: Schema.String, token: Schema.String, }), aggregate: Schema.NullOr(RelayAgentActivityAggregateState), notification: Schema.NullOr(ApnsNotificationPayload), createdAt: Schema.String, expiresAt: Schema.String, }); export type ApnsDeliveryJobPayload = typeof ApnsDeliveryJobPayload.Type; export const SignedApnsDeliveryJob = Schema.Struct({ algorithm: Schema.Literal(APNS_DELIVERY_JOB_SIGNING_ALGORITHM), payload: ApnsDeliveryJobPayload, signature: Schema.String, }); 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 with ${this.receivedType} payload.`; } } export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( "ApnsDeliveryJobLiveActivityAggregateMissing", { ...ApnsDeliveryJobContext, kind: LiveActivityStartOrUpdateKindSchema, }, ) { override get message(): string { 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 `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 `APNs push notification job ${this.jobId} requires a notification payload.`; } } export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( "ApnsDeliveryJobPushNotificationAggregateUnexpected", ApnsDeliveryJobContext, ) { override get message(): string { 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 `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 `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 `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 ${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 signature for APNs delivery job ${this.jobId}.`; } } export const ApnsDeliveryJobInvalid = Schema.Union([ ApnsDeliveryJobQueuePayloadInvalid, ApnsDeliveryJobLiveActivityAggregateMissing, ApnsDeliveryJobLiveActivityNotificationUnexpected, ApnsDeliveryJobPushNotificationMissing, ApnsDeliveryJobPushNotificationAggregateUnexpected, ApnsDeliveryJobCreatedAtInvalid, ApnsDeliveryJobExpiresAtInvalid, ApnsDeliveryJobTimeWindowInvalid, ApnsDeliveryJobTimeWindowTooLong, ApnsDeliveryJobSignatureInvalid, ]); 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 ${this.jobId} expired at ${this.expiresAt}.`; } } export const ApnsDeliveryJobVerificationError = Schema.Union([ ApnsDeliveryJobInvalid, ApnsDeliveryJobExpired, ]); export type ApnsDeliveryJobVerificationError = typeof ApnsDeliveryJobVerificationError.Type; export const isApnsDeliveryJobVerificationError = Schema.is(ApnsDeliveryJobVerificationError); export function makeApnsDeliveryJobPayload(input: { readonly kind: RelayDeliveryKind; readonly userId: string; readonly deviceId: string; readonly token: string; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly notification?: ApnsNotificationPayload | null; readonly createdAt: string; readonly expiresAt: string; readonly jobId: string; }): ApnsDeliveryJobPayload { return { version: 1, jobId: input.jobId, kind: input.kind, target: { userId: input.userId, deviceId: input.deviceId, token: input.token, }, aggregate: input.aggregate, notification: input.notification ?? null, createdAt: input.createdAt, expiresAt: input.expiresAt, }; } export function expiresAtForJob(createdAtMs: number): string { return DateTime.formatIso(Option.getOrThrow(DateTime.make(createdAtMs + MAX_JOB_AGE_MS))); } function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobInvalid | null { switch (payload.kind) { case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { return new ApnsDeliveryJobLiveActivityAggregateMissing({ jobId: payload.jobId, kind: payload.kind, userId: payload.target.userId, deviceId: payload.target.deviceId, }); } if (payload.notification !== null) { 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({ 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({ jobId: payload.jobId, userId: payload.target.userId, deviceId: payload.target.deviceId, }); } if (payload.aggregate !== null) { return new ApnsDeliveryJobPushNotificationAggregateUnexpected({ jobId: payload.jobId, userId: payload.target.userId, deviceId: payload.target.deviceId, }); } return null; } } function signatureForPayload(input: { readonly secret: Redacted.Redacted; readonly payload: ApnsDeliveryJobPayload; }): string { return NodeCrypto.createHmac("sha256", Redacted.value(input.secret)) .update(stableStringify(input.payload)) .digest("base64url"); } function timingSafeEqualBase64Url(left: string, right: string): boolean { const leftBuffer = Buffer.from(left, "base64url"); const rightBuffer = Buffer.from(right, "base64url"); if (leftBuffer.length !== rightBuffer.length) { return false; } return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); } export function signApnsDeliveryJob(input: { readonly secret: Redacted.Redacted; readonly payload: ApnsDeliveryJobPayload; }): SignedApnsDeliveryJob { return { algorithm: APNS_DELIVERY_JOB_SIGNING_ALGORITHM, payload: input.payload, signature: signatureForPayload(input), }; } export function verifySignedApnsDeliveryJob(input: { readonly secret: Redacted.Redacted; readonly job: SignedApnsDeliveryJob; readonly nowMs: number; }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { const payload = input.job.payload; const invalidPayload = validatePayloadShape(payload); if (invalidPayload !== null) { return invalidPayload; } const createdAt = DateTime.make(payload.createdAt); if (Option.isNone(createdAt)) { return new ApnsDeliveryJobCreatedAtInvalid({ jobId: payload.jobId, kind: payload.kind, userId: payload.target.userId, deviceId: payload.target.deviceId, createdAt: payload.createdAt, }); } const expiresAt = DateTime.make(payload.expiresAt); if (Option.isNone(expiresAt)) { 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({ 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({ 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({ jobId: payload.jobId, kind: payload.kind, userId: payload.target.userId, deviceId: payload.target.deviceId, expiresAt: payload.expiresAt, }); } const expected = signatureForPayload({ secret: input.secret, payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { return new ApnsDeliveryJobSignatureInvalid({ jobId: payload.jobId, kind: payload.kind, userId: payload.target.userId, deviceId: payload.target.deviceId, }); } return payload; }