History

fix(relay): version device listing for Android compatibility

Julius Marminge committed 2 months ago

Commit b970b651 parent afcbc6f5 files · +144 −4Browse files

Unified · Split

5 files
  1. infra/relay/src/http/Api.test.ts +88 −1
  2. infra/relay/src/http/Api.ts +14 −0
  3. packages/client-runtime/src/relay/managedRelay.test.ts +25 −2
  4. packages/client-runtime/src/relay/managedRelay.ts +1 −1
  5. packages/contracts/src/relay.ts +16 −0
infra/relay/src/http/Api.test.ts+88−1
@@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; · 1 unmodified line
22 import { describe, expect, it } from "@effect/vitest";
33 import { vi } from "vite-plus/test";
44 import * as Context from "effect/Context";
5+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
56 import * as Duration from "effect/Duration";
67 import * as Effect from "effect/Effect";
78 import * as Fiber from "effect/Fiber";
@@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer"; · 6 unmodified lines
1415 import * as HttpRouter from "effect/unstable/http/HttpRouter";
1516 import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
1617 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
18+import * as HttpServer from "effect/unstable/http/HttpServer";
19+import * as HttpApiTest from "effect/unstable/httpapi/HttpApiTest";
20+import * as HttpApi from "effect/unstable/httpapi/HttpApi";
1721 import { EnvironmentId } from "@t3tools/contracts";
18−import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
22+import {
23+ RelayApi,
24+ RelayClientAuth,
25+ RelayClientPrincipal,
26+ RelayEnvironmentAuth,
27+ type RelayClientDeviceRecord,
28+} from "@t3tools/contracts/relay";
1929
2030 import {
2131 RELAY_REQUEST_DEADLINE_MS,
32+ clientApi,
2233 relayCors,
2334 relayDocsRedirectRoute,
2435 relayEnvironmentAuthLayer,
@@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts"; · 10 unmodified lines
3546 import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
3647 import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
3748 import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
49+import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
50+import * as RelayTokens from "../auth/RelayTokens.ts";
51+import * as Devices from "../agentActivity/Devices.ts";
3852
3953 vi.mock("@clerk/backend", () => ({
4054 createClerkClient: vi.fn(),
@@ -60,6 +74,79 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { · 19 unmodified lines
6074 managedEndpointNamespace: undefined,
6175 };
6276
77+describe("device listing compatibility", () => {
78+ it.effect("keeps v1 iOS-only while v2 returns every platform for the same account", () => {
79+ const iphone: RelayClientDeviceRecord = {
80+ deviceId: "iphone",
81+ label: "iPhone",
82+ platform: "ios",
83+ iosMajorVersion: 18,
84+ appVersion: "1.0.0",
85+ notifications: {
86+ enabled: true,
87+ notifyOnApproval: true,
88+ notifyOnInput: true,
89+ notifyOnCompletion: true,
90+ notifyOnFailure: true,
91+ },
92+ liveActivities: { enabled: true },
93+ updatedAt: "2026-09-09T00:00:00.000Z",
94+ };
95+ const android: RelayClientDeviceRecord = {
96+ ...iphone,
97+ deviceId: "android",
98+ label: "Android",
99+ platform: "android",
100+ iosMajorVersion: null,
101+ androidApiLevel: 36,
102+ };
103+ const handlers = clientApi.pipe(
104+ HttpRouter.provideRequest(
105+ Layer.mergeAll(
106+ Layer.mock(EnvironmentCredentials.EnvironmentCredentials, {}),
107+ Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
108+ Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
109+ Layer.mock(RelayDb.RelayTransactions, {}),
110+ ),
111+ ),
112+ Layer.provide(
113+ Layer.mergeAll(
114+ Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings),
115+ NodeCrypto.layer,
116+ Layer.mock(RelayTokens.RelayTokens, { resolveDpopAccessTokenScopes: () => null }),
117+ Layer.mock(EnvironmentLinker.EnvironmentLinker, {}),
118+ Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
119+ Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
120+ Layer.mock(Devices.Devices, {
121+ listForUser: ({ userId }) => {
122+ expect(userId).toBe("user-1");
123+ return Effect.succeed([iphone, android]);
124+ },
125+ }),
126+ ),
127+ ),
128+ Layer.provideMerge(
129+ Layer.succeed(RelayClientAuth, {
130+ clientBearer: (effect) =>
131+ Effect.provideService(effect, RelayClientPrincipal, {
132+ userId: "user-1",
133+ token: "test-token",
134+ }),
135+ }),
136+ ),
137+ );
138+ return Effect.gen(function* () {
139+ const client = yield* HttpApiTest.groups(
140+ HttpApi.make("RelayApi").add(RelayApi.groups.client),
141+ ["client"],
142+ );
143+ const request = { headers: { authorization: "Bearer test-token" } };
144+ expect(yield* client.client.listDevices(request)).toEqual({ devices: [iphone] });
145+ expect(yield* client.client.listDevicesV2(request)).toEqual({ devices: [iphone, android] });
146+ }).pipe(Effect.provide(Layer.mergeAll(handlers, HttpServer.layerServices)), Effect.scoped);
147+ });
148+});
149+
63150 describe("relay client authentication", () => {
64151 it.effect("preserves the existing Clerk session JWT path", () =>
65152 Effect.gen(function* () {
infra/relay/src/http/Api.ts+14−0
@@ -545,6 +545,20 @@ export const clientApi = HttpApiBuilder.group( · 544 unmodified lines
545545 .handle(
546546 "listDevices",
547547 Effect.fn("relay.api.client.listDevices")(function* () {
548+ const { userId } = yield* RelayClientPrincipal;
549+ const registered = yield* devices.listForUser({ userId });
550+ return {
551+ devices: registered.flatMap((device) =>
552+ device.platform === "ios" && device.iosMajorVersion !== null
553+ ? [{ ...device, platform: "ios" as const, iosMajorVersion: device.iosMajorVersion }]
554+ : [],
555+ ),
556+ };
557+ }, mapRelayCommonApiErrors("not_authorized")),
558+ )
559+ .handle(
560+ "listDevicesV2",
561+ Effect.fn("relay.api.client.listDevicesV2")(function* () {
548562 const { userId } = yield* RelayClientPrincipal;
549563 return { devices: yield* devices.listForUser({ userId }) };
550564 }, mapRelayCommonApiErrors("not_authorized")),
packages/client-runtime/src/relay/managedRelay.test.ts+25−2
@@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => { · 635 unmodified lines
636636 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
637637 });
638638
639− it.effect("lists account devices through the Clerk bearer client endpoint", () => {
639+ it.effect("lists account devices through the v2 Clerk bearer client endpoint", () => {
640640 const fetchFn = ((input, init) => {
641− expect(String(input)).toBe("https://relay.example.test/v1/client/devices");
641+ expect(String(input)).toBe("https://relay.example.test/v2/client/devices");
642642 expect(init?.headers).toMatchObject({
643643 authorization: "Bearer clerk-token",
644644 });
@@ -663,6 +663,23 @@ describe("ManagedRelayClient", () => { · 18 unmodified lines
663663 },
664664 updatedAt: "2026-06-01T00:00:00.000Z",
665665 },
666+ {
667+ deviceId: "device-2",
668+ label: "Android phone",
669+ platform: "android",
670+ iosMajorVersion: null,
671+ androidApiLevel: 36,
672+ appVersion: "1.0.0",
673+ notifications: {
674+ enabled: true,
675+ notifyOnApproval: true,
676+ notifyOnInput: true,
677+ notifyOnCompletion: true,
678+ notifyOnFailure: true,
679+ },
680+ liveActivities: { enabled: true },
681+ updatedAt: "2026-06-01T00:00:00.000Z",
682+ },
666683 ],
667684 }),
668685 );
@@ -679,6 +696,12 @@ describe("ManagedRelayClient", () => { · 10 unmodified lines
679696 enabled: false,
680697 },
681698 },
699+ {
700+ deviceId: "device-2",
701+ platform: "android",
702+ iosMajorVersion: null,
703+ androidApiLevel: 36,
704+ },
682705 ]);
683706 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
684707 });
packages/client-runtime/src/relay/managedRelay.ts+1−1
@@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( · 728 unmodified lines
729729 listDevices: Effect.fnUntraced(
730730 function* (input) {
731731 return yield* client.client
732− .listDevices({
732+ .listDevicesV2({
733733 headers: bearerHeaders(input.clerkToken),
734734 })
735735 .pipe(
packages/contracts/src/relay.ts+16−0
@@ -97,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({ · 96 unmodified lines
9797 });
9898 export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;
9999
100+// Installed clients decode v1 as iOS-only. Keep that response contract frozen.
101+export const RelayListDevicesResponseV1 = Schema.Struct({
102+ devices: Schema.Array(
103+ Schema.Struct({
104+ ...RelayClientDeviceRecord.fields,
105+ platform: Schema.Literal("ios"),
106+ iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)),
107+ }),
108+ ),
109+});
110+
100111 export const RelayLiveActivityRegistrationRequest = Schema.Struct({
101112 deviceId: TrimmedNonEmptyString,
102113 activityPushToken: TrimmedNonEmptyString,
@@ -975,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client") · 872 unmodified lines
975986 error: RelayAuthAndInternalErrors,
976987 }).annotate(OpenApi.Summary, "List linked environments"),
977988 HttpApiEndpoint.get("listDevices", "/v1/client/devices", {
989+ headers: RelayBearerRequestHeaders,
990+ success: RelayListDevicesResponseV1,
991+ error: RelayAuthAndInternalErrors,
992+ }).annotate(OpenApi.Summary, "List registered iOS devices (legacy clients)"),
993+ HttpApiEndpoint.get("listDevicesV2", "/v2/client/devices", {
978994 headers: RelayBearerRequestHeaders,
979995 success: RelayListDevicesResponse,
980996 error: RelayAuthAndInternalErrors,