| ... | ... | @@ -11,14 +11,17 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Headers from "effect/unstable/http/Headers"; |
| 12 | 12 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 13 | 13 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; |
| 14 | | import type * as RelayConfiguration from "../Config.ts"; |
| 14 | import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; |
| 15 | 15 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 16 | 16 | |
| 17 | 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; |
| 18 | 18 | const STALE_AFTER_SECONDS = 2 * 60; |
| 19 | 19 | const DISMISS_AFTER_SECONDS = 5 * 60; |
| 20 | 20 | |
| 21 | | export type ApnsLiveActivityEvent = "start" | "update" | "end"; |
| 21 | const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); |
| 22 | export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; |
| 23 | |
| 24 | const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]); |
| 22 | 25 | |
| 23 | 26 | interface ApnsLiveActivityRequest { |
| 24 | 27 | readonly token: string; |
| ... | ... | @@ -42,41 +45,48 @@ export interface ApnsDeliveryResult { |
| 42 | 45 | |
| 43 | 46 | export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()( |
| 44 | 47 | "ApnsJwtEncodingError", |
| 45 | | { cause: Schema.Defect() }, |
| 48 | { |
| 49 | component: Schema.Literals(["header", "payload"]), |
| 50 | teamId: Schema.String, |
| 51 | keyId: Schema.String, |
| 52 | issuedAtUnixSeconds: Schema.Number, |
| 53 | cause: Schema.Defect(), |
| 54 | }, |
| 46 | 55 | ) { |
| 47 | 56 | override get message(): string { |
| 48 | | return "Failed to encode APNs JWT."; |
| 57 | return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; |
| 49 | 58 | } |
| 50 | 59 | } |
| 51 | 60 | |
| 52 | 61 | export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()( |
| 53 | 62 | "ApnsJwtSigningError", |
| 54 | | { cause: Schema.Defect() }, |
| 55 | | ) { |
| 56 | | override get message(): string { |
| 57 | | return "Failed to sign APNs JWT."; |
| 58 | | } |
| 59 | | } |
| 60 | | |
| 61 | | export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()( |
| 62 | | "ApnsHttpRequestError", |
| 63 | 63 | { |
| 64 | teamId: Schema.String, |
| 65 | keyId: Schema.String, |
| 66 | issuedAtUnixSeconds: Schema.Number, |
| 64 | 67 | cause: Schema.Defect(), |
| 65 | 68 | }, |
| 66 | 69 | ) { |
| 67 | 70 | override get message(): string { |
| 68 | | return "APNs HTTP request failed."; |
| 71 | return `Failed to sign APNs JWT for key ${this.keyId}.`; |
| 69 | 72 | } |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | | export class ApnsInvalidResponseError extends Schema.TaggedErrorClass<ApnsInvalidResponseError>()( |
| 73 | | "ApnsInvalidResponseError", |
| 75 | export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()( |
| 76 | "ApnsHttpRequestError", |
| 74 | 77 | { |
| 78 | requestKind: ApnsRequestKindSchema, |
| 79 | event: Schema.NullOr(ApnsLiveActivityEventSchema), |
| 80 | environment: ApnsEnvironmentSchema, |
| 81 | bundleId: Schema.String, |
| 82 | tokenSuffix: Schema.String, |
| 83 | stage: Schema.Literals(["send", "read-response"]), |
| 84 | status: Schema.NullOr(Schema.Number), |
| 75 | 85 | cause: Schema.Defect(), |
| 76 | 86 | }, |
| 77 | 87 | ) { |
| 78 | 88 | override get message(): string { |
| 79 | | return "APNs returned an invalid response."; |
| 89 | return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`; |
| 80 | 90 | } |
| 81 | 91 | } |
| 82 | 92 | |
| ... | ... | @@ -84,7 +94,6 @@ export const ApnsError = Schema.Union([ |
| 84 | 94 | ApnsJwtEncodingError, |
| 85 | 95 | ApnsJwtSigningError, |
| 86 | 96 | ApnsHttpRequestError, |
| 87 | | ApnsInvalidResponseError, |
| 88 | 97 | ]); |
| 89 | 98 | export type ApnsError = typeof ApnsError.Type; |
| 90 | 99 | |
| ... | ... | @@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( |
| 113 | 122 | ); |
| 114 | 123 | |
| 115 | 124 | const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { |
| 116 | | readonly teamId: RelayConfiguration.ApnsCredentials["teamId"]; |
| 117 | | readonly keyId: RelayConfiguration.ApnsCredentials["keyId"]; |
| 118 | | readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"]; |
| 125 | readonly teamId: ApnsCredentials["teamId"]; |
| 126 | readonly keyId: ApnsCredentials["keyId"]; |
| 127 | readonly privateKey: ApnsCredentials["privateKey"]; |
| 119 | 128 | readonly issuedAtUnixSeconds: number; |
| 120 | 129 | }) { |
| 121 | 130 | const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( |
| 122 | | Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })), |
| 131 | Effect.mapError( |
| 132 | (cause) => |
| 133 | new ApnsJwtEncodingError({ |
| 134 | component: "header", |
| 135 | teamId: input.teamId, |
| 136 | keyId: input.keyId, |
| 137 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, |
| 138 | cause, |
| 139 | }), |
| 140 | ), |
| 123 | 141 | ); |
| 124 | 142 | const payloadJson = yield* encodeApnsJwtPayloadJson({ |
| 125 | 143 | iss: input.teamId, |
| 126 | 144 | iat: input.issuedAtUnixSeconds, |
| 127 | | }).pipe(Effect.mapError((cause) => new ApnsJwtEncodingError({ cause }))); |
| 145 | }).pipe( |
| 146 | Effect.mapError( |
| 147 | (cause) => |
| 148 | new ApnsJwtEncodingError({ |
| 149 | component: "payload", |
| 150 | teamId: input.teamId, |
| 151 | keyId: input.keyId, |
| 152 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, |
| 153 | cause, |
| 154 | }), |
| 155 | ), |
| 156 | ); |
| 128 | 157 | |
| 129 | 158 | const privateKey = Redacted.value(input.privateKey); |
| 130 | 159 | const header = Encoding.encodeBase64Url(headerJson); |
| ... | ... | @@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { |
| 141 | 170 | }); |
| 142 | 171 | return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; |
| 143 | 172 | }, |
| 144 | | catch: (cause) => new ApnsJwtSigningError({ cause }), |
| 173 | catch: (cause) => |
| 174 | new ApnsJwtSigningError({ |
| 175 | teamId: input.teamId, |
| 176 | keyId: input.keyId, |
| 177 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, |
| 178 | cause, |
| 179 | }), |
| 145 | 180 | }); |
| 146 | 181 | }); |
| 147 | 182 | |
| ... | ... | @@ -251,12 +286,12 @@ export class ApnsClient extends Context.Service< |
| 251 | 286 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; |
| 252 | 287 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; |
| 253 | 288 | readonly sendLiveActivityRequest: (input: { |
| 254 | | readonly credentials: RelayConfiguration.ApnsCredentials; |
| 289 | readonly credentials: ApnsCredentials; |
| 255 | 290 | readonly request: ApnsLiveActivityRequest; |
| 256 | 291 | readonly issuedAtUnixSeconds: number; |
| 257 | 292 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; |
| 258 | 293 | readonly sendPushNotificationRequest: (input: { |
| 259 | | readonly credentials: RelayConfiguration.ApnsCredentials; |
| 294 | readonly credentials: ApnsCredentials; |
| 260 | 295 | readonly request: ApnsPushNotificationRequest; |
| 261 | 296 | readonly issuedAtUnixSeconds: number; |
| 262 | 297 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; |
| ... | ... | @@ -287,10 +322,34 @@ export const make = Effect.gen(function* () { |
| 287 | 322 | }), |
| 288 | 323 | HttpClientRequest.bodyJson(input.request.payload), |
| 289 | 324 | Effect.flatMap(httpClient.execute), |
| 290 | | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), |
| 325 | Effect.mapError( |
| 326 | (cause) => |
| 327 | new ApnsHttpRequestError({ |
| 328 | requestKind: "live-activity", |
| 329 | event: input.request.event, |
| 330 | environment: input.credentials.environment, |
| 331 | bundleId: input.credentials.bundleId, |
| 332 | tokenSuffix: input.request.token.slice(-8), |
| 333 | stage: "send", |
| 334 | status: null, |
| 335 | cause, |
| 336 | }), |
| 337 | ), |
| 291 | 338 | ); |
| 292 | 339 | const responseText = yield* response.text.pipe( |
| 293 | | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), |
| 340 | Effect.mapError( |
| 341 | (cause) => |
| 342 | new ApnsHttpRequestError({ |
| 343 | requestKind: "live-activity", |
| 344 | event: input.request.event, |
| 345 | environment: input.credentials.environment, |
| 346 | bundleId: input.credentials.bundleId, |
| 347 | tokenSuffix: input.request.token.slice(-8), |
| 348 | stage: "read-response", |
| 349 | status: response.status, |
| 350 | cause, |
| 351 | }), |
| 352 | ), |
| 294 | 353 | ); |
| 295 | 354 | const reason = apnsReasonFromBody(responseText); |
| 296 | 355 | return { |
| ... | ... | @@ -323,10 +382,34 @@ export const make = Effect.gen(function* () { |
| 323 | 382 | }), |
| 324 | 383 | HttpClientRequest.bodyJson(input.request.payload), |
| 325 | 384 | Effect.flatMap(httpClient.execute), |
| 326 | | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), |
| 385 | Effect.mapError( |
| 386 | (cause) => |
| 387 | new ApnsHttpRequestError({ |
| 388 | requestKind: "push-notification", |
| 389 | event: null, |
| 390 | environment: input.credentials.environment, |
| 391 | bundleId: input.credentials.bundleId, |
| 392 | tokenSuffix: input.request.token.slice(-8), |
| 393 | stage: "send", |
| 394 | status: null, |
| 395 | cause, |
| 396 | }), |
| 397 | ), |
| 327 | 398 | ); |
| 328 | 399 | const responseText = yield* response.text.pipe( |
| 329 | | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), |
| 400 | Effect.mapError( |
| 401 | (cause) => |
| 402 | new ApnsHttpRequestError({ |
| 403 | requestKind: "push-notification", |
| 404 | event: null, |
| 405 | environment: input.credentials.environment, |
| 406 | bundleId: input.credentials.bundleId, |
| 407 | tokenSuffix: input.request.token.slice(-8), |
| 408 | stage: "read-response", |
| 409 | status: response.status, |
| 410 | cause, |
| 411 | }), |
| 412 | ), |
| 330 | 413 | ); |
| 331 | 414 | const reason = apnsReasonFromBody(responseText); |
| 332 | 415 | return { |