| ... | @@ -11,14 +11,17 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,14 +11,17 @@ import * as Schema from "effect/Schema"; |
| 11 | import * as Headers from "effect/unstable/http/Headers"; | 11 | import * as Headers from "effect/unstable/http/Headers"; |
| 12 | import * as HttpClient from "effect/unstable/http/HttpClient"; | 12 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 13 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | 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 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; | 15 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 16 | | 16 | |
| 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; | 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; |
| 18 | const STALE_AFTER_SECONDS = 2 * 60; | 18 | const STALE_AFTER_SECONDS = 2 * 60; |
| 19 | const DISMISS_AFTER_SECONDS = 5 * 60; | 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 | interface ApnsLiveActivityRequest { | 26 | interface ApnsLiveActivityRequest { |
| 24 | readonly token: string; | 27 | readonly token: string; |
| ... | @@ -42,41 +45,48 @@ export interface ApnsDeliveryResult { | ... | @@ -42,41 +45,48 @@ export interface ApnsDeliveryResult { |
| 42 | | 45 | |
| 43 | export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()( | 46 | export class ApnsJwtEncodingError extends Schema.TaggedErrorClass<ApnsJwtEncodingError>()( |
| 44 | "ApnsJwtEncodingError", | 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 | override get message(): string { | 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 | export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()( | 61 | export class ApnsJwtSigningError extends Schema.TaggedErrorClass<ApnsJwtSigningError>()( |
| 53 | "ApnsJwtSigningError", | 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 | cause: Schema.Defect(), | 67 | cause: Schema.Defect(), |
| 65 | }, | 68 | }, |
| 66 | ) { | 69 | ) { |
| 67 | override get message(): string { | 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>()( | 75 | export class ApnsHttpRequestError extends Schema.TaggedErrorClass<ApnsHttpRequestError>()( |
| 73 | "ApnsInvalidResponseError", | 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 | cause: Schema.Defect(), | 85 | cause: Schema.Defect(), |
| 76 | }, | 86 | }, |
| 77 | ) { | 87 | ) { |
| 78 | override get message(): string { | 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,7 +94,6 @@ export const ApnsError = Schema.Union([ |
| 84 | ApnsJwtEncodingError, | 94 | ApnsJwtEncodingError, |
| 85 | ApnsJwtSigningError, | 95 | ApnsJwtSigningError, |
| 86 | ApnsHttpRequestError, | 96 | ApnsHttpRequestError, |
| 87 | ApnsInvalidResponseError, | | |
| 88 | ]); | 97 | ]); |
| 89 | export type ApnsError = typeof ApnsError.Type; | 98 | export type ApnsError = typeof ApnsError.Type; |
| 90 | | 99 | |
| ... | @@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( | ... | @@ -113,18 +122,38 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( |
| 113 | ); | 122 | ); |
| 114 | | 123 | |
| 115 | const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { | 124 | const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { |
| 116 | readonly teamId: RelayConfiguration.ApnsCredentials["teamId"]; | 125 | readonly teamId: ApnsCredentials["teamId"]; |
| 117 | readonly keyId: RelayConfiguration.ApnsCredentials["keyId"]; | 126 | readonly keyId: ApnsCredentials["keyId"]; |
| 118 | readonly privateKey: RelayConfiguration.ApnsCredentials["privateKey"]; | 127 | readonly privateKey: ApnsCredentials["privateKey"]; |
| 119 | readonly issuedAtUnixSeconds: number; | 128 | readonly issuedAtUnixSeconds: number; |
| 120 | }) { | 129 | }) { |
| 121 | const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( | 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 | const payloadJson = yield* encodeApnsJwtPayloadJson({ | 142 | const payloadJson = yield* encodeApnsJwtPayloadJson({ |
| 125 | iss: input.teamId, | 143 | iss: input.teamId, |
| 126 | iat: input.issuedAtUnixSeconds, | 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 | const privateKey = Redacted.value(input.privateKey); | 158 | const privateKey = Redacted.value(input.privateKey); |
| 130 | const header = Encoding.encodeBase64Url(headerJson); | 159 | const header = Encoding.encodeBase64Url(headerJson); |
| ... | @@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { | ... | @@ -141,7 +170,13 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { |
| 141 | }); | 170 | }); |
| 142 | return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; | 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,12 +286,12 @@ export class ApnsClient extends Context.Service< |
| 251 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; | 286 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; |
| 252 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; | 287 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; |
| 253 | readonly sendLiveActivityRequest: (input: { | 288 | readonly sendLiveActivityRequest: (input: { |
| 254 | readonly credentials: RelayConfiguration.ApnsCredentials; | 289 | readonly credentials: ApnsCredentials; |
| 255 | readonly request: ApnsLiveActivityRequest; | 290 | readonly request: ApnsLiveActivityRequest; |
| 256 | readonly issuedAtUnixSeconds: number; | 291 | readonly issuedAtUnixSeconds: number; |
| 257 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | 292 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; |
| 258 | readonly sendPushNotificationRequest: (input: { | 293 | readonly sendPushNotificationRequest: (input: { |
| 259 | readonly credentials: RelayConfiguration.ApnsCredentials; | 294 | readonly credentials: ApnsCredentials; |
| 260 | readonly request: ApnsPushNotificationRequest; | 295 | readonly request: ApnsPushNotificationRequest; |
| 261 | readonly issuedAtUnixSeconds: number; | 296 | readonly issuedAtUnixSeconds: number; |
| 262 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | 297 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; |
| ... | @@ -287,10 +322,34 @@ export const make = Effect.gen(function* () { | ... | @@ -287,10 +322,34 @@ export const make = Effect.gen(function* () { |
| 287 | }), | 322 | }), |
| 288 | HttpClientRequest.bodyJson(input.request.payload), | 323 | HttpClientRequest.bodyJson(input.request.payload), |
| 289 | Effect.flatMap(httpClient.execute), | 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 | const responseText = yield* response.text.pipe( | 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 | const reason = apnsReasonFromBody(responseText); | 354 | const reason = apnsReasonFromBody(responseText); |
| 296 | return { | 355 | return { |
| ... | @@ -323,10 +382,34 @@ export const make = Effect.gen(function* () { | ... | @@ -323,10 +382,34 @@ export const make = Effect.gen(function* () { |
| 323 | }), | 382 | }), |
| 324 | HttpClientRequest.bodyJson(input.request.payload), | 383 | HttpClientRequest.bodyJson(input.request.payload), |
| 325 | Effect.flatMap(httpClient.execute), | 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 | const responseText = yield* response.text.pipe( | 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 | const reason = apnsReasonFromBody(responseText); | 414 | const reason = apnsReasonFromBody(responseText); |
| 332 | return { | 415 | return { |