| author | |
| committer | |
| log | d5a72d5be105d16a95df6dc18ad08f0b0cdb7efb |
| tree | e7528f84cd0c7ca83bfa613caff790db2707760e |
| parent | 742dd7af0dcb54c32774f87fd354105726c7a9db |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>13 files changed, 320 insertions(+), 285 deletions(-)
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+17-14| ... | ... | @@ -41,8 +41,8 @@ function target(deviceId: string): LiveActivities.TargetRow { |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | 43 | function makeLiveActivities( |
| 44 | overrides: Partial<LiveActivities.LiveActivitiesShape> = {}, | |
| 45 | ): LiveActivities.LiveActivitiesShape { | |
| 44 | overrides: Partial<LiveActivities.LiveActivities["Service"]> = {}, | |
| 45 | ): LiveActivities.LiveActivities["Service"] { | |
| 46 | 46 | return { |
| 47 | 47 | register: () => Effect.void, |
| 48 | 48 | listTargets: () => Effect.succeed([]), |
| ... | ... | @@ -55,8 +55,8 @@ function makeLiveActivities( |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | function makeAgentActivityRows( |
| 58 | overrides: Partial<AgentActivityRows.AgentActivityRowsShape> = {}, | |
| 59 | ): AgentActivityRows.AgentActivityRowsShape { | |
| 58 | overrides: Partial<AgentActivityRows.AgentActivityRows["Service"]> = {}, | |
| 59 | ): AgentActivityRows.AgentActivityRows["Service"] { | |
| 60 | 60 | return { |
| 61 | 61 | upsert: () => Effect.void, |
| 62 | 62 | remove: () => Effect.void, |
| ... | ... | @@ -88,8 +88,8 @@ function makeEnvironmentLinks( |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | function makeApnsDeliveries( |
| 91 | overrides: Partial<ApnsDeliveries.ApnsDeliveriesShape> = {}, | |
| 92 | ): ApnsDeliveries.ApnsDeliveriesShape { | |
| 91 | overrides: Partial<ApnsDeliveries.ApnsDeliveries["Service"]> = {}, | |
| 92 | ): ApnsDeliveries.ApnsDeliveries["Service"] { | |
| 93 | 93 | return { |
| 94 | 94 | sendForTarget: () => Effect.succeed(null), |
| 95 | 95 | sendPushNotificationForTarget: () => Effect.succeed(null), |
| ... | ... | @@ -133,7 +133,8 @@ describe("AgentActivityPublisher", () => { |
| 133 | 133 | remote_start_queued_at: null, |
| 134 | 134 | remote_started_at: "1970-01-01T00:00:01.000Z", |
| 135 | 135 | }; |
| 136 | const sent: Array<Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0]> = []; | |
| 136 | const sent: Array<Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]> = | |
| 137 | []; | |
| 137 | 138 | const deliveryResult: RelayDeliveryResult = { |
| 138 | 139 | deviceId: "device-1", |
| 139 | 140 | kind: "live_activity_update", |
| ... | ... | @@ -211,7 +212,8 @@ describe("AgentActivityPublisher", () => { |
| 211 | 212 | readonly environmentId: string; |
| 212 | 213 | readonly environmentPublicKey: string; |
| 213 | 214 | }> = []; |
| 214 | const upserts: Array<Parameters<AgentActivityRows.AgentActivityRowsShape["upsert"]>[0]> = []; | |
| 215 | const upserts: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["upsert"]>[0]> = | |
| 216 | []; | |
| 215 | 217 | |
| 216 | 218 | return Effect.gen(function* () { |
| 217 | 219 | const result = yield* Effect.gen(function* () { |
| ... | ... | @@ -302,9 +304,10 @@ describe("AgentActivityPublisher", () => { |
| 302 | 304 | updatedAt: "1970-01-01T00:00:10.000Z", |
| 303 | 305 | }; |
| 304 | 306 | const sentAggregates: Array< |
| 305 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | |
| 307 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0] | |
| 306 | 308 | > = []; |
| 307 | const removals: Array<Parameters<AgentActivityRows.AgentActivityRowsShape["remove"]>[0]> = []; | |
| 309 | const removals: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["remove"]>[0]> = | |
| 310 | []; | |
| 308 | 311 | |
| 309 | 312 | return Effect.gen(function* () { |
| 310 | 313 | const result = yield* Effect.gen(function* () { |
| ... | ... | @@ -405,10 +408,10 @@ describe("AgentActivityPublisher", () => { |
| 405 | 408 | headline: "Needs input", |
| 406 | 409 | }; |
| 407 | 410 | const liveAggregates: Array< |
| 408 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | |
| 411 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0] | |
| 409 | 412 | > = []; |
| 410 | 413 | const pushAggregates: Array< |
| 411 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendPushNotificationForTarget"]>[0] | |
| 414 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendPushNotificationForTarget"]>[0] | |
| 412 | 415 | > = []; |
| 413 | 416 | |
| 414 | 417 | return Effect.gen(function* () { |
| ... | ... | @@ -517,10 +520,10 @@ describe("AgentActivityPublisher", () => { |
| 517 | 520 | headline: "Needs approval", |
| 518 | 521 | }; |
| 519 | 522 | const liveAggregates: Array< |
| 520 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | |
| 523 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0] | |
| 521 | 524 | > = []; |
| 522 | 525 | const pushAggregates: Array< |
| 523 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendPushNotificationForTarget"]>[0] | |
| 526 | Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendPushNotificationForTarget"]>[0] | |
| 524 | 527 | > = []; |
| 525 | 528 | |
| 526 | 529 | return Effect.gen(function* () { |
infra/relay/src/agentActivity/AgentActivityPublisher.ts+12-14| ... | ... | @@ -23,22 +23,20 @@ export type AgentActivityPublishError = |
| 23 | 23 | | LiveActivities.LiveActivityTargetListPersistenceError |
| 24 | 24 | | ApnsDeliveries.ApnsDeliveryError; |
| 25 | 25 | |
| 26 | export interface AgentActivityPublisherShape { | |
| 27 | readonly publish: (input: { | |
| 28 | readonly environmentId: string; | |
| 29 | readonly environmentPublicKey: string; | |
| 30 | readonly threadId: string; | |
| 31 | readonly state: RelayAgentActivityState | null; | |
| 32 | }) => Effect.Effect<RelayPublishResponse, AgentActivityPublishError>; | |
| 33 | readonly replayForLiveActivityRegistration: (input: { | |
| 34 | readonly userId: string; | |
| 35 | readonly deviceId: string; | |
| 36 | }) => Effect.Effect<RelayDeliveryResult | null, AgentActivityPublishError>; | |
| 37 | } | |
| 38 | ||
| 39 | 26 | export class AgentActivityPublisher extends Context.Service< |
| 40 | 27 | AgentActivityPublisher, |
| 41 | AgentActivityPublisherShape | |
| 28 | { | |
| 29 | readonly publish: (input: { | |
| 30 | readonly environmentId: string; | |
| 31 | readonly environmentPublicKey: string; | |
| 32 | readonly threadId: string; | |
| 33 | readonly state: RelayAgentActivityState | null; | |
| 34 | }) => Effect.Effect<RelayPublishResponse, AgentActivityPublishError>; | |
| 35 | readonly replayForLiveActivityRegistration: (input: { | |
| 36 | readonly userId: string; | |
| 37 | readonly deviceId: string; | |
| 38 | }) => Effect.Effect<RelayDeliveryResult | null, AgentActivityPublishError>; | |
| 39 | } | |
| 42 | 40 | >()("t3code-relay/agentActivity/AgentActivityPublisher") {} |
| 43 | 41 | |
| 44 | 42 | const make = Effect.gen(function* () { |
infra/relay/src/agentActivity/AgentActivityRows.ts+22-20| ... | ... | @@ -3,7 +3,7 @@ import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3too |
| 3 | 3 | import * as Context from "effect/Context"; |
| 4 | 4 | import * as DateTime from "effect/DateTime"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | import { cast } from "effect/Function"; | |
| 6 | import * as Function from "effect/Function"; | |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Option from "effect/Option"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -39,24 +39,26 @@ export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClas |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | export interface AgentActivityRowsShape { | |
| 43 | readonly upsert: (input: { | |
| 44 | readonly environmentPublicKey: string; | |
| 45 | readonly state: RelayAgentActivityState; | |
| 46 | }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>; | |
| 47 | readonly remove: (input: { | |
| 48 | readonly environmentId: string; | |
| 49 | readonly environmentPublicKey: string; | |
| 50 | readonly threadId: string; | |
| 51 | }) => Effect.Effect<void, AgentActivityRowDeletePersistenceError>; | |
| 52 | readonly listForUser: (input: { | |
| 53 | readonly userId: string; | |
| 54 | }) => Effect.Effect<ReadonlyArray<RelayAgentActivityState>, AgentActivityRowListPersistenceError>; | |
| 55 | } | |
| 56 | ||
| 57 | export class AgentActivityRows extends Context.Service<AgentActivityRows, AgentActivityRowsShape>()( | |
| 58 | "t3code-relay/agentActivity/AgentActivityRows", | |
| 59 | ) {} | |
| 42 | export class AgentActivityRows extends Context.Service< | |
| 43 | AgentActivityRows, | |
| 44 | { | |
| 45 | readonly upsert: (input: { | |
| 46 | readonly environmentPublicKey: string; | |
| 47 | readonly state: RelayAgentActivityState; | |
| 48 | }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>; | |
| 49 | readonly remove: (input: { | |
| 50 | readonly environmentId: string; | |
| 51 | readonly environmentPublicKey: string; | |
| 52 | readonly threadId: string; | |
| 53 | }) => Effect.Effect<void, AgentActivityRowDeletePersistenceError>; | |
| 54 | readonly listForUser: (input: { | |
| 55 | readonly userId: string; | |
| 56 | }) => Effect.Effect< | |
| 57 | ReadonlyArray<RelayAgentActivityState>, | |
| 58 | AgentActivityRowListPersistenceError | |
| 59 | >; | |
| 60 | } | |
| 61 | >()("t3code-relay/agentActivity/AgentActivityRows") {} | |
| 60 | 62 | |
| 61 | 63 | const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); |
| 62 | 64 | const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| ... | ... | @@ -82,7 +84,7 @@ const make = Effect.gen(function* () { |
| 82 | 84 | const now = yield* DateTime.now; |
| 83 | 85 | const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( |
| 84 | 86 | Effect.flatMap(decodeJsonString), |
| 85 | Effect.map(cast<unknown, RelayAgentActivityState>), | |
| 87 | Effect.map(Function.cast<unknown, RelayAgentActivityState>), | |
| 86 | 88 | ); |
| 87 | 89 | yield* db |
| 88 | 90 | .insert(relayAgentActivityRows) |
infra/relay/src/agentActivity/ApnsClient.ts+56-54| ... | ... | @@ -8,8 +8,10 @@ import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Option from "effect/Option"; |
| 9 | 9 | import * as Redacted from "effect/Redacted"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 12 | import * as RelayConfiguration from "../Config.ts"; | |
| 11 | import * as Headers from "effect/unstable/http/Headers"; | |
| 12 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 13 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | |
| 14 | import type * as RelayConfiguration from "../Config.ts"; | |
| 13 | 15 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 14 | 16 | |
| 15 | 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; |
| ... | ... | @@ -231,29 +233,28 @@ function apnsReasonFromBody(body: string): string | undefined { |
| 231 | 233 | }); |
| 232 | 234 | } |
| 233 | 235 | |
| 234 | export interface ApnsClientShape { | |
| 235 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; | |
| 236 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; | |
| 237 | readonly sendLiveActivityRequest: (input: { | |
| 238 | readonly credentials: RelayConfiguration.ApnsCredentials; | |
| 239 | readonly request: ApnsLiveActivityRequest; | |
| 240 | readonly issuedAtUnixSeconds: number; | |
| 241 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | |
| 242 | readonly sendPushNotificationRequest: (input: { | |
| 243 | readonly credentials: RelayConfiguration.ApnsCredentials; | |
| 244 | readonly request: ApnsPushNotificationRequest; | |
| 245 | readonly issuedAtUnixSeconds: number; | |
| 246 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | |
| 247 | } | |
| 248 | ||
| 249 | export class ApnsClient extends Context.Service<ApnsClient, ApnsClientShape>()( | |
| 250 | "t3code-relay/agentActivity/ApnsClient", | |
| 251 | ) {} | |
| 236 | export class ApnsClient extends Context.Service< | |
| 237 | ApnsClient, | |
| 238 | { | |
| 239 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; | |
| 240 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; | |
| 241 | readonly sendLiveActivityRequest: (input: { | |
| 242 | readonly credentials: RelayConfiguration.ApnsCredentials; | |
| 243 | readonly request: ApnsLiveActivityRequest; | |
| 244 | readonly issuedAtUnixSeconds: number; | |
| 245 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | |
| 246 | readonly sendPushNotificationRequest: (input: { | |
| 247 | readonly credentials: RelayConfiguration.ApnsCredentials; | |
| 248 | readonly request: ApnsPushNotificationRequest; | |
| 249 | readonly issuedAtUnixSeconds: number; | |
| 250 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | |
| 251 | } | |
| 252 | >()("t3code-relay/agentActivity/ApnsClient") {} | |
| 252 | 253 | |
| 253 | 254 | const make = Effect.gen(function* () { |
| 254 | 255 | const httpClient = yield* HttpClient.HttpClient; |
| 255 | 256 | |
| 256 | const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn( | |
| 257 | const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( | |
| 257 | 258 | "relay.apns.send_live_activity_request", |
| 258 | 259 | )(function* (input) { |
| 259 | 260 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); |
| ... | ... | @@ -288,40 +289,41 @@ const make = Effect.gen(function* () { |
| 288 | 289 | }; |
| 289 | 290 | }); |
| 290 | 291 | |
| 291 | const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn( | |
| 292 | "relay.apns.send_push_notification_request", | |
| 293 | )(function* (input) { | |
| 294 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); | |
| 295 | const jwt = yield* makeApnsJwt({ | |
| 296 | ...input.credentials, | |
| 297 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | |
| 292 | const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = | |
| 293 | Effect.fn("relay.apns.send_push_notification_request")(function* (input) { | |
| 294 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); | |
| 295 | const jwt = yield* makeApnsJwt({ | |
| 296 | ...input.credentials, | |
| 297 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | |
| 298 | }); | |
| 299 | const host = | |
| 300 | input.credentials.environment === "production" | |
| 301 | ? "https://api.push.apple.com" | |
| 302 | : "https://api.sandbox.push.apple.com"; | |
| 303 | const response = yield* HttpClientRequest.post( | |
| 304 | `${host}/3/device/${input.request.token}`, | |
| 305 | ).pipe( | |
| 306 | HttpClientRequest.setHeaders({ | |
| 307 | authorization: `bearer ${jwt}`, | |
| 308 | "apns-priority": input.request.priority, | |
| 309 | "apns-push-type": "alert", | |
| 310 | "apns-topic": input.credentials.bundleId, | |
| 311 | }), | |
| 312 | HttpClientRequest.bodyJson(input.request.payload), | |
| 313 | Effect.flatMap(httpClient.execute), | |
| 314 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | |
| 315 | ); | |
| 316 | const responseText = yield* response.text.pipe( | |
| 317 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | |
| 318 | ); | |
| 319 | const reason = apnsReasonFromBody(responseText); | |
| 320 | return { | |
| 321 | ok: response.status >= 200 && response.status < 300, | |
| 322 | status: response.status, | |
| 323 | ...(reason === undefined ? {} : { reason }), | |
| 324 | apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), | |
| 325 | }; | |
| 298 | 326 | }); |
| 299 | const host = | |
| 300 | input.credentials.environment === "production" | |
| 301 | ? "https://api.push.apple.com" | |
| 302 | : "https://api.sandbox.push.apple.com"; | |
| 303 | const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( | |
| 304 | HttpClientRequest.setHeaders({ | |
| 305 | authorization: `bearer ${jwt}`, | |
| 306 | "apns-priority": input.request.priority, | |
| 307 | "apns-push-type": "alert", | |
| 308 | "apns-topic": input.credentials.bundleId, | |
| 309 | }), | |
| 310 | HttpClientRequest.bodyJson(input.request.payload), | |
| 311 | Effect.flatMap(httpClient.execute), | |
| 312 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | |
| 313 | ); | |
| 314 | const responseText = yield* response.text.pipe( | |
| 315 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | |
| 316 | ); | |
| 317 | const reason = apnsReasonFromBody(responseText); | |
| 318 | return { | |
| 319 | ok: response.status >= 200 && response.status < 300, | |
| 320 | status: response.status, | |
| 321 | ...(reason === undefined ? {} : { reason }), | |
| 322 | apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), | |
| 323 | }; | |
| 324 | }); | |
| 325 | 327 | |
| 326 | 328 | return ApnsClient.of({ |
| 327 | 329 | makeLiveActivityRequest, |
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+10-10| ... | ... | @@ -141,16 +141,16 @@ function makeLayer(input: { |
| 141 | 141 | readonly sourceJobClaims?: ReadonlyMap<string, DeliveryAttempts.DeliverySourceJobClaimResult>; |
| 142 | 142 | readonly queuedJobs?: Array<SignedApnsDeliveryJob>; |
| 143 | 143 | readonly queuedStarts?: Array< |
| 144 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | |
| 144 | Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0] | |
| 145 | 145 | >; |
| 146 | 146 | readonly clearedStarts?: Array< |
| 147 | Parameters<LiveActivities.LiveActivitiesShape["clearStartQueued"]>[0] | |
| 147 | Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0] | |
| 148 | 148 | >; |
| 149 | 149 | readonly markedDeliveries?: Array< |
| 150 | Parameters<LiveActivities.LiveActivitiesShape["markDelivery"]>[0] | |
| 150 | Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0] | |
| 151 | 151 | >; |
| 152 | 152 | readonly invalidatedTokens?: Array< |
| 153 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | |
| 153 | Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0] | |
| 154 | 154 | >; |
| 155 | 155 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; |
| 156 | 156 | readonly config?: RelayConfiguration.RelayConfiguration["Service"]; |
| ... | ... | @@ -227,10 +227,10 @@ describe("ApnsDeliveries", () => { |
| 227 | 227 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 228 | 228 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| 229 | 229 | const queuedStarts: Array< |
| 230 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | |
| 230 | Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0] | |
| 231 | 231 | > = []; |
| 232 | 232 | const markedDeliveries: Array< |
| 233 | Parameters<LiveActivities.LiveActivitiesShape["markDelivery"]>[0] | |
| 233 | Parameters<LiveActivities.LiveActivities["Service"]["markDelivery"]>[0] | |
| 234 | 234 | > = []; |
| 235 | 235 | |
| 236 | 236 | return Effect.gen(function* () { |
| ... | ... | @@ -933,7 +933,7 @@ describe("ApnsDeliveries", () => { |
| 933 | 933 | it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => { |
| 934 | 934 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 935 | 935 | const invalidatedTokens: Array< |
| 936 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | |
| 936 | Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0] | |
| 937 | 937 | > = []; |
| 938 | 938 | const payload = makeApnsDeliveryJobPayload({ |
| 939 | 939 | kind: "push_notification", |
| ... | ... | @@ -1000,7 +1000,7 @@ describe("ApnsDeliveries", () => { |
| 1000 | 1000 | it.effect("clears queued start state when a start job fails in APNs", () => { |
| 1001 | 1001 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 1002 | 1002 | const clearedStarts: Array< |
| 1003 | Parameters<LiveActivities.LiveActivitiesShape["clearStartQueued"]>[0] | |
| 1003 | Parameters<LiveActivities.LiveActivities["Service"]["clearStartQueued"]>[0] | |
| 1004 | 1004 | > = []; |
| 1005 | 1005 | const payload = makeApnsDeliveryJobPayload({ |
| 1006 | 1006 | kind: "live_activity_start", |
| ... | ... | @@ -1035,7 +1035,7 @@ describe("ApnsDeliveries", () => { |
| 1035 | 1035 | it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { |
| 1036 | 1036 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 1037 | 1037 | const invalidatedTokens: Array< |
| 1038 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | |
| 1038 | Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0] | |
| 1039 | 1039 | > = []; |
| 1040 | 1040 | const payload = makeApnsDeliveryJobPayload({ |
| 1041 | 1041 | kind: "live_activity_start", |
| ... | ... | @@ -1082,7 +1082,7 @@ describe("ApnsDeliveries", () => { |
| 1082 | 1082 | it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => { |
| 1083 | 1083 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; |
| 1084 | 1084 | const invalidatedTokens: Array< |
| 1085 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | |
| 1085 | Parameters<LiveActivities.LiveActivities["Service"]["invalidateDeliveryToken"]>[0] | |
| 1086 | 1086 | > = []; |
| 1087 | 1087 | const payload = makeApnsDeliveryJobPayload({ |
| 1088 | 1088 | kind: "live_activity_update", |
infra/relay/src/agentActivity/ApnsDeliveries.ts+33-34| ... | ... | @@ -356,7 +356,7 @@ export type SendLiveActivityDeliveryInput = |
| 356 | 356 | }); |
| 357 | 357 | |
| 358 | 358 | function makeLiveActivityDeliveryRequest( |
| 359 | apns: Apns.ApnsClientShape, | |
| 359 | apns: Apns.ApnsClient["Service"], | |
| 360 | 360 | input: SendLiveActivityDeliveryInput, |
| 361 | 361 | now: DateTime.DateTime, |
| 362 | 362 | ) { |
| ... | ... | @@ -391,33 +391,32 @@ function makeLiveActivityDeliveryRequest( |
| 391 | 391 | } |
| 392 | 392 | } |
| 393 | 393 | |
| 394 | export interface ApnsDeliveriesShape { | |
| 395 | readonly sendForTarget: (input: { | |
| 396 | readonly target: LiveActivities.TargetRow; | |
| 397 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 398 | readonly nowMs: number; | |
| 399 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | |
| 400 | readonly sendPushNotificationForTarget: (input: { | |
| 401 | readonly target: LiveActivities.TargetRow; | |
| 402 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 403 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | |
| 404 | readonly sendLiveActivity: ( | |
| 405 | input: SendLiveActivityDeliveryInput, | |
| 406 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 407 | readonly processSignedJob: ( | |
| 408 | body: unknown, | |
| 409 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 410 | readonly sendPushNotification: (input: { | |
| 411 | readonly target: LiveActivityDeliveryTarget; | |
| 412 | readonly token: string; | |
| 413 | readonly sourceJobId?: string | null; | |
| 414 | readonly notification: ApnsNotificationPayload; | |
| 415 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 416 | } | |
| 417 | ||
| 418 | export class ApnsDeliveries extends Context.Service<ApnsDeliveries, ApnsDeliveriesShape>()( | |
| 419 | "t3code-relay/agentActivity/ApnsDeliveries", | |
| 420 | ) {} | |
| 394 | export class ApnsDeliveries extends Context.Service< | |
| 395 | ApnsDeliveries, | |
| 396 | { | |
| 397 | readonly sendForTarget: (input: { | |
| 398 | readonly target: LiveActivities.TargetRow; | |
| 399 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 400 | readonly nowMs: number; | |
| 401 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | |
| 402 | readonly sendPushNotificationForTarget: (input: { | |
| 403 | readonly target: LiveActivities.TargetRow; | |
| 404 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 405 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | |
| 406 | readonly sendLiveActivity: ( | |
| 407 | input: SendLiveActivityDeliveryInput, | |
| 408 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 409 | readonly processSignedJob: ( | |
| 410 | body: unknown, | |
| 411 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 412 | readonly sendPushNotification: (input: { | |
| 413 | readonly target: LiveActivityDeliveryTarget; | |
| 414 | readonly token: string; | |
| 415 | readonly sourceJobId?: string | null; | |
| 416 | readonly notification: ApnsNotificationPayload; | |
| 417 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | |
| 418 | } | |
| 419 | >()("t3code-relay/agentActivity/ApnsDeliveries") {} | |
| 421 | 420 | |
| 422 | 421 | const make = Effect.gen(function* () { |
| 423 | 422 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; |
| ... | ... | @@ -442,7 +441,7 @@ const make = Effect.gen(function* () { |
| 442 | 441 | ); |
| 443 | 442 | }); |
| 444 | 443 | |
| 445 | const sendLiveActivity: ApnsDeliveriesShape["sendLiveActivity"] = Effect.fn( | |
| 444 | const sendLiveActivity: ApnsDeliveries["Service"]["sendLiveActivity"] = Effect.fn( | |
| 446 | 445 | "relay.apns_deliveries.send_live_activity", |
| 447 | 446 | )(function* (input) { |
| 448 | 447 | yield* Effect.annotateCurrentSpan({ |
| ... | ... | @@ -550,7 +549,7 @@ const make = Effect.gen(function* () { |
| 550 | 549 | }; |
| 551 | 550 | }); |
| 552 | 551 | |
| 553 | const sendPushNotification: ApnsDeliveriesShape["sendPushNotification"] = Effect.fn( | |
| 552 | const sendPushNotification: ApnsDeliveries["Service"]["sendPushNotification"] = Effect.fn( | |
| 554 | 553 | "relay.apns_deliveries.send_push_notification", |
| 555 | 554 | )(function* (input) { |
| 556 | 555 | yield* Effect.annotateCurrentSpan({ |
| ... | ... | @@ -654,14 +653,14 @@ const make = Effect.gen(function* () { |
| 654 | 653 | }; |
| 655 | 654 | }); |
| 656 | 655 | |
| 657 | const processSignedJob: ApnsDeliveriesShape["processSignedJob"] = Effect.fn( | |
| 656 | const processSignedJob: ApnsDeliveries["Service"]["processSignedJob"] = Effect.fn( | |
| 658 | 657 | "relay.apns_deliveries.process_signed_job", |
| 659 | 658 | )(function* (body) { |
| 660 | 659 | const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( |
| 661 | 660 | Effect.mapError( |
| 662 | 661 | () => |
| 663 | 662 | new ApnsDeliveryJobInvalid({ |
| 664 | message: "Invalid APNs delivery queue job.", | |
| 663 | reason: "invalid-queue-payload", | |
| 665 | 664 | }), |
| 666 | 665 | ), |
| 667 | 666 | ); |
| ... | ... | @@ -686,7 +685,7 @@ const make = Effect.gen(function* () { |
| 686 | 685 | if (payload.aggregate === null) { |
| 687 | 686 | return Effect.fail( |
| 688 | 687 | new ApnsDeliveryJobInvalid({ |
| 689 | message: "Live Activity start/update jobs require an aggregate.", | |
| 688 | reason: "missing-live-activity-aggregate", | |
| 690 | 689 | }), |
| 691 | 690 | ); |
| 692 | 691 | } |
| ... | ... | @@ -715,7 +714,7 @@ const make = Effect.gen(function* () { |
| 715 | 714 | if (payload.notification === null) { |
| 716 | 715 | return Effect.fail( |
| 717 | 716 | new ApnsDeliveryJobInvalid({ |
| 718 | message: "Push notification jobs require a notification payload.", | |
| 717 | reason: "missing-push-notification", | |
| 719 | 718 | }), |
| 720 | 719 | ); |
| 721 | 720 | } |
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts+21-24| ... | ... | @@ -33,34 +33,31 @@ export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass<ApnsDeli |
| 33 | 33 | |
| 34 | 34 | export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; |
| 35 | 35 | |
| 36 | export interface ApnsDeliveryQueueSenderShape { | |
| 37 | readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>; | |
| 38 | } | |
| 39 | ||
| 40 | 36 | export class ApnsDeliveryQueueSender extends Context.Service< |
| 41 | 37 | ApnsDeliveryQueueSender, |
| 42 | ApnsDeliveryQueueSenderShape | |
| 38 | { | |
| 39 | readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>; | |
| 40 | } | |
| 43 | 41 | >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} |
| 44 | 42 | |
| 45 | export interface ApnsDeliveryQueueShape { | |
| 46 | readonly enqueueLiveActivity: (input: { | |
| 47 | readonly kind: ApnsDeliveryJobPayload["kind"]; | |
| 48 | readonly userId: string; | |
| 49 | readonly deviceId: string; | |
| 50 | readonly token: string; | |
| 51 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | |
| 52 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | |
| 53 | readonly enqueuePushNotification: (input: { | |
| 54 | readonly userId: string; | |
| 55 | readonly deviceId: string; | |
| 56 | readonly token: string; | |
| 57 | readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; | |
| 58 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | |
| 59 | } | |
| 60 | ||
| 61 | export class ApnsDeliveryQueue extends Context.Service<ApnsDeliveryQueue, ApnsDeliveryQueueShape>()( | |
| 62 | "t3code-relay/agentActivity/ApnsDeliveryQueue", | |
| 63 | ) {} | |
| 43 | export class ApnsDeliveryQueue extends Context.Service< | |
| 44 | ApnsDeliveryQueue, | |
| 45 | { | |
| 46 | readonly enqueueLiveActivity: (input: { | |
| 47 | readonly kind: ApnsDeliveryJobPayload["kind"]; | |
| 48 | readonly userId: string; | |
| 49 | readonly deviceId: string; | |
| 50 | readonly token: string; | |
| 51 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | |
| 52 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | |
| 53 | readonly enqueuePushNotification: (input: { | |
| 54 | readonly userId: string; | |
| 55 | readonly deviceId: string; | |
| 56 | readonly token: string; | |
| 57 | readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; | |
| 58 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | |
| 59 | } | |
| 60 | >()("t3code-relay/agentActivity/ApnsDeliveryQueue") {} | |
| 64 | 61 | |
| 65 | 62 | const make = Effect.gen(function* () { |
| 66 | 63 | const sender = yield* ApnsDeliveryQueueSender; |
infra/relay/src/agentActivity/DeliveryAttempts.ts+14-15| ... | ... | @@ -43,21 +43,20 @@ export interface DeliveryAttemptCompletionInput { |
| 43 | 43 | |
| 44 | 44 | export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight"; |
| 45 | 45 | |
| 46 | export interface DeliveryAttemptsShape { | |
| 47 | readonly record: ( | |
| 48 | input: DeliveryAttemptInput, | |
| 49 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | |
| 50 | readonly claimSourceJob: ( | |
| 51 | input: DeliveryAttemptInput & { readonly sourceJobId: string }, | |
| 52 | ) => Effect.Effect<DeliverySourceJobClaimResult, DeliveryAttemptRecordPersistenceError>; | |
| 53 | readonly completeSourceJob: ( | |
| 54 | input: DeliveryAttemptCompletionInput, | |
| 55 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | |
| 56 | } | |
| 57 | ||
| 58 | export class DeliveryAttempts extends Context.Service<DeliveryAttempts, DeliveryAttemptsShape>()( | |
| 59 | "t3code-relay/agentActivity/DeliveryAttempts", | |
| 60 | ) {} | |
| 46 | export class DeliveryAttempts extends Context.Service< | |
| 47 | DeliveryAttempts, | |
| 48 | { | |
| 49 | readonly record: ( | |
| 50 | input: DeliveryAttemptInput, | |
| 51 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | |
| 52 | readonly claimSourceJob: ( | |
| 53 | input: DeliveryAttemptInput & { readonly sourceJobId: string }, | |
| 54 | ) => Effect.Effect<DeliverySourceJobClaimResult, DeliveryAttemptRecordPersistenceError>; | |
| 55 | readonly completeSourceJob: ( | |
| 56 | input: DeliveryAttemptCompletionInput, | |
| 57 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | |
| 58 | } | |
| 59 | >()("t3code-relay/agentActivity/DeliveryAttempts") {} | |
| 61 | 60 | |
| 62 | 61 | const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; |
| 63 | 62 |
infra/relay/src/agentActivity/Devices.ts+16-17| ... | ... | @@ -40,23 +40,22 @@ export class DeviceListPersistenceError extends Schema.TaggedErrorClass<DeviceLi |
| 40 | 40 | } |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | export interface DevicesShape { | |
| 44 | readonly register: (input: { | |
| 45 | readonly userId: string; | |
| 46 | readonly registration: RelayDeviceRegistrationRequest; | |
| 47 | }) => Effect.Effect<void, DeviceRegistrationPersistenceError>; | |
| 48 | readonly unregister: (input: { | |
| 49 | readonly userId: string; | |
| 50 | readonly deviceId: string; | |
| 51 | }) => Effect.Effect<void, DeviceUnregistrationPersistenceError>; | |
| 52 | readonly listForUser: (input: { | |
| 53 | readonly userId: string; | |
| 54 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, DeviceListPersistenceError>; | |
| 55 | } | |
| 56 | ||
| 57 | export class Devices extends Context.Service<Devices, DevicesShape>()( | |
| 58 | "t3code-relay/agentActivity/Devices", | |
| 59 | ) {} | |
| 43 | export class Devices extends Context.Service< | |
| 44 | Devices, | |
| 45 | { | |
| 46 | readonly register: (input: { | |
| 47 | readonly userId: string; | |
| 48 | readonly registration: RelayDeviceRegistrationRequest; | |
| 49 | }) => Effect.Effect<void, DeviceRegistrationPersistenceError>; | |
| 50 | readonly unregister: (input: { | |
| 51 | readonly userId: string; | |
| 52 | readonly deviceId: string; | |
| 53 | }) => Effect.Effect<void, DeviceUnregistrationPersistenceError>; | |
| 54 | readonly listForUser: (input: { | |
| 55 | readonly userId: string; | |
| 56 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, DeviceListPersistenceError>; | |
| 57 | } | |
| 58 | >()("t3code-relay/agentActivity/Devices") {} | |
| 60 | 59 | |
| 61 | 60 | const make = Effect.gen(function* () { |
| 62 | 61 | const db = yield* RelayDb.RelayDb; |
infra/relay/src/agentActivity/LiveActivities.ts+36-37| ... | ... | @@ -7,7 +7,7 @@ import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSch |
| 7 | 7 | import * as Context from "effect/Context"; |
| 8 | 8 | import * as DateTime from "effect/DateTime"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import { cast } from "effect/Function"; | |
| 10 | import * as Function from "effect/Function"; | |
| 11 | 11 | import * as Layer from "effect/Layer"; |
| 12 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | import { and, eq, sql } from "drizzle-orm"; |
| ... | ... | @@ -64,41 +64,40 @@ export interface LiveActivityRow { |
| 64 | 64 | |
| 65 | 65 | export type TargetRow = DeviceRow & LiveActivityRow; |
| 66 | 66 | |
| 67 | export interface LiveActivitiesShape { | |
| 68 | readonly register: (input: { | |
| 69 | readonly userId: string; | |
| 70 | readonly registration: RelayLiveActivityRegistrationRequest; | |
| 71 | }) => Effect.Effect<void, LiveActivityRegistrationPersistenceError>; | |
| 72 | readonly listTargets: (input: { | |
| 73 | readonly userId: string; | |
| 74 | }) => Effect.Effect<ReadonlyArray<TargetRow>, LiveActivityTargetListPersistenceError>; | |
| 75 | readonly markDelivery: (input: { | |
| 76 | readonly userId: string; | |
| 77 | readonly deviceId: string; | |
| 78 | readonly kind: RelayDeliveryKind; | |
| 79 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 80 | readonly deliveredAt: string; | |
| 81 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 82 | readonly markStartQueued: (input: { | |
| 83 | readonly userId: string; | |
| 84 | readonly deviceId: string; | |
| 85 | readonly queuedAt: string; | |
| 86 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 87 | readonly clearStartQueued: (input: { | |
| 88 | readonly userId: string; | |
| 89 | readonly deviceId: string; | |
| 90 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 91 | readonly invalidateDeliveryToken: (input: { | |
| 92 | readonly userId: string; | |
| 93 | readonly deviceId: string; | |
| 94 | readonly kind: RelayDeliveryKind; | |
| 95 | readonly invalidatedAt: string; | |
| 96 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 97 | } | |
| 98 | ||
| 99 | export class LiveActivities extends Context.Service<LiveActivities, LiveActivitiesShape>()( | |
| 100 | "t3code-relay/agentActivity/LiveActivities", | |
| 101 | ) {} | |
| 67 | export class LiveActivities extends Context.Service< | |
| 68 | LiveActivities, | |
| 69 | { | |
| 70 | readonly register: (input: { | |
| 71 | readonly userId: string; | |
| 72 | readonly registration: RelayLiveActivityRegistrationRequest; | |
| 73 | }) => Effect.Effect<void, LiveActivityRegistrationPersistenceError>; | |
| 74 | readonly listTargets: (input: { | |
| 75 | readonly userId: string; | |
| 76 | }) => Effect.Effect<ReadonlyArray<TargetRow>, LiveActivityTargetListPersistenceError>; | |
| 77 | readonly markDelivery: (input: { | |
| 78 | readonly userId: string; | |
| 79 | readonly deviceId: string; | |
| 80 | readonly kind: RelayDeliveryKind; | |
| 81 | readonly aggregate: RelayAgentActivityAggregateState | null; | |
| 82 | readonly deliveredAt: string; | |
| 83 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 84 | readonly markStartQueued: (input: { | |
| 85 | readonly userId: string; | |
| 86 | readonly deviceId: string; | |
| 87 | readonly queuedAt: string; | |
| 88 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 89 | readonly clearStartQueued: (input: { | |
| 90 | readonly userId: string; | |
| 91 | readonly deviceId: string; | |
| 92 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 93 | readonly invalidateDeliveryToken: (input: { | |
| 94 | readonly userId: string; | |
| 95 | readonly deviceId: string; | |
| 96 | readonly kind: RelayDeliveryKind; | |
| 97 | readonly invalidatedAt: string; | |
| 98 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | |
| 99 | } | |
| 100 | >()("t3code-relay/agentActivity/LiveActivities") {} | |
| 102 | 101 | |
| 103 | 102 | const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); |
| 104 | 103 | const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| ... | ... | @@ -223,7 +222,7 @@ const make = Effect.gen(function* () { |
| 223 | 222 | ? null |
| 224 | 223 | : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( |
| 225 | 224 | Effect.flatMap(decodeJsonString), |
| 226 | Effect.map(cast<unknown, RelayAgentActivityAggregateState>), | |
| 225 | Effect.map(Function.cast<unknown, RelayAgentActivityAggregateState>), | |
| 227 | 226 | ); |
| 228 | 227 | |
| 229 | 228 | yield* db |
infra/relay/src/agentActivity/MobileRegistrations.test.ts+21-18| ... | ... | @@ -38,7 +38,9 @@ const device: RelayDeviceRegistrationRequest = { |
| 38 | 38 | }, |
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | function makeDevices(overrides: Partial<Devices.DevicesShape> = {}): Devices.DevicesShape { | |
| 41 | function makeDevices( | |
| 42 | overrides: Partial<Devices.Devices["Service"]> = {}, | |
| 43 | ): Devices.Devices["Service"] { | |
| 42 | 44 | return { |
| 43 | 45 | register: () => Effect.void, |
| 44 | 46 | unregister: () => Effect.void, |
| ... | ... | @@ -48,8 +50,8 @@ function makeDevices(overrides: Partial<Devices.DevicesShape> = {}): Devices.Dev |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | function makeLiveActivities( |
| 51 | overrides: Partial<LiveActivities.LiveActivitiesShape> = {}, | |
| 52 | ): LiveActivities.LiveActivitiesShape { | |
| 53 | overrides: Partial<LiveActivities.LiveActivities["Service"]> = {}, | |
| 54 | ): LiveActivities.LiveActivities["Service"] { | |
| 53 | 55 | return { |
| 54 | 56 | register: () => Effect.void, |
| 55 | 57 | listTargets: () => Effect.succeed([]), |
| ... | ... | @@ -62,8 +64,8 @@ function makeLiveActivities( |
| 62 | 64 | } |
| 63 | 65 | |
| 64 | 66 | function makeAgentActivityRows( |
| 65 | overrides: Partial<AgentActivityRows.AgentActivityRowsShape> = {}, | |
| 66 | ): AgentActivityRows.AgentActivityRowsShape { | |
| 67 | overrides: Partial<AgentActivityRows.AgentActivityRows["Service"]> = {}, | |
| 68 | ): AgentActivityRows.AgentActivityRows["Service"] { | |
| 67 | 69 | return { |
| 68 | 70 | upsert: () => Effect.void, |
| 69 | 71 | remove: () => Effect.void, |
| ... | ... | @@ -108,8 +110,8 @@ function makeEnvironmentLinks( |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | 112 | function makeDeliveryAttempts( |
| 111 | overrides: Partial<DeliveryAttempts.DeliveryAttemptsShape> = {}, | |
| 112 | ): DeliveryAttempts.DeliveryAttemptsShape { | |
| 113 | overrides: Partial<DeliveryAttempts.DeliveryAttempts["Service"]> = {}, | |
| 114 | ): DeliveryAttempts.DeliveryAttempts["Service"] { | |
| 113 | 115 | return { |
| 114 | 116 | record: () => Effect.void, |
| 115 | 117 | claimSourceJob: () => Effect.succeed("claimed"), |
| ... | ... | @@ -138,8 +140,8 @@ const config = RelayConfiguration.RelayConfiguration.of({ |
| 138 | 140 | }); |
| 139 | 141 | |
| 140 | 142 | function makeRegistrationReplayLayer(input: { |
| 141 | readonly devices: Devices.DevicesShape; | |
| 142 | readonly liveActivities: LiveActivities.LiveActivitiesShape; | |
| 143 | readonly devices: Devices.Devices["Service"]; | |
| 144 | readonly liveActivities: LiveActivities.LiveActivities["Service"]; | |
| 143 | 145 | readonly queuedJobs: Array<SignedApnsDeliveryJob>; |
| 144 | 146 | }) { |
| 145 | 147 | return MobileRegistrations.layer.pipe( |
| ... | ... | @@ -167,8 +169,8 @@ function makeRegistrationReplayLayer(input: { |
| 167 | 169 | } |
| 168 | 170 | |
| 169 | 171 | function makeAgentActivityPublisher( |
| 170 | overrides: Partial<AgentActivityPublisher.AgentActivityPublisherShape> = {}, | |
| 171 | ): AgentActivityPublisher.AgentActivityPublisherShape { | |
| 172 | overrides: Partial<AgentActivityPublisher.AgentActivityPublisher["Service"]> = {}, | |
| 173 | ): AgentActivityPublisher.AgentActivityPublisher["Service"] { | |
| 172 | 174 | return { |
| 173 | 175 | publish: () => Effect.succeed({ ok: true, deliveries: [] }), |
| 174 | 176 | replayForLiveActivityRegistration: () => Effect.succeed(null), |
| ... | ... | @@ -178,10 +180,10 @@ function makeAgentActivityPublisher( |
| 178 | 180 | |
| 179 | 181 | describe("MobileRegistrations", () => { |
| 180 | 182 | it.effect("registers devices through the device persistence service", () => { |
| 181 | let registered: Parameters<Devices.DevicesShape["register"]>[0] | null = null; | |
| 183 | let registered: Parameters<Devices.Devices["Service"]["register"]>[0] | null = null; | |
| 182 | 184 | let replayed: |
| 183 | 185 | | Parameters< |
| 184 | AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] | |
| 186 | AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] | |
| 185 | 187 | >[0] |
| 186 | 188 | | null = null; |
| 187 | 189 | |
| ... | ... | @@ -263,7 +265,7 @@ describe("MobileRegistrations", () => { |
| 263 | 265 | }); |
| 264 | 266 | |
| 265 | 267 | it.effect("unregisters the current user's device", () => { |
| 266 | let unregistered: Parameters<Devices.DevicesShape["unregister"]>[0] | null = null; | |
| 268 | let unregistered: Parameters<Devices.Devices["Service"]["unregister"]>[0] | null = null; | |
| 267 | 269 | |
| 268 | 270 | return Effect.gen(function* () { |
| 269 | 271 | const result = yield* Effect.gen(function* () { |
| ... | ... | @@ -310,10 +312,11 @@ describe("MobileRegistrations", () => { |
| 310 | 312 | deviceId: "device-1" as const, |
| 311 | 313 | activityPushToken: "activity-token" as const, |
| 312 | 314 | }; |
| 313 | let registered: Parameters<LiveActivities.LiveActivitiesShape["register"]>[0] | null = null; | |
| 315 | let registered: Parameters<LiveActivities.LiveActivities["Service"]["register"]>[0] | null = | |
| 316 | null; | |
| 314 | 317 | let replayed: |
| 315 | 318 | | Parameters< |
| 316 | AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] | |
| 319 | AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"] | |
| 317 | 320 | >[0] |
| 318 | 321 | | null = null; |
| 319 | 322 | |
| ... | ... | @@ -372,9 +375,9 @@ describe("MobileRegistrations", () => { |
| 372 | 375 | () => { |
| 373 | 376 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; |
| 374 | 377 | const queuedStarts: Array< |
| 375 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | |
| 378 | Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0] | |
| 376 | 379 | > = []; |
| 377 | const registeredDevices: Array<Parameters<Devices.DevicesShape["register"]>[0]> = []; | |
| 380 | const registeredDevices: Array<Parameters<Devices.Devices["Service"]["register"]>[0]> = []; | |
| 378 | 381 | const devices = makeDevices({ |
| 379 | 382 | register: (input) => |
| 380 | 383 | Effect.sync(() => { |
infra/relay/src/agentActivity/MobileRegistrations.ts+14-16| ... | ... | @@ -15,24 +15,22 @@ export type MobileRegistrationError = |
| 15 | 15 | | Devices.DeviceUnregistrationPersistenceError |
| 16 | 16 | | LiveActivities.LiveActivityRegistrationPersistenceError; |
| 17 | 17 | |
| 18 | export interface MobileRegistrationsShape { | |
| 19 | readonly registerDevice: (input: { | |
| 20 | readonly userId: string; | |
| 21 | readonly payload: RelayDeviceRegistrationRequest; | |
| 22 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 23 | readonly registerLiveActivity: (input: { | |
| 24 | readonly userId: string; | |
| 25 | readonly payload: RelayLiveActivityRegistrationRequest; | |
| 26 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 27 | readonly unregisterDevice: (input: { | |
| 28 | readonly userId: string; | |
| 29 | readonly deviceId: string; | |
| 30 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 31 | } | |
| 32 | ||
| 33 | 18 | export class MobileRegistrations extends Context.Service< |
| 34 | 19 | MobileRegistrations, |
| 35 | MobileRegistrationsShape | |
| 20 | { | |
| 21 | readonly registerDevice: (input: { | |
| 22 | readonly userId: string; | |
| 23 | readonly payload: RelayDeviceRegistrationRequest; | |
| 24 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 25 | readonly registerLiveActivity: (input: { | |
| 26 | readonly userId: string; | |
| 27 | readonly payload: RelayLiveActivityRegistrationRequest; | |
| 28 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 29 | readonly unregisterDevice: (input: { | |
| 30 | readonly userId: string; | |
| 31 | readonly deviceId: string; | |
| 32 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | |
| 33 | } | |
| 36 | 34 | >()("t3code-relay/agentActivity/MobileRegistrations") {} |
| 37 | 35 | |
| 38 | 36 | const make = Effect.gen(function* () { |
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+48-12| ... | ... | @@ -52,9 +52,45 @@ export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; |
| 52 | 52 | export class ApnsDeliveryJobInvalid extends Schema.TaggedErrorClass<ApnsDeliveryJobInvalid>()( |
| 53 | 53 | "ApnsDeliveryJobInvalid", |
| 54 | 54 | { |
| 55 | message: Schema.String, | |
| 55 | reason: Schema.Literals([ | |
| 56 | "invalid-queue-payload", | |
| 57 | "missing-live-activity-aggregate", | |
| 58 | "unexpected-live-activity-notification", | |
| 59 | "missing-push-notification", | |
| 60 | "unexpected-push-notification-aggregate", | |
| 61 | "invalid-created-at", | |
| 62 | "invalid-expires-at", | |
| 63 | "invalid-time-window", | |
| 64 | "time-window-too-long", | |
| 65 | "invalid-signature", | |
| 66 | ]), | |
| 56 | 67 | }, |
| 57 | ) {} | |
| 68 | ) { | |
| 69 | override get message(): string { | |
| 70 | switch (this.reason) { | |
| 71 | case "invalid-queue-payload": | |
| 72 | return "Invalid APNs delivery queue job."; | |
| 73 | case "missing-live-activity-aggregate": | |
| 74 | return "Live Activity start/update jobs require an aggregate."; | |
| 75 | case "unexpected-live-activity-notification": | |
| 76 | return "Live Activity jobs must not carry push notification payloads."; | |
| 77 | case "missing-push-notification": | |
| 78 | return "Push notification jobs require a notification payload."; | |
| 79 | case "unexpected-push-notification-aggregate": | |
| 80 | return "Push notification jobs must not carry aggregate state."; | |
| 81 | case "invalid-created-at": | |
| 82 | return "Invalid APNs delivery job creation time."; | |
| 83 | case "invalid-expires-at": | |
| 84 | return "Invalid APNs delivery job expiry."; | |
| 85 | case "invalid-time-window": | |
| 86 | return "Invalid APNs delivery job time window."; | |
| 87 | case "time-window-too-long": | |
| 88 | return "APNs delivery job time window is too long."; | |
| 89 | case "invalid-signature": | |
| 90 | return "Invalid APNs delivery job signature."; | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
| 58 | 94 | |
| 59 | 95 | export class ApnsDeliveryJobExpired extends Schema.TaggedErrorClass<ApnsDeliveryJobExpired>()( |
| 60 | 96 | "ApnsDeliveryJobExpired", |
| ... | ... | @@ -106,31 +142,31 @@ function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobI |
| 106 | 142 | case "live_activity_update": |
| 107 | 143 | if (payload.aggregate === null) { |
| 108 | 144 | return new ApnsDeliveryJobInvalid({ |
| 109 | message: "Live Activity start/update jobs require an aggregate.", | |
| 145 | reason: "missing-live-activity-aggregate", | |
| 110 | 146 | }); |
| 111 | 147 | } |
| 112 | 148 | if (payload.notification !== null) { |
| 113 | 149 | return new ApnsDeliveryJobInvalid({ |
| 114 | message: "Live Activity jobs must not carry push notification payloads.", | |
| 150 | reason: "unexpected-live-activity-notification", | |
| 115 | 151 | }); |
| 116 | 152 | } |
| 117 | 153 | return null; |
| 118 | 154 | case "live_activity_end": |
| 119 | 155 | if (payload.notification !== null) { |
| 120 | 156 | return new ApnsDeliveryJobInvalid({ |
| 121 | message: "Live Activity jobs must not carry push notification payloads.", | |
| 157 | reason: "unexpected-live-activity-notification", | |
| 122 | 158 | }); |
| 123 | 159 | } |
| 124 | 160 | return null; |
| 125 | 161 | case "push_notification": |
| 126 | 162 | if (payload.notification === null) { |
| 127 | 163 | return new ApnsDeliveryJobInvalid({ |
| 128 | message: "Push notification jobs require a notification payload.", | |
| 164 | reason: "missing-push-notification", | |
| 129 | 165 | }); |
| 130 | 166 | } |
| 131 | 167 | if (payload.aggregate !== null) { |
| 132 | 168 | return new ApnsDeliveryJobInvalid({ |
| 133 | message: "Push notification jobs must not carry aggregate state.", | |
| 169 | reason: "unexpected-push-notification-aggregate", | |
| 134 | 170 | }); |
| 135 | 171 | } |
| 136 | 172 | return null; |
| ... | ... | @@ -177,19 +213,19 @@ export function verifySignedApnsDeliveryJob(input: { |
| 177 | 213 | } |
| 178 | 214 | const createdAt = DateTime.make(input.job.payload.createdAt); |
| 179 | 215 | if (Option.isNone(createdAt)) { |
| 180 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job creation time." }); | |
| 216 | return new ApnsDeliveryJobInvalid({ reason: "invalid-created-at" }); | |
| 181 | 217 | } |
| 182 | 218 | const expiresAt = DateTime.make(input.job.payload.expiresAt); |
| 183 | 219 | if (Option.isNone(expiresAt)) { |
| 184 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job expiry." }); | |
| 220 | return new ApnsDeliveryJobInvalid({ reason: "invalid-expires-at" }); | |
| 185 | 221 | } |
| 186 | 222 | const createdAtMs = createdAt.value.epochMilliseconds; |
| 187 | 223 | const expiresAtMs = expiresAt.value.epochMilliseconds; |
| 188 | 224 | if (expiresAtMs <= createdAtMs) { |
| 189 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job time window." }); | |
| 225 | return new ApnsDeliveryJobInvalid({ reason: "invalid-time-window" }); | |
| 190 | 226 | } |
| 191 | 227 | if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { |
| 192 | return new ApnsDeliveryJobInvalid({ message: "APNs delivery job time window is too long." }); | |
| 228 | return new ApnsDeliveryJobInvalid({ reason: "time-window-too-long" }); | |
| 193 | 229 | } |
| 194 | 230 | if (expiresAtMs <= input.nowMs) { |
| 195 | 231 | return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); |
| ... | ... | @@ -199,7 +235,7 @@ export function verifySignedApnsDeliveryJob(input: { |
| 199 | 235 | payload: input.job.payload, |
| 200 | 236 | }); |
| 201 | 237 | if (!timingSafeEqualBase64Url(input.job.signature, expected)) { |
| 202 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job signature." }); | |
| 238 | return new ApnsDeliveryJobInvalid({ reason: "invalid-signature" }); | |
| 203 | 239 | } |
| 204 | 240 | return input.job.payload; |
| 205 | 241 | } |