| ... | @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; | ... | @@ -10,12 +10,27 @@ import * as Schema from "effect/Schema"; |
| 10 | const MAX_JOB_AGE_MS = 10 * 60 * 1_000; | 10 | const MAX_JOB_AGE_MS = 10 * 60 * 1_000; |
| 11 | export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; | 11 | export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; |
| 12 | | 12 | |
| 13 | const ApnsDeliveryKind = Schema.Literals([ | 13 | const ApnsDeliveryKindSchema = Schema.Literals([ |
| 14 | "live_activity_start", | 14 | "live_activity_start", |
| 15 | "live_activity_update", | 15 | "live_activity_update", |
| 16 | "live_activity_end", | 16 | "live_activity_end", |
| 17 | "push_notification", | 17 | "push_notification", |
| 18 | ]); | 18 | ]); |
| | 19 | const LiveActivityStartOrUpdateKindSchema = Schema.Literals([ |
| | 20 | "live_activity_start", |
| | 21 | "live_activity_update", |
| | 22 | ]); |
| | 23 | const LiveActivityKindSchema = Schema.Literals([ |
| | 24 | "live_activity_start", |
| | 25 | "live_activity_update", |
| | 26 | "live_activity_end", |
| | 27 | ]); |
| | 28 | |
| | 29 | const ApnsDeliveryJobContext = { |
| | 30 | jobId: Schema.String, |
| | 31 | userId: Schema.String, |
| | 32 | deviceId: Schema.String, |
| | 33 | }; |
| 19 | | 34 | |
| 20 | export const ApnsNotificationPayload = Schema.Struct({ | 35 | export const ApnsNotificationPayload = Schema.Struct({ |
| 21 | title: Schema.String, | 36 | title: Schema.String, |
| ... | @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; | ... | @@ -29,7 +44,7 @@ export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; |
| 29 | export const ApnsDeliveryJobPayload = Schema.Struct({ | 44 | export const ApnsDeliveryJobPayload = Schema.Struct({ |
| 30 | version: Schema.Literal(1), | 45 | version: Schema.Literal(1), |
| 31 | jobId: Schema.String, | 46 | jobId: Schema.String, |
| 32 | kind: ApnsDeliveryKind, | 47 | kind: ApnsDeliveryKindSchema, |
| 33 | target: Schema.Struct({ | 48 | target: Schema.Struct({ |
| 34 | userId: Schema.String, | 49 | userId: Schema.String, |
| 35 | deviceId: Schema.String, | 50 | deviceId: Schema.String, |
| ... | @@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; | ... | @@ -51,91 +66,121 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; |
| 51 | | 66 | |
| 52 | export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobQueuePayloadInvalid>()( | 67 | export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobQueuePayloadInvalid>()( |
| 53 | "ApnsDeliveryJobQueuePayloadInvalid", | 68 | "ApnsDeliveryJobQueuePayloadInvalid", |
| 54 | {}, | 69 | { |
| | 70 | receivedType: Schema.String, |
| | 71 | cause: Schema.Defect(), |
| | 72 | }, |
| 55 | ) { | 73 | ) { |
| 56 | override get message(): string { | 74 | override get message(): string { |
| 57 | return "Invalid APNs delivery queue job."; | 75 | return `Invalid APNs delivery queue job with ${this.receivedType} payload.`; |
| 58 | } | 76 | } |
| 59 | } | 77 | } |
| 60 | | 78 | |
| 61 | export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityAggregateMissing>()( | 79 | export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityAggregateMissing>()( |
| 62 | "ApnsDeliveryJobLiveActivityAggregateMissing", | 80 | "ApnsDeliveryJobLiveActivityAggregateMissing", |
| 63 | {}, | 81 | { |
| | 82 | ...ApnsDeliveryJobContext, |
| | 83 | kind: LiveActivityStartOrUpdateKindSchema, |
| | 84 | }, |
| 64 | ) { | 85 | ) { |
| 65 | override get message(): string { | 86 | 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.`; |
| 67 | } | 88 | } |
| 68 | } | 89 | } |
| 69 | | 90 | |
| 70 | export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityNotificationUnexpected>()( | 91 | export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobLiveActivityNotificationUnexpected>()( |
| 71 | "ApnsDeliveryJobLiveActivityNotificationUnexpected", | 92 | "ApnsDeliveryJobLiveActivityNotificationUnexpected", |
| 72 | {}, | 93 | { |
| | 94 | ...ApnsDeliveryJobContext, |
| | 95 | kind: LiveActivityKindSchema, |
| | 96 | }, |
| 73 | ) { | 97 | ) { |
| 74 | override get message(): string { | 98 | 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.`; |
| 76 | } | 100 | } |
| 77 | } | 101 | } |
| 78 | | 102 | |
| 79 | export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationMissing>()( | 103 | export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationMissing>()( |
| 80 | "ApnsDeliveryJobPushNotificationMissing", | 104 | "ApnsDeliveryJobPushNotificationMissing", |
| 81 | {}, | 105 | ApnsDeliveryJobContext, |
| 82 | ) { | 106 | ) { |
| 83 | override get message(): string { | 107 | 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.`; |
| 85 | } | 109 | } |
| 86 | } | 110 | } |
| 87 | | 111 | |
| 88 | export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationAggregateUnexpected>()( | 112 | export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass<ApnsDeliveryJobPushNotificationAggregateUnexpected>()( |
| 89 | "ApnsDeliveryJobPushNotificationAggregateUnexpected", | 113 | "ApnsDeliveryJobPushNotificationAggregateUnexpected", |
| 90 | {}, | 114 | ApnsDeliveryJobContext, |
| 91 | ) { | 115 | ) { |
| 92 | override get message(): string { | 116 | 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.`; |
| 94 | } | 118 | } |
| 95 | } | 119 | } |
| 96 | | 120 | |
| 97 | export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobCreatedAtInvalid>()( | 121 | export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobCreatedAtInvalid>()( |
| 98 | "ApnsDeliveryJobCreatedAtInvalid", | 122 | "ApnsDeliveryJobCreatedAtInvalid", |
| 99 | {}, | 123 | { |
| | 124 | ...ApnsDeliveryJobContext, |
| | 125 | kind: ApnsDeliveryKindSchema, |
| | 126 | createdAt: Schema.String, |
| | 127 | }, |
| 100 | ) { | 128 | ) { |
| 101 | override get message(): string { | 129 | 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}.`; |
| 103 | } | 131 | } |
| 104 | } | 132 | } |
| 105 | | 133 | |
| 106 | export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobExpiresAtInvalid>()( | 134 | export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobExpiresAtInvalid>()( |
| 107 | "ApnsDeliveryJobExpiresAtInvalid", | 135 | "ApnsDeliveryJobExpiresAtInvalid", |
| 108 | {}, | 136 | { |
| | 137 | ...ApnsDeliveryJobContext, |
| | 138 | kind: ApnsDeliveryKindSchema, |
| | 139 | expiresAt: Schema.String, |
| | 140 | }, |
| 109 | ) { | 141 | ) { |
| 110 | override get message(): string { | 142 | override get message(): string { |
| 111 | return "Invalid APNs delivery job expiry."; | 143 | return `APNs delivery job ${this.jobId} has invalid expiry ${this.expiresAt}.`; |
| 112 | } | 144 | } |
| 113 | } | 145 | } |
| 114 | | 146 | |
| 115 | export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowInvalid>()( | 147 | export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowInvalid>()( |
| 116 | "ApnsDeliveryJobTimeWindowInvalid", | 148 | "ApnsDeliveryJobTimeWindowInvalid", |
| 117 | {}, | 149 | { |
| | 150 | ...ApnsDeliveryJobContext, |
| | 151 | kind: ApnsDeliveryKindSchema, |
| | 152 | createdAt: Schema.String, |
| | 153 | expiresAt: Schema.String, |
| | 154 | }, |
| 118 | ) { | 155 | ) { |
| 119 | override get message(): string { | 156 | 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}.`; |
| 121 | } | 158 | } |
| 122 | } | 159 | } |
| 123 | | 160 | |
| 124 | export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowTooLong>()( | 161 | export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass<ApnsDeliveryJobTimeWindowTooLong>()( |
| 125 | "ApnsDeliveryJobTimeWindowTooLong", | 162 | "ApnsDeliveryJobTimeWindowTooLong", |
| 126 | {}, | 163 | { |
| | 164 | ...ApnsDeliveryJobContext, |
| | 165 | kind: ApnsDeliveryKindSchema, |
| | 166 | createdAt: Schema.String, |
| | 167 | expiresAt: Schema.String, |
| | 168 | }, |
| 127 | ) { | 169 | ) { |
| 128 | override get message(): string { | 170 | 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.`; |
| 130 | } | 172 | } |
| 131 | } | 173 | } |
| 132 | | 174 | |
| 133 | export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobSignatureInvalid>()( | 175 | export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobSignatureInvalid>()( |
| 134 | "ApnsDeliveryJobSignatureInvalid", | 176 | "ApnsDeliveryJobSignatureInvalid", |
| 135 | {}, | 177 | { |
| | 178 | ...ApnsDeliveryJobContext, |
| | 179 | kind: ApnsDeliveryKindSchema, |
| | 180 | }, |
| 136 | ) { | 181 | ) { |
| 137 | override get message(): string { | 182 | override get message(): string { |
| 138 | return "Invalid APNs delivery job signature."; | 183 | return `Invalid signature for APNs delivery job ${this.jobId}.`; |
| 139 | } | 184 | } |
| 140 | } | 185 | } |
| 141 | | 186 | |
| ... | @@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; | ... | @@ -156,11 +201,13 @@ export type ApnsDeliveryJobInvalid = typeof ApnsDeliveryJobInvalid.Type; |
| 156 | export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()( | 201 | export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()( |
| 157 | "ApnsDeliveryJobExpired", | 202 | "ApnsDeliveryJobExpired", |
| 158 | { | 203 | { |
| | 204 | ...ApnsDeliveryJobContext, |
| | 205 | kind: ApnsDeliveryKindSchema, |
| 159 | expiresAt: Schema.String, | 206 | expiresAt: Schema.String, |
| 160 | }, | 207 | }, |
| 161 | ) { | 208 | ) { |
| 162 | override get message(): string { | 209 | override get message(): string { |
| 163 | return `APNs delivery job expired at ${this.expiresAt}`; | 210 | return `APNs delivery job ${this.jobId} expired at ${this.expiresAt}.`; |
| 164 | } | 211 | } |
| 165 | } | 212 | } |
| 166 | | 213 | |
| ... | @@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI | ... | @@ -208,23 +255,46 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI |
| 208 | case "live_activity_start": | 255 | case "live_activity_start": |
| 209 | case "live_activity_update": | 256 | case "live_activity_update": |
| 210 | if (payload.aggregate === null) { | 257 | 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 | }); |
| 212 | } | 264 | } |
| 213 | if (payload.notification !== null) { | 265 | 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 | }); |
| 215 | } | 272 | } |
| 216 | return null; | 273 | return null; |
| 217 | case "live_activity_end": | 274 | case "live_activity_end": |
| 218 | if (payload.notification !== null) { | 275 | 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 | }); |
| 220 | } | 282 | } |
| 221 | return null; | 283 | return null; |
| 222 | case "push_notification": | 284 | case "push_notification": |
| 223 | if (payload.notification === null) { | 285 | 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 | }); |
| 225 | } | 291 | } |
| 226 | if (payload.aggregate !== null) { | 292 | 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 | }); |
| 228 | } | 298 | } |
| 229 | return null; | 299 | return null; |
| 230 | } | 300 | } |
| ... | @@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { | ... | @@ -264,35 +334,73 @@ export function verifySignedApnsDeliveryJob(input: { |
| 264 | readonly job: SignedApnsDeliveryJob; | 334 | readonly job: SignedApnsDeliveryJob; |
| 265 | readonly nowMs: number; | 335 | readonly nowMs: number; |
| 266 | }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { | 336 | }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { |
| 267 | const invalidPayload = validatePayloadShape(input.job.payload); | 337 | const payload = input.job.payload; |
| | 338 | const invalidPayload = validatePayloadShape(payload); |
| 268 | if (invalidPayload !== null) { | 339 | if (invalidPayload !== null) { |
| 269 | return invalidPayload; | 340 | return invalidPayload; |
| 270 | } | 341 | } |
| 271 | const createdAt = DateTime.make(input.job.payload.createdAt); | 342 | const createdAt = DateTime.make(payload.createdAt); |
| 272 | if (Option.isNone(createdAt)) { | 343 | 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 | }); |
| 274 | } | 351 | } |
| 275 | const expiresAt = DateTime.make(input.job.payload.expiresAt); | 352 | const expiresAt = DateTime.make(payload.expiresAt); |
| 276 | if (Option.isNone(expiresAt)) { | 353 | 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 | }); |
| 278 | } | 361 | } |
| 279 | const createdAtMs = createdAt.value.epochMilliseconds; | 362 | const createdAtMs = createdAt.value.epochMilliseconds; |
| 280 | const expiresAtMs = expiresAt.value.epochMilliseconds; | 363 | const expiresAtMs = expiresAt.value.epochMilliseconds; |
| 281 | if (expiresAtMs <= createdAtMs) { | 364 | 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 | }); |
| 283 | } | 373 | } |
| 284 | if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { | 374 | 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 | }); |
| 286 | } | 383 | } |
| 287 | if (expiresAtMs <= input.nowMs) { | 384 | 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 | }); |
| 289 | } | 392 | } |
| 290 | const expected = signatureForPayload({ | 393 | const expected = signatureForPayload({ |
| 291 | secret: input.secret, | 394 | secret: input.secret, |
| 292 | payload: input.job.payload, | 395 | payload, |
| 293 | }); | 396 | }); |
| 294 | if (!timingSafeEqualBase64Url(input.job.signature, expected)) { | 397 | 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 | }); |
| 296 | } | 404 | } |
| 297 | return input.job.payload; | 405 | return payload; |
| 298 | } | 406 | } |