diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index d33cc42cd8d7aed7ac2627665c01c1ef993b1f70..abe05f07da296654f868dbbf3b63ad2ef390d56f 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -39,7 +39,7 @@ export class AgentActivityPublisher extends Context.Service< } >()("t3code-relay/agentActivity/AgentActivityPublisher") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const rows = yield* AgentActivityRows.AgentActivityRows; const links = yield* EnvironmentLinks.EnvironmentLinks; const liveActivities = yield* LiveActivities.LiveActivities; diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 854facfc7c546aaca5e0e291d51ba8e2ff0688f8..7f19378633ff64419cbf9e7f135422a6dba75b59 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -71,7 +71,7 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityStateSchema), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 61bfd69bc96451949835089a691308639f610643..01a3d04bd62738328382229be5fc8002434e6a4e 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -40,15 +40,21 @@ export interface ApnsDeliveryResult { readonly apnsId: string | null; } -export class ApnsSigningError extends Schema.TaggedErrorClass()( - "ApnsSigningError", - { - phase: Schema.Literals(["encoding", "signing"]), - cause: Schema.Defect(), - }, +export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( + "ApnsJwtEncodingError", + { cause: Schema.Defect() }, ) { override get message(): string { - return `Failed during APNs JWT ${this.phase}`; + return "Failed to encode APNs JWT."; + } +} + +export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( + "ApnsJwtSigningError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to sign APNs JWT."; } } @@ -59,7 +65,7 @@ export class ApnsHttpRequestError extends Schema.TaggedErrorClass new ApnsSigningError({ cause, phase: "encoding" })), + Effect.mapError((cause) => new ApnsJwtEncodingError({ cause })), ); const payloadJson = yield* encodeApnsJwtPayloadJson({ iss: input.teamId, iat: input.issuedAtUnixSeconds, - }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" }))); + }).pipe(Effect.mapError((cause) => new ApnsJwtEncodingError({ cause }))); const privateKey = Redacted.value(input.privateKey); const header = Encoding.encodeBase64Url(headerJson); @@ -129,7 +141,7 @@ const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { }); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, - catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), + catch: (cause) => new ApnsJwtSigningError({ cause }), }); }); @@ -251,7 +263,7 @@ export class ApnsClient extends Context.Service< } >()("t3code-relay/agentActivity/ApnsClient") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index d70808144fcb384e10e550d94aa8ca6c94493c7e..e0b652823ba3ac03fca094059bc64883a4524814 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -21,9 +21,12 @@ import { } from "./agentActivityPayloads.ts"; import * as Apns from "./ApnsClient.ts"; import { - ApnsDeliveryJobInvalid, + ApnsDeliveryJobLiveActivityAggregateMissing, + ApnsDeliveryJobPushNotificationMissing, + ApnsDeliveryJobQueuePayloadInvalid, type ApnsNotificationPayload, SignedApnsDeliveryJob, + isApnsDeliveryJobVerificationError, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, } from "./apnsDeliveryJobs.ts"; @@ -92,17 +95,6 @@ const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( ); const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob); -function apnsErrorMessage(error: Apns.ApnsError): string { - switch (error._tag) { - case "ApnsSigningError": - return "Failed to sign APNs request."; - case "ApnsHttpRequestError": - return "Failed to send APNs request."; - case "ApnsInvalidResponseError": - return "APNs returned an invalid response."; - } -} - function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null { if (!value) { return null; @@ -273,15 +265,6 @@ function isPermanentApnsTokenFailure(result: Apns.ApnsDeliveryResult): boolean { ); } -function isDeliveryJobVerificationError(value: unknown): value is ApnsDeliveryJobVerificationError { - return ( - typeof value === "object" && - value !== null && - "_tag" in value && - (value._tag === "ApnsDeliveryJobInvalid" || value._tag === "ApnsDeliveryJobExpired") - ); -} - function duplicateJobResult(input: { readonly deviceId: string; readonly kind: RelayDeliveryKind; @@ -418,7 +401,7 @@ export class ApnsDeliveries extends Context.Service< } >()("t3code-relay/agentActivity/ApnsDeliveries") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const attempts = yield* DeliveryAttempts.DeliveryAttempts; const liveActivities = yield* LiveActivities.LiveActivities; const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; @@ -497,7 +480,7 @@ const make = Effect.gen(function* () { Effect.succeed({ ok: false, status: 0, - reason: apnsErrorMessage(error), + reason: error.message, apnsId: null, }), ), @@ -614,7 +597,7 @@ const make = Effect.gen(function* () { Effect.succeed({ ok: false, status: 0, - reason: apnsErrorMessage(error), + reason: error.message, apnsId: null, }), ), @@ -657,12 +640,7 @@ const make = Effect.gen(function* () { "relay.apns_deliveries.process_signed_job", )(function* (body) { const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( - Effect.mapError( - () => - new ApnsDeliveryJobInvalid({ - reason: "invalid-queue-payload", - }), - ), + Effect.mapError(() => new ApnsDeliveryJobQueuePayloadInvalid()), ); const now = yield* DateTime.now; const payload = verifySignedApnsDeliveryJob({ @@ -670,7 +648,7 @@ const make = Effect.gen(function* () { job: signedJob, nowMs: now.epochMilliseconds, }); - if (isDeliveryJobVerificationError(payload)) { + if (isApnsDeliveryJobVerificationError(payload)) { return yield* payload; } yield* Effect.annotateCurrentSpan({ @@ -683,11 +661,7 @@ const make = Effect.gen(function* () { case "live_activity_start": case "live_activity_update": if (payload.aggregate === null) { - return Effect.fail( - new ApnsDeliveryJobInvalid({ - reason: "missing-live-activity-aggregate", - }), - ); + return Effect.fail(new ApnsDeliveryJobLiveActivityAggregateMissing()); } return sendLiveActivity({ target: { @@ -712,11 +686,7 @@ const make = Effect.gen(function* () { }); case "push_notification": if (payload.notification === null) { - return Effect.fail( - new ApnsDeliveryJobInvalid({ - reason: "missing-push-notification", - }), - ); + return Effect.fail(new ApnsDeliveryJobPushNotificationMissing()); } return sendPushNotification({ target: { diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 33c21cf0d54d93015082ea5d42772515785e9fc0..980eab169533c1a2c06217847175fe53fc51f278 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -59,7 +59,7 @@ export class ApnsDeliveryQueue extends Context.Service< } >()("t3code-relay/agentActivity/ApnsDeliveryQueue") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const sender = yield* ApnsDeliveryQueueSender; const crypto = yield* Crypto.Crypto; const config = yield* RelayConfiguration.RelayConfiguration; diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts index 931837818b69537be5f62c8f5a719b3b01d3ab47..8845588329a5486c23cb15208f7364b4fc90fa3f 100644 --- a/infra/relay/src/agentActivity/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -82,7 +82,7 @@ function insertValues( }; } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; const crypto = yield* Crypto.Crypto; diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 51a9bd53d64209077cb8b78c41493a6844aed244..973c430832cff96d9208b257cb0518ff71edfa7f 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -57,7 +57,7 @@ export class Devices extends Context.Service< } >()("t3code-relay/agentActivity/Devices") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return Devices.of({ diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 9ee1274b935a59f5a4927c00fda8bffe36c5fe85..4417f4eba36650fe7acaedda59bdac4a9a38e17e 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -106,7 +106,7 @@ const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return LiveActivities.of({ diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index b44d24dfa5d5d41ee97ea38faaa4c102b58df2a7..395422b81dde77ee4b8efd978c590f45d02e6602 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -33,7 +33,7 @@ export class MobileRegistrations extends Context.Service< } >()("t3code-relay/agentActivity/MobileRegistrations") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const devices = yield* Devices.Devices; const liveActivities = yield* LiveActivities.LiveActivities; const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index 428dc3a82b6375469daa37722754c01cf71e2841..d65587f0d192e903e3a8c43510e70ff8c69d1c03 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -69,7 +69,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobSignatureInvalid", }); }); @@ -93,7 +93,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobLiveActivityAggregateMissing", message: "Live Activity start/update jobs require an aggregate.", }); }); @@ -119,7 +119,7 @@ describe("apnsDeliveryJobs", () => { }); expect(result).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobPushNotificationAggregateUnexpected", message: "Push notification jobs must not carry aggregate state.", }); }); @@ -194,7 +194,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobCreatedAtInvalid", message: "Invalid APNs delivery job creation time.", }); expect( @@ -204,7 +204,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobTimeWindowInvalid", message: "Invalid APNs delivery job time window.", }); expect( @@ -214,7 +214,7 @@ describe("apnsDeliveryJobs", () => { nowMs: 0, }), ).toMatchObject({ - _tag: "ApnsDeliveryJobInvalid", + _tag: "ApnsDeliveryJobTimeWindowTooLong", message: "APNs delivery job time window is too long.", }); }); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 8de33752a9acc8949321bff953ea4fe0a44e4b94..6e493943ab407d56384eb51a341ac3ec446d7c58 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -49,49 +49,110 @@ export const SignedApnsDeliveryJob = Schema.Struct({ }); export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; -export class ApnsDeliveryJobInvalid extends Schema.TaggedErrorClass()( - "ApnsDeliveryJobInvalid", - { - reason: Schema.Literals([ - "invalid-queue-payload", - "missing-live-activity-aggregate", - "unexpected-live-activity-notification", - "missing-push-notification", - "unexpected-push-notification-aggregate", - "invalid-created-at", - "invalid-expires-at", - "invalid-time-window", - "time-window-too-long", - "invalid-signature", - ]), - }, -) { - override get message(): string { - switch (this.reason) { - case "invalid-queue-payload": - return "Invalid APNs delivery queue job."; - case "missing-live-activity-aggregate": - return "Live Activity start/update jobs require an aggregate."; - case "unexpected-live-activity-notification": - return "Live Activity jobs must not carry push notification payloads."; - case "missing-push-notification": - return "Push notification jobs require a notification payload."; - case "unexpected-push-notification-aggregate": - return "Push notification jobs must not carry aggregate state."; - case "invalid-created-at": - return "Invalid APNs delivery job creation time."; - case "invalid-expires-at": - return "Invalid APNs delivery job expiry."; - case "invalid-time-window": - return "Invalid APNs delivery job time window."; - case "time-window-too-long": - return "APNs delivery job time window is too long."; - case "invalid-signature": - return "Invalid APNs delivery job signature."; - } +export class ApnsDeliveryJobQueuePayloadInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobQueuePayloadInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery queue job."; + } +} + +export class ApnsDeliveryJobLiveActivityAggregateMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityAggregateMissing", + {}, +) { + override get message(): string { + return "Live Activity start/update jobs require an aggregate."; + } +} + +export class ApnsDeliveryJobLiveActivityNotificationUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobLiveActivityNotificationUnexpected", + {}, +) { + override get message(): string { + return "Live Activity jobs must not carry push notification payloads."; + } +} + +export class ApnsDeliveryJobPushNotificationMissing extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationMissing", + {}, +) { + override get message(): string { + return "Push notification jobs require a notification payload."; + } +} + +export class ApnsDeliveryJobPushNotificationAggregateUnexpected extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobPushNotificationAggregateUnexpected", + {}, +) { + override get message(): string { + return "Push notification jobs must not carry aggregate state."; + } +} + +export class ApnsDeliveryJobCreatedAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobCreatedAtInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job creation time."; + } +} + +export class ApnsDeliveryJobExpiresAtInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobExpiresAtInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job expiry."; + } +} + +export class ApnsDeliveryJobTimeWindowInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job time window."; + } +} + +export class ApnsDeliveryJobTimeWindowTooLong extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobTimeWindowTooLong", + {}, +) { + override get message(): string { + return "APNs delivery job time window is too long."; + } +} + +export class ApnsDeliveryJobSignatureInvalid extends Schema.TaggedErrorClass()( + "ApnsDeliveryJobSignatureInvalid", + {}, +) { + override get message(): string { + return "Invalid APNs delivery job signature."; } } +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", { @@ -103,7 +164,13 @@ export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass MAX_JOB_AGE_MS) { - return new ApnsDeliveryJobInvalid({ reason: "time-window-too-long" }); + return new ApnsDeliveryJobTimeWindowTooLong(); } if (expiresAtMs <= input.nowMs) { return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); @@ -235,7 +292,7 @@ export function verifySignedApnsDeliveryJob(input: { payload: input.job.payload, }); if (!timingSafeEqualBase64Url(input.job.signature, expected)) { - return new ApnsDeliveryJobInvalid({ reason: "invalid-signature" }); + return new ApnsDeliveryJobSignatureInvalid(); } return input.job.payload; } diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index adb13e828dd3f3c6eedc523c2168bdaff98735e9..33bcd187c26e46623c7dc75e1e2f6dfff13711e5 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -777,18 +777,17 @@ export const serverApi = HttpApiBuilder.group( reason: "persistence_failed", traceId, }), - ApnsDeliveryJobInvalid: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), - ApnsDeliveryJobExpired: (_error, traceId) => - new RelayInternalError({ - code: "internal_error", - reason: "internal_error", - traceId, - }), + ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError, + ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError, ApnsDeliveryJobClaimInFlight: (_error, traceId) => new RelayInternalError({ code: "internal_error", @@ -893,6 +892,14 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { ): Effect.Effect, R> => effect.pipe(Effect.catch(mapError)); } +function mapApnsDeliveryJobInternalError(_error: unknown, traceId: string) { + return new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }); +} + type TaggedErrorTag = Extract["_tag"]; type MapErrorTagCases = {