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
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vite-plus/test";3import { vi } from "vite-plus/test";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";8import * as Fiber from "effect/Fiber";
@@ -14,11 +15,21 @@ import * as Tracer from "effect/Tracer"; · 6 unmodified lines
14import * as HttpRouter from "effect/unstable/http/HttpRouter";15import * as HttpRouter from "effect/unstable/http/HttpRouter";
15import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";16import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
16import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";17import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
18import * as HttpServer from "effect/unstable/http/HttpServer";
19import * as HttpApiTest from "effect/unstable/httpapi/HttpApiTest";
20import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import { EnvironmentId } from "@t3tools/contracts";21import { EnvironmentId } from "@t3tools/contracts";
18import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";22import {
23 RelayApi,
24 RelayClientAuth,
25 RelayClientPrincipal,
26 RelayEnvironmentAuth,
27 type RelayClientDeviceRecord,
28} from "@t3tools/contracts/relay";
19 29
20import {30import {
21 RELAY_REQUEST_DEADLINE_MS,31 RELAY_REQUEST_DEADLINE_MS,
32 clientApi,
22 relayCors,33 relayCors,
23 relayDocsRedirectRoute,34 relayDocsRedirectRoute,
24 relayEnvironmentAuthLayer,35 relayEnvironmentAuthLayer,
@@ -35,6 +46,9 @@ import * as RelayDb from "../db.ts"; · 10 unmodified lines
35import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";46import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
36import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";47import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
37import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";48import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
49import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
50import * as RelayTokens from "../auth/RelayTokens.ts";
51import * as Devices from "../agentActivity/Devices.ts";
38 52
39vi.mock("@clerk/backend", () => ({53vi.mock("@clerk/backend", () => ({
40 createClerkClient: vi.fn(),54 createClerkClient: vi.fn(),
@@ -60,6 +74,79 @@ const relaySettings: RelayConfiguration.RelayConfiguration["Service"] = { · 19 unmodified lines
60 managedEndpointNamespace: undefined,74 managedEndpointNamespace: undefined,
61};75};
62 76
77describe("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
63describe("relay client authentication", () => {150describe("relay client authentication", () => {
64 it.effect("preserves the existing Clerk session JWT path", () =>151 it.effect("preserves the existing Clerk session JWT path", () =>
65 Effect.gen(function* () {152 Effect.gen(function* () {
infra/relay/src/http/Api.ts+14−0
@@ -545,6 +545,20 @@ export const clientApi = HttpApiBuilder.group( · 544 unmodified lines
545 .handle(545 .handle(
546 "listDevices",546 "listDevices",
547 Effect.fn("relay.api.client.listDevices")(function* () {547 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* () {
548 const { userId } = yield* RelayClientPrincipal;562 const { userId } = yield* RelayClientPrincipal;
549 return { devices: yield* devices.listForUser({ userId }) };563 return { devices: yield* devices.listForUser({ userId }) };
550 }, mapRelayCommonApiErrors("not_authorized")),564 }, mapRelayCommonApiErrors("not_authorized")),
packages/client-runtime/src/relay/managedRelay.test.ts+25−2
@@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => { · 635 unmodified lines
636 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));636 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
637 });637 });
638 638
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", () => {
640 const fetchFn = ((input, init) => {640 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");
642 expect(init?.headers).toMatchObject({642 expect(init?.headers).toMatchObject({
643 authorization: "Bearer clerk-token",643 authorization: "Bearer clerk-token",
644 });644 });
@@ -663,6 +663,23 @@ describe("ManagedRelayClient", () => { · 18 unmodified lines
663 },663 },
664 updatedAt: "2026-06-01T00:00:00.000Z",664 updatedAt: "2026-06-01T00:00:00.000Z",
665 },665 },
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 },
666 ],683 ],
667 }),684 }),
668 );685 );
@@ -679,6 +696,12 @@ describe("ManagedRelayClient", () => { · 10 unmodified lines
679 enabled: false,696 enabled: false,
680 },697 },
681 },698 },
699 {
700 deviceId: "device-2",
701 platform: "android",
702 iosMajorVersion: null,
703 androidApiLevel: 36,
704 },
682 ]);705 ]);
683 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));706 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
684 });707 });
packages/client-runtime/src/relay/managedRelay.ts+1−1
@@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( · 728 unmodified lines
729 listDevices: Effect.fnUntraced(729 listDevices: Effect.fnUntraced(
730 function* (input) {730 function* (input) {
731 return yield* client.client731 return yield* client.client
732 .listDevices({732 .listDevicesV2({
733 headers: bearerHeaders(input.clerkToken),733 headers: bearerHeaders(input.clerkToken),
734 })734 })
735 .pipe(735 .pipe(
packages/contracts/src/relay.ts+16−0
@@ -97,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({ · 96 unmodified lines
97});97});
98export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;98export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;
99 99
100// Installed clients decode v1 as iOS-only. Keep that response contract frozen.
101export 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
100export const RelayLiveActivityRegistrationRequest = Schema.Struct({111export const RelayLiveActivityRegistrationRequest = Schema.Struct({
101 deviceId: TrimmedNonEmptyString,112 deviceId: TrimmedNonEmptyString,
102 activityPushToken: TrimmedNonEmptyString,113 activityPushToken: TrimmedNonEmptyString,
@@ -975,6 +986,11 @@ const RelayClientGroup = HttpApiGroup.make("client") · 872 unmodified lines
975 error: RelayAuthAndInternalErrors,986 error: RelayAuthAndInternalErrors,
976 }).annotate(OpenApi.Summary, "List linked environments"),987 }).annotate(OpenApi.Summary, "List linked environments"),
977 HttpApiEndpoint.get("listDevices", "/v1/client/devices", {988 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", {
978 headers: RelayBearerRequestHeaders,994 headers: RelayBearerRequestHeaders,
979 success: RelayListDevicesResponse,995 success: RelayListDevicesResponse,
980 error: RelayAuthAndInternalErrors,996 error: RelayAuthAndInternalErrors,