Skip to content

Commit b970b65

Browse files
fix(relay): version device listing for Android compatibility
1 parent afcbc6f commit b970b65

5 files changed

Lines changed: 144 additions & 4 deletions

File tree

‎infra/relay/src/http/Api.test.ts‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createClerkClient, verifyToken } from "@clerk/backend";
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";
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";
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"] = {
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‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,20 @@ export const clientApi = HttpApiBuilder.group(
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‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,9 @@ describe("ManagedRelayClient", () => {
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", () => {
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", () => {
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‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* (
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‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ export const RelayListDevicesResponse = Schema.Struct({
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")
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,

0 commit comments

Comments
 (0)