1import type {
2 RelayAgentActivityState,
3 RelayDeviceRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts";
6import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
11import * as Redacted from "effect/Redacted";
12import { FetchHttpClient } from "effect/http";
13
14import * as Devices from "./Devices.ts";
15import * as AgentActivityRows from "./AgentActivityRows.ts";
16import * as DeliveryAttempts from "./DeliveryAttempts.ts";
17import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
18import * as LiveActivities from "./LiveActivities.ts";
19import * as RelayConfiguration from "../Config.ts";
20import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
21import * as FcmDeliveries from "./FcmDeliveries.ts";
22
23const layerPublisher = AgentActivityPublisher.layer.pipe(
24 Layer.provide(
25 Layer.succeed(FcmDeliveries.FcmDeliveries, {
26 enqueue: () => Effect.succeed(null),
27 process: () => Effect.void,
28 }),
29 ),
30);
31import * as ApnsDeliveries from "./ApnsDeliveries.ts";
32import * as ApnsClient from "./ApnsClient.ts";
33import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
34import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
35import * as MobileRegistrations from "./MobileRegistrations.ts";
36
37const device: RelayDeviceRegistrationRequest = {
38 deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"],
39 label: "Julius's iPhone",
40 platform: "ios",
41 iosMajorVersion: 18,
42 appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"],
43 preferences: {
44 liveActivitiesEnabled: true,
45 notificationsEnabled: true,
46 notifyOnApproval: true,
47 notifyOnInput: true,
48 notifyOnCompletion: true,
49 notifyOnFailure: true,
50 },
51};
52
53function makeDevices(
54 overrides: Partial<Devices.Devices["Service"]> = {},
55): Devices.Devices["Service"] {
56 return {
57 register: () => Effect.void,
58 unregister: () => Effect.void,
59 listForUser: () => Effect.succeed([]),
60 ...overrides,
61 };
62}
63
64function makeLiveActivities(
65 overrides: Partial<LiveActivities.LiveActivities["Service"]> = {},
66): LiveActivities.LiveActivities["Service"] {
67 return {
68 register: () => Effect.void,
69 listTargets: () => Effect.succeed([]),
70 markDelivery: () => Effect.void,
71 markStartQueued: () => Effect.void,
72 clearStartQueued: () => Effect.void,
73 invalidateDeliveryToken: () => Effect.void,
74 ...overrides,
75 };
76}
77
78function makeAgentActivityRows(
79 overrides: Partial<AgentActivityRows.AgentActivityRows["Service"]> = {},
80): AgentActivityRows.AgentActivityRows["Service"] {
81 return {
82 upsert: () => Effect.void,
83 remove: () => Effect.void,
84 pruneTerminal: () => Effect.void,
85 listForUser: () => {
86 const activeState: RelayAgentActivityState = {
87 environmentId: "env-1" as RelayAgentActivityState["environmentId"],
88 threadId: "thread-1" as RelayAgentActivityState["threadId"],
89 projectTitle: "Project",
90 threadTitle: "Implement APNs",
91 modelTitle: "gpt-5.4",
92 phase: "running",
93 headline: "Working",
94 updatedAt: "1970-01-01T00:00:10.000Z",
95 deepLink: "/env-1/thread-1",
96 };
97 return Effect.succeed([activeState]);
98 },
99 getForUserThread: () => Effect.succeed(null),
100 ...overrides,
101 };
102}
103
104function makeEnvironmentLinks(
105 overrides: Partial<EnvironmentLinks.EnvironmentLinks["Service"]> = {},
106): EnvironmentLinks.EnvironmentLinks["Service"] {
107 return {
108 upsert: () => Effect.void,
109 listDeliveryUsersForEnvironment: () =>
110 Effect.succeed([
111 {
112 userId: "dev:julius",
113 notificationsEnabled: true,
114 liveActivitiesEnabled: true,
115 },
116 ]),
117 listForUser: () => Effect.succeed([]),
118 getForUser: () => Effect.succeed(null),
119 findActiveManagedForEnvironment: () => Effect.succeed([]),
120 setHoldWebhooksWhileOffline: () => Effect.void,
121 revokeForUser: () => Effect.succeed(false),
122 ...overrides,
123 };
124}
125
126function makeDeliveryAttempts(
127 overrides: Partial<DeliveryAttempts.DeliveryAttempts["Service"]> = {},
128): DeliveryAttempts.DeliveryAttempts["Service"] {
129 return {
130 record: () => Effect.void,
131 claimSourceJob: () => Effect.succeed("claimed"),
132 completeSourceJob: () => Effect.void,
133 ...overrides,
134 };
135}
136
137const config = RelayConfiguration.RelayConfiguration.of({
138 relayIssuer: "https://relay.example.test",
139 apns: {
140 environment: "sandbox",
141 teamId: "team-id",
142 keyId: "key-id",
143 bundleId: "codes.t3.mobile",
144 privateKey: Redacted.make("apns-private-key"),
145 },
146 clerkSecretKey: Redacted.make("clerk-secret"),
147 clerkPublishableKey: "pk_test_test",
148 clerkJwtAudience: "t3-code-relay",
149 apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"),
150 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
151 cloudMintPublicKey: "cloud-public-key",
152 managedEndpointBaseDomain: undefined,
153 managedEndpointNamespace: undefined,
154});
155
156function layerRegistrationReplay(input: {
157 readonly devices: Devices.Devices["Service"];
158 readonly liveActivities: LiveActivities.LiveActivities["Service"];
159 readonly queuedJobs: Array<SignedApnsDeliveryJob>;
160}) {
161 return MobileRegistrations.layer.pipe(
162 Layer.provide(layerPublisher),
163 Layer.provide(
164 ApnsDeliveries.layer.pipe(
165 Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))),
166 ),
167 ),
168 Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))),
169 Layer.provide(
170 Layer.mergeAll(
171 Layer.succeed(Devices.Devices, input.devices),
172 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
173 Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()),
174 Layer.succeed(LiveActivities.LiveActivities, input.liveActivities),
175 Layer.succeed(DeliveryAttempts.DeliveryAttempts, makeDeliveryAttempts()),
176 RelayConfiguration.layer(config),
177 Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
178 send: (body) =>
179 Effect.sync(() => {
180 input.queuedJobs.push(body);
181 }),
182 }),
183 ),
184 ),
185 Layer.provide(FetchHttpClient.layer),
186 );
187}
188
189function makeAgentActivityPublisher(
190 overrides: Partial<AgentActivityPublisher.AgentActivityPublisher["Service"]> = {},
191): AgentActivityPublisher.AgentActivityPublisher["Service"] {
192 return {
193 publish: () => Effect.succeed({ ok: true, deliveries: [] }),
194 replayForLiveActivityRegistration: () => Effect.succeed(null),
195 ...overrides,
196 };
197}
198
199describe("MobileRegistrations", () => {
200 it.effect("registers devices through the device persistence service", () => {
201 let registered: Parameters<Devices.Devices["Service"]["register"]>[0] | null = null;
202 let replayed:
203 | Parameters<
204 AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"]
205 >[0]
206 | null = null;
207
208 return Effect.gen(function* () {
209 const result = yield* Effect.gen(function* () {
210 const registrations = yield* MobileRegistrations.MobileRegistrations;
211 return yield* registrations.registerDevice({ userId: "dev:julius", payload: device });
212 }).pipe(
213 Effect.provide(
214 MobileRegistrations.layer.pipe(
215 Layer.provide(
216 Layer.mergeAll(
217 Layer.succeed(
218 Devices.Devices,
219 makeDevices({
220 register: (input) =>
221 Effect.sync(() => {
222 registered = input;
223 }),
224 }),
225 ),
226 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
227 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
228 Layer.succeed(
229 AgentActivityPublisher.AgentActivityPublisher,
230 makeAgentActivityPublisher({
231 replayForLiveActivityRegistration: (input) =>
232 Effect.sync(() => {
233 replayed = input;
234 return null;
235 }),
236 }),
237 ),
238 ),
239 ),
240 ),
241 ),
242 );
243
244 expect(result).toEqual({ ok: true });
245 expect(registered).toEqual({ userId: "dev:julius", registration: device });
246 expect(replayed).toEqual({
247 userId: "dev:julius",
248 deviceId: "device-1",
249 });
250 });
251 });
252
253 it.effect("keeps device registration successful when activity replay fails", () => {
254 const messages: unknown[] = [];
255 const logger = Logger.make(({ message }) => {
256 messages.push(message);
257 });
258
259 return Effect.gen(function* () {
260 const result = yield* Effect.gen(function* () {
261 const registrations = yield* MobileRegistrations.MobileRegistrations;
262 return yield* registrations.registerDevice({ userId: "dev:julius", payload: device });
263 }).pipe(
264 Effect.provide(
265 MobileRegistrations.layer.pipe(
266 Layer.provide(
267 Layer.mergeAll(
268 Layer.succeed(Devices.Devices, makeDevices()),
269 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
270 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
271 Layer.succeed(
272 AgentActivityPublisher.AgentActivityPublisher,
273 makeAgentActivityPublisher({
274 replayForLiveActivityRegistration: () =>
275 Effect.fail(
276 new AgentActivityRows.AgentActivityRowListPersistenceError({
277 userId: "dev:julius",
278 cause: "sensitive device replay detail",
279 }),
280 ),
281 }),
282 ),
283 ),
284 ),
285 ),
286 ),
287 );
288
289 expect(result).toEqual({ ok: true });
290 expect(messages).toContainEqual([
291 "device registration activity replay failed",
292 { errorTag: "AgentActivityRowListPersistenceError" },
293 ]);
294 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
295 });
296
297 it.effect("unregisters the current user's device", () => {
298 let unregistered: Parameters<Devices.Devices["Service"]["unregister"]>[0] | null = null;
299
300 return Effect.gen(function* () {
301 const result = yield* Effect.gen(function* () {
302 const registrations = yield* MobileRegistrations.MobileRegistrations;
303 return yield* registrations.unregisterDevice({
304 userId: "dev:julius",
305 deviceId: "device-1",
306 });
307 }).pipe(
308 Effect.provide(
309 MobileRegistrations.layer.pipe(
310 Layer.provide(
311 Layer.mergeAll(
312 Layer.succeed(
313 Devices.Devices,
314 makeDevices({
315 unregister: (input) =>
316 Effect.sync(() => {
317 unregistered = input;
318 }),
319 }),
320 ),
321 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
322 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
323 Layer.succeed(
324 AgentActivityPublisher.AgentActivityPublisher,
325 makeAgentActivityPublisher(),
326 ),
327 ),
328 ),
329 ),
330 ),
331 );
332
333 expect(result).toEqual({ ok: true });
334 expect(unregistered).toEqual({
335 userId: "dev:julius",
336 deviceId: "device-1",
337 });
338 });
339 });
340
341 it.effect("replays the latest activity state after registering a Live Activity token", () => {
342 const liveActivity = {
343 deviceId: "device-1" as const,
344 activityPushToken: "activity-token" as const,
345 };
346 let registered: Parameters<LiveActivities.LiveActivities["Service"]["register"]>[0] | null =
347 null;
348 let replayed:
349 | Parameters<
350 AgentActivityPublisher.AgentActivityPublisher["Service"]["replayForLiveActivityRegistration"]
351 >[0]
352 | null = null;
353
354 return Effect.gen(function* () {
355 const result = yield* Effect.gen(function* () {
356 const registrations = yield* MobileRegistrations.MobileRegistrations;
357 return yield* registrations.registerLiveActivity({
358 userId: "dev:julius",
359 payload: liveActivity,
360 });
361 }).pipe(
362 Effect.provide(
363 MobileRegistrations.layer.pipe(
364 Layer.provide(
365 Layer.mergeAll(
366 Layer.succeed(Devices.Devices, makeDevices()),
367 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
368 Layer.succeed(
369 LiveActivities.LiveActivities,
370 makeLiveActivities({
371 register: (input) =>
372 Effect.sync(() => {
373 registered = input;
374 }),
375 }),
376 ),
377 Layer.succeed(
378 AgentActivityPublisher.AgentActivityPublisher,
379 makeAgentActivityPublisher({
380 replayForLiveActivityRegistration: (input) =>
381 Effect.sync(() => {
382 replayed = input;
383 return null;
384 }),
385 }),
386 ),
387 ),
388 ),
389 ),
390 ),
391 );
392
393 expect(result).toEqual({ ok: true });
394 expect(registered).toEqual({
395 userId: "dev:julius",
396 registration: liveActivity,
397 });
398 expect(replayed).toEqual({
399 userId: "dev:julius",
400 deviceId: "device-1",
401 });
402 });
403 });
404
405 it.effect("returns the current aggregate for the app's arming decision", () => {
406 return Effect.gen(function* () {
407 const registrations = yield* MobileRegistrations.MobileRegistrations;
408 const snapshot = yield* registrations.getAgentActivitySnapshot({ userId: "dev:julius" });
409
410 expect(snapshot.aggregate).toMatchObject({
411 activeCount: 1,
412 activities: [{ threadId: "thread-1", phase: "running" }],
413 });
414 }).pipe(
415 Effect.provide(
416 MobileRegistrations.layer.pipe(
417 Layer.provide(
418 Layer.mergeAll(
419 Layer.succeed(Devices.Devices, makeDevices()),
420 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
421 Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()),
422 Layer.succeed(
423 AgentActivityPublisher.AgentActivityPublisher,
424 makeAgentActivityPublisher(),
425 ),
426 ),
427 ),
428 ),
429 ),
430 );
431 });
432
433 it.effect(
434 "does not remotely start a Live Activity when a device registers after work is already active",
435 () => {
436 const queuedJobs: Array<SignedApnsDeliveryJob> = [];
437 const queuedStarts: Array<
438 Parameters<LiveActivities.LiveActivities["Service"]["markStartQueued"]>[0]
439 > = [];
440 const registeredDevices: Array<Parameters<Devices.Devices["Service"]["register"]>[0]> = [];
441 const devices = makeDevices({
442 register: (input) =>
443 Effect.sync(() => {
444 registeredDevices.push(input);
445 }),
446 });
447 const liveActivities = makeLiveActivities({
448 listTargets: () =>
449 Effect.succeed([
450 {
451 user_id: "dev:julius",
452 device_id: "device-1",
453 platform: "ios",
454 ios_major_version: 18,
455 app_version: "1.0.0",
456 bundle_id: null,
457 aps_environment: null,
458 push_token: "apns-device-token",
459 push_to_start_token: "push-to-start-token",
460 preferences_json: JSON.stringify(device.preferences),
461 activity_push_token: null,
462 remote_start_queued_at: null,
463 remote_started_at: null,
464 ended_at: null,
465 last_aggregate_json: null,
466 last_live_activity_delivery_at: null,
467 },
468 ]),
469 markStartQueued: (input) =>
470 Effect.sync(() => {
471 queuedStarts.push(input);
472 }),
473 });
474
475 return Effect.gen(function* () {
476 const registrations = yield* MobileRegistrations.MobileRegistrations;
477 const result = yield* registrations.registerDevice({
478 userId: "dev:julius",
479 payload: {
480 ...device,
481 pushToken: "apns-device-token",
482 pushToStartToken: "push-to-start-token",
483 },
484 });
485
486 // Activities are armed by the app in the foreground; a device
487 // registration alone never remote-starts one, even when work is
488 // already active and a push-to-start token is on file.
489 expect(result).toEqual({ ok: true });
490 expect(registeredDevices).toHaveLength(1);
491 expect(queuedStarts).toEqual([]);
492 expect(queuedJobs).toEqual([]);
493 }).pipe(Effect.provide(layerRegistrationReplay({ devices, liveActivities, queuedJobs })));
494 },
495 );
496});