authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 00:17:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 07:17:29+00:00
logc00e721c047ab0b832401196e64df26431fd9c98
treee5f8101c265ec4d44d92e1559e3f3ca6ffd11c30
parentb0a3a504481ab4d8f2da0343e5a4258c10d03c73
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor client runtime Effect services (#3200)

Co-authored-by: codex <codex@users.noreply.github.com>

29 files changed, 962 insertions(+), 765 deletions(-)

apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+2-2
...@@ -2,7 +2,7 @@ import { beforeEach, vi } from "vite-plus/test";...@@ -2,7 +2,7 @@ import { beforeEach, vi } from "vite-plus/test";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelayClient } from "@t3tools/client-runtime/relay";5import { ManagedRelay } from "@t3tools/client-runtime/relay";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
88
...@@ -35,7 +35,7 @@ const connection: SavedRemoteConnection = {...@@ -35,7 +35,7 @@ const connection: SavedRemoteConnection = {
35};35};
3636
37const testLayer = Layer.mergeAll(37const testLayer = Layer.mergeAll(
38 Layer.succeed(ManagedRelayClient, null as never),38 Layer.succeed(ManagedRelay.ManagedRelayClient, null as never),
39 Layer.succeed(39 Layer.succeed(
40 HttpClient.HttpClient,40 HttpClient.HttpClient,
41 HttpClient.make(() => Effect.die("unexpected HTTP request")),41 HttpClient.make(() => Effect.die("unexpected HTTP request")),
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import { HttpClient } from "effect/unstable/http";2import { HttpClient } from "effect/unstable/http";
3import { ManagedRelayClient } from "@t3tools/client-runtime/relay";3import { ManagedRelay } from "@t3tools/client-runtime/relay";
44
5import type { SavedRemoteConnection } from "../../lib/connection";5import type { SavedRemoteConnection } from "../../lib/connection";
6import { savePreferencesPatch } from "../../lib/storage";6import { savePreferencesPatch } from "../../lib/storage";
...@@ -11,7 +11,7 @@ export function setLiveActivityUpdatesEnabled(input: {...@@ -11,7 +11,7 @@ export function setLiveActivityUpdatesEnabled(input: {
11 readonly enabled: boolean;11 readonly enabled: boolean;
12 readonly clerkToken: string | null;12 readonly clerkToken: string | null;
13 readonly connections: ReadonlyArray<SavedRemoteConnection>;13 readonly connections: ReadonlyArray<SavedRemoteConnection>;
14}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelayClient> {14}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> {
15 return Effect.gen(function* () {15 return Effect.gen(function* () {
16 yield* Effect.tryPromise({16 yield* Effect.tryPromise({
17 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),17 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+2-2
...@@ -9,7 +9,7 @@ import * as Effect from "effect/Effect";...@@ -9,7 +9,7 @@ import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";9import * as Exit from "effect/Exit";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import { FetchHttpClient } from "effect/unstable/http";11import { FetchHttpClient } from "effect/unstable/http";
12import { type ManagedRelayClient } from "@t3tools/client-runtime/relay";12import { ManagedRelay } from "@t3tools/client-runtime/relay";
1313
14import type { EnvironmentId } from "@t3tools/contracts";14import type { EnvironmentId } from "@t3tools/contracts";
15import { verifyDpopProof } from "@t3tools/shared/dpop";15import { verifyDpopProof } from "@t3tools/shared/dpop";
...@@ -158,7 +158,7 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO...@@ -158,7 +158,7 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO
158 }158 }
159 idlePasses = 0;159 idlePasses = 0;
160 const exit = yield* Effect.exit(160 const exit = yield* Effect.exit(
161 pending.operation as Effect.Effect<unknown, unknown, ManagedRelayClient>,161 pending.operation as Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
162 );162 );
163 yield* Effect.sync(() => {163 yield* Effect.sync(() => {
164 pending.resolve(exit);164 pending.resolve(exit);
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+15-15
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 type RelayLiveActivityRegistrationRequest,9 type RelayLiveActivityRegistrationRequest,
10} from "@t3tools/contracts/relay";10} from "@t3tools/contracts/relay";
11import { findErrorTraceId } from "@t3tools/client-runtime/errors";11import { findErrorTraceId } from "@t3tools/client-runtime/errors";
12import { ManagedRelayClient } from "@t3tools/client-runtime/relay";12import { ManagedRelay } from "@t3tools/client-runtime/relay";
13import {13import {
14 isAtomCommandInterrupted,14 isAtomCommandInterrupted,
15 settleAsyncResult,15 settleAsyncResult,
...@@ -175,7 +175,7 @@ const relayToken = Effect.gen(function* () {...@@ -175,7 +175,7 @@ const relayToken = Effect.gen(function* () {
175function registerDeviceWithRelay(175function registerDeviceWithRelay(
176 body: RelayDeviceRegistrationRequest,176 body: RelayDeviceRegistrationRequest,
177 expectedGeneration: number,177 expectedGeneration: number,
178): Effect.Effect<void, unknown, ManagedRelayClient> {178): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
179 return Effect.gen(function* () {179 return Effect.gen(function* () {
180 if (expectedGeneration !== deviceRegistrationGeneration) {180 if (expectedGeneration !== deviceRegistrationGeneration) {
181 logRegistrationDebug("device registration cancelled before relay request", {181 logRegistrationDebug("device registration cancelled before relay request", {
...@@ -198,7 +198,7 @@ function registerDeviceWithRelay(...@@ -198,7 +198,7 @@ function registerDeviceWithRelay(
198 return;198 return;
199 }199 }
200200
201 const client = yield* ManagedRelayClient;201 const client = yield* ManagedRelay.ManagedRelayClient;
202 logRegistrationDebug("relay device registration request started", {202 logRegistrationDebug("relay device registration request started", {
203 expectedGeneration,203 expectedGeneration,
204 });204 });
...@@ -215,7 +215,7 @@ function registerDeviceWithRelay(...@@ -215,7 +215,7 @@ function registerDeviceWithRelay(
215function unregisterDeviceWithRelay(input: {215function unregisterDeviceWithRelay(input: {
216 readonly deviceId: string;216 readonly deviceId: string;
217 readonly tokenProvider: () => Promise<string | null>;217 readonly tokenProvider: () => Promise<string | null>;
218}): Effect.Effect<void, unknown, ManagedRelayClient> {218}): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
219 return Effect.gen(function* () {219 return Effect.gen(function* () {
220 if (!readRelayConfig()) return;220 if (!readRelayConfig()) return;
221 const token = yield* Effect.tryPromise({221 const token = yield* Effect.tryPromise({
...@@ -227,7 +227,7 @@ function unregisterDeviceWithRelay(input: {...@@ -227,7 +227,7 @@ function unregisterDeviceWithRelay(input: {
227 return;227 return;
228 }228 }
229229
230 const client = yield* ManagedRelayClient;230 const client = yield* ManagedRelay.ManagedRelayClient;
231 yield* client.unregisterDevice({231 yield* client.unregisterDevice({
232 clerkToken: token,232 clerkToken: token,
233 deviceId: input.deviceId,233 deviceId: input.deviceId,
...@@ -237,7 +237,7 @@ function unregisterDeviceWithRelay(input: {...@@ -237,7 +237,7 @@ function unregisterDeviceWithRelay(input: {
237237
238function registerLiveActivityWithRelay(238function registerLiveActivityWithRelay(
239 body: RelayLiveActivityRegistrationRequest,239 body: RelayLiveActivityRegistrationRequest,
240): Effect.Effect<boolean, unknown, ManagedRelayClient> {240): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
241 return Effect.gen(function* () {241 return Effect.gen(function* () {
242 if (!readRelayConfig()) return false;242 if (!readRelayConfig()) return false;
243 const token = yield* relayToken;243 const token = yield* relayToken;
...@@ -246,7 +246,7 @@ function registerLiveActivityWithRelay(...@@ -246,7 +246,7 @@ function registerLiveActivityWithRelay(
246 return false;246 return false;
247 }247 }
248248
249 const client = yield* ManagedRelayClient;249 const client = yield* ManagedRelay.ManagedRelayClient;
250 yield* client.registerLiveActivity({250 yield* client.registerLiveActivity({
251 clerkToken: token,251 clerkToken: token,
252 payload: body,252 payload: body,
...@@ -274,7 +274,7 @@ function logRegistrationDebug(context: string, details?: unknown): void {...@@ -274,7 +274,7 @@ function logRegistrationDebug(context: string, details?: unknown): void {
274}274}
275275
276function runRegistrationInBackground(276function runRegistrationInBackground(
277 operation: Effect.Effect<unknown, unknown, ManagedRelayClient>,277 operation: Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
278 context: string,278 context: string,
279): void {279): void {
280 void (async () => {280 void (async () => {
...@@ -370,7 +370,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri...@@ -370,7 +370,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri
370function registerDevice(370function registerDevice(
371 input: DeviceRegistrationInput = {},371 input: DeviceRegistrationInput = {},
372 expectedGeneration = deviceRegistrationGeneration,372 expectedGeneration = deviceRegistrationGeneration,
373): Effect.Effect<void, unknown, ManagedRelayClient> {373): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
374 return Effect.gen(function* () {374 return Effect.gen(function* () {
375 if (!canRegisterRemoteLiveActivities()) {375 if (!canRegisterRemoteLiveActivities()) {
376 logRegistrationDebug("device registration skipped; platform does not support it");376 logRegistrationDebug("device registration skipped; platform does not support it");
...@@ -411,7 +411,7 @@ function registerDevice(...@@ -411,7 +411,7 @@ function registerDevice(
411411
412function registerDeviceForCurrentUser(412function registerDeviceForCurrentUser(
413 pushToStartToken?: string,413 pushToStartToken?: string,
414): Effect.Effect<void, unknown, ManagedRelayClient> {414): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
415 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);415 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
416}416}
417417
...@@ -485,7 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void {...@@ -485,7 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void {
485export function refreshAgentAwarenessRegistration(): Effect.Effect<485export function refreshAgentAwarenessRegistration(): Effect.Effect<
486 void,486 void,
487 never,487 never,
488 ManagedRelayClient488 ManagedRelay.ManagedRelayClient
489> {489> {
490 return registerDeviceForCurrentUser().pipe(490 return registerDeviceForCurrentUser().pipe(
491 Effect.catch((error) =>491 Effect.catch((error) =>
...@@ -515,7 +515,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {...@@ -515,7 +515,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
515515
516export function unregisterAgentAwarenessDeviceForCurrentUser(516export function unregisterAgentAwarenessDeviceForCurrentUser(
517 tokenProvider: () => Promise<string | null>,517 tokenProvider: () => Promise<string | null>,
518): Effect.Effect<void, never, ManagedRelayClient> {518): Effect.Effect<void, never, ManagedRelay.ManagedRelayClient> {
519 return Effect.gen(function* () {519 return Effect.gen(function* () {
520 const deviceId = yield* Effect.tryPromise({520 const deviceId = yield* Effect.tryPromise({
521 try: () => loadAgentAwarenessDeviceId(),521 try: () => loadAgentAwarenessDeviceId(),
...@@ -536,7 +536,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser(...@@ -536,7 +536,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser(
536536
537export function registerLiveActivityPushToken(input: {537export function registerLiveActivityPushToken(input: {
538 readonly activity: LiveActivity<AgentActivityProps>;538 readonly activity: LiveActivity<AgentActivityProps>;
539}): Effect.Effect<boolean, unknown, ManagedRelayClient> {539}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
540 return Effect.gen(function* () {540 return Effect.gen(function* () {
541 if (!canRegisterRemoteLiveActivities()) {541 if (!canRegisterRemoteLiveActivities()) {
542 return false;542 return false;
...@@ -588,7 +588,7 @@ export function registerLiveActivityPushToken(input: {...@@ -588,7 +588,7 @@ export function registerLiveActivityPushToken(input: {
588588
589function registerLiveActivityPushTokenValue(input: {589function registerLiveActivityPushTokenValue(input: {
590 readonly activityPushToken: string;590 readonly activityPushToken: string;
591}): Effect.Effect<boolean, unknown, ManagedRelayClient> {591}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
592 return Effect.gen(function* () {592 return Effect.gen(function* () {
593 const deviceId = yield* Effect.tryPromise({593 const deviceId = yield* Effect.tryPromise({
594 try: () => loadOrCreateAgentAwarenessDeviceId(),594 try: () => loadOrCreateAgentAwarenessDeviceId(),
...@@ -624,7 +624,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void {...@@ -624,7 +624,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void {
624export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<624export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
625 void,625 void,
626 never,626 never,
627 ManagedRelayClient627 ManagedRelay.ManagedRelayClient
628> {628> {
629 return Effect.gen(function* () {629 return Effect.gen(function* () {
630 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {630 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { ClerkProvider, useAuth } from "@clerk/expo";1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";2import { tokenCache } from "@clerk/expo/token-cache";
3import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";3import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
4import {4import {
5 reportAtomCommandResult,5 reportAtomCommandResult,
6 settleAsyncResult,6 settleAsyncResult,
...@@ -22,7 +22,7 @@ import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publi...@@ -22,7 +22,7 @@ import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publi
22function resetManagedRelayTokenCache() {22function resetManagedRelayTokenCache() {
23 return settleAsyncResult(() =>23 return settleAsyncResult(() =>
24 runtime.runPromiseExit(24 runtime.runPromiseExit(
25 ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),25 ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
26 ),26 ),
27 );27 );
28}28}
apps/mobile/src/features/cloud/linkEnvironment.test.ts+9-9
...@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import {7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8 managedRelayClientLayer,
9 ManagedRelayClient,
10 ManagedRelayDpopSigner,
11} from "@t3tools/client-runtime/relay";
12import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
13import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
1410
...@@ -62,8 +58,8 @@ const createProofMock = vi.fn(...@@ -62,8 +58,8 @@ const createProofMock = vi.fn(
62 Effect.succeed(`dpop:${input.method}:${input.url}`),58 Effect.succeed(`dpop:${input.method}:${input.url}`),
63);59);
64const testDpopSignerLayer = Layer.succeed(60const testDpopSignerLayer = Layer.succeed(
65 ManagedRelayDpopSigner,61 ManagedRelay.ManagedRelayDpopSigner,
66 ManagedRelayDpopSigner.of({62 ManagedRelay.ManagedRelayDpopSigner.of({
67 thumbprint: Effect.succeed("client-proof-key-thumbprint"),63 thumbprint: Effect.succeed("client-proof-key-thumbprint"),
68 createProof: (input) => createProofMock(input),64 createProof: (input) => createProofMock(input),
69 }),65 }),
...@@ -73,7 +69,7 @@ function cloudClientLayer() {...@@ -73,7 +69,7 @@ function cloudClientLayer() {
73 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));69 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
74 return Layer.mergeAll(70 return Layer.mergeAll(
75 httpClientLayer,71 httpClientLayer,
76 managedRelayClientLayer({72 ManagedRelay.layer({
77 relayUrl: "https://relay.example.test",73 relayUrl: "https://relay.example.test",
78 clientId: RelayMobileClientId,74 clientId: RelayMobileClientId,
79 }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),75 }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),
...@@ -81,7 +77,11 @@ function cloudClientLayer() {...@@ -81,7 +77,11 @@ function cloudClientLayer() {
81}77}
8278
83const withCloudServices = <A, E>(79const withCloudServices = <A, E>(
84 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,80 effect: Effect.Effect<
81 A,
82 E,
83 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
84 >,
85) => effect.pipe(Effect.provide(cloudClientLayer()));85) => effect.pipe(Effect.provide(cloudClientLayer()));
8686
87function validLinkProof() {87function validLinkProof() {
apps/mobile/src/features/cloud/linkEnvironment.ts+22-20
...@@ -25,11 +25,7 @@ import {...@@ -25,11 +25,7 @@ import {
25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
27import { findErrorTraceId } from "@t3tools/client-runtime/errors";27import { findErrorTraceId } from "@t3tools/client-runtime/errors";
28import {28import { ManagedRelay } from "@t3tools/client-runtime/relay";
29 ManagedRelayClient,
30 type ManagedRelayClientError,
31 ManagedRelayDpopSigner,
32} from "@t3tools/client-runtime/relay";
33import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";29import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3430
35import { authClientMetadata } from "../../lib/authClientMetadata";31import { authClientMetadata } from "../../lib/authClientMetadata";
...@@ -156,13 +152,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {...@@ -156,13 +152,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
156}152}
157153
158function decodedRelayClientError(message: string) {154function decodedRelayClientError(message: string) {
159 return (cause: ManagedRelayClientError) => {155 return (cause: ManagedRelay.ManagedRelayClientError) => {
160 const relayError = cause.relayError;156 const relayError =
157 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
158 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
161 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;159 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
162 return new CloudEnvironmentLinkError({160 return new CloudEnvironmentLinkError({
163 message: detail ? `${message}: ${detail}` : message,161 message: detail ? `${message}: ${detail}` : message,
164 cause,162 cause,
165 ...(cause.traceId ? { traceId: cause.traceId } : {}),163 ...(traceId ? { traceId } : {}),
166 });164 });
167 };165 };
168}166}
...@@ -261,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: {...@@ -261,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: {
261export function linkEnvironmentToCloud(input: {259export function linkEnvironmentToCloud(input: {
262 readonly connection: SavedRemoteConnection;260 readonly connection: SavedRemoteConnection;
263 readonly clerkToken: string;261 readonly clerkToken: string;
264}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {262}): Effect.Effect<
263 void,
264 CloudEnvironmentLinkError,
265 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
266> {
265 return Effect.gen(function* () {267 return Effect.gen(function* () {
266 if (!input.connection.bearerToken) {268 if (!input.connection.bearerToken) {
267 return yield* new CloudEnvironmentLinkError({269 return yield* new CloudEnvironmentLinkError({
...@@ -270,7 +272,7 @@ export function linkEnvironmentToCloud(input: {...@@ -270,7 +272,7 @@ export function linkEnvironmentToCloud(input: {
270 }272 }
271 const localBearerToken = input.connection.bearerToken;273 const localBearerToken = input.connection.bearerToken;
272 const relayUrl = yield* requireRelayUrl();274 const relayUrl = yield* requireRelayUrl();
273 const relayClient = yield* ManagedRelayClient;275 const relayClient = yield* ManagedRelay.ManagedRelayClient;
274 const deviceId = yield* Effect.tryPromise({276 const deviceId = yield* Effect.tryPromise({
275 try: () => loadOrCreateAgentAwarenessDeviceId(),277 try: () => loadOrCreateAgentAwarenessDeviceId(),
276 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),278 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
...@@ -353,11 +355,11 @@ export function listCloudEnvironments(input: {...@@ -353,11 +355,11 @@ export function listCloudEnvironments(input: {
353}): Effect.Effect<355}): Effect.Effect<
354 ReadonlyArray<RelayClientEnvironmentRecord>,356 ReadonlyArray<RelayClientEnvironmentRecord>,
355 CloudEnvironmentLinkError,357 CloudEnvironmentLinkError,
356 ManagedRelayClient358 ManagedRelay.ManagedRelayClient
357> {359> {
358 return Effect.gen(function* () {360 return Effect.gen(function* () {
359 const relayUrl = yield* requireRelayUrl();361 const relayUrl = yield* requireRelayUrl();
360 const relayClient = yield* ManagedRelayClient;362 const relayClient = yield* ManagedRelay.ManagedRelayClient;
361363
362 return yield* relayClient364 return yield* relayClient
363 .listEnvironments({365 .listEnvironments({
...@@ -374,11 +376,11 @@ export function getCloudEnvironmentStatus(input: {...@@ -374,11 +376,11 @@ export function getCloudEnvironmentStatus(input: {
374}): Effect.Effect<376}): Effect.Effect<
375 RelayEnvironmentStatusResponseType,377 RelayEnvironmentStatusResponseType,
376 CloudEnvironmentLinkError,378 CloudEnvironmentLinkError,
377 ManagedRelayClient379 ManagedRelay.ManagedRelayClient
378> {380> {
379 return Effect.gen(function* () {381 return Effect.gen(function* () {
380 const relayUrl = yield* requireRelayUrl();382 const relayUrl = yield* requireRelayUrl();
381 const relayClient = yield* ManagedRelayClient;383 const relayClient = yield* ManagedRelay.ManagedRelayClient;
382 const status = yield* relayClient384 const status = yield* relayClient
383 .getEnvironmentStatus({385 .getEnvironmentStatus({
384 clerkToken: input.clerkToken,386 clerkToken: input.clerkToken,
...@@ -413,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: {...@@ -413,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: {
413}): Effect.Effect<415}): Effect.Effect<
414 ReadonlyArray<CloudEnvironmentRecordWithStatus>,416 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
415 CloudEnvironmentLinkError,417 CloudEnvironmentLinkError,
416 ManagedRelayClient418 ManagedRelay.ManagedRelayClient
417> {419> {
418 return Effect.forEach(420 return Effect.forEach(
419 input.environments,421 input.environments,
...@@ -445,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: {...@@ -445,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: {
445}): Effect.Effect<447}): Effect.Effect<
446 ReadonlyArray<CloudEnvironmentRecordWithStatus>,448 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
447 CloudEnvironmentLinkError,449 CloudEnvironmentLinkError,
448 ManagedRelayClient450 ManagedRelay.ManagedRelayClient
449> {451> {
450 return Effect.gen(function* () {452 return Effect.gen(function* () {
451 const environments = yield* listCloudEnvironments(input);453 const environments = yield* listCloudEnvironments(input);
...@@ -473,7 +475,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag...@@ -473,7 +475,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
473 }) {475 }) {
474 yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });476 yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });
475 const relayUrl = yield* requireRelayUrl();477 const relayUrl = yield* requireRelayUrl();
476 const relayClient = yield* ManagedRelayClient;478 const relayClient = yield* ManagedRelay.ManagedRelayClient;
477479
478 const deviceId = yield* loadAgentAwarenessDeviceId();480 const deviceId = yield* loadAgentAwarenessDeviceId();
479 const connect = yield* relayClient481 const connect = yield* relayClient
...@@ -514,7 +516,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag...@@ -514,7 +516,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
514 message: "Connected endpoint descriptor does not match the selected environment.",516 message: "Connected endpoint descriptor does not match the selected environment.",
515 });517 });
516 }518 }
517 const signer = yield* ManagedRelayDpopSigner;519 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
518 const bootstrapDpop = yield* signer520 const bootstrapDpop = yield* signer
519 .createProof({521 .createProof({
520 method: "POST",522 method: "POST",
...@@ -555,7 +557,7 @@ export function connectCloudEnvironment(input: {...@@ -555,7 +557,7 @@ export function connectCloudEnvironment(input: {
555}): Effect.Effect<557}): Effect.Effect<
556 SavedRemoteConnection,558 SavedRemoteConnection,
557 CloudEnvironmentLinkError,559 CloudEnvironmentLinkError,
558 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner560 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
559> {561> {
560 return connectRelayManagedEnvironment({562 return connectRelayManagedEnvironment({
561 clerkToken: input.clerkToken,563 clerkToken: input.clerkToken,
...@@ -570,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: {...@@ -570,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: {
570}): Effect.Effect<572}): Effect.Effect<
571 SavedRemoteConnection,573 SavedRemoteConnection,
572 CloudEnvironmentLinkError,574 CloudEnvironmentLinkError,
573 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner575 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
574> {576> {
575 return connectRelayManagedEnvironment({577 return connectRelayManagedEnvironment({
576 clerkToken: input.clerkToken,578 clerkToken: input.clerkToken,
apps/mobile/src/features/cloud/managedRelayLayer.ts+35-19
...@@ -1,8 +1,4 @@...@@ -1,8 +1,4 @@
1import {1import { ManagedRelay } from "@t3tools/client-runtime/relay";
2 managedRelayClientLayer as makeManagedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime/relay";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";2import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -12,34 +8,54 @@ import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";...@@ -12,34 +8,54 @@ import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
12import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";8import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
139
14const relayDpopSignerLayer = Layer.effect(10const relayDpopSignerLayer = Layer.effect(
15 ManagedRelayDpopSigner,11 ManagedRelay.ManagedRelayDpopSigner,
16 Effect.gen(function* () {12 Effect.gen(function* () {
17 const crypto = yield* Crypto.Crypto;13 const crypto = yield* Crypto.Crypto;
18 const loadProofKey = yield* Effect.cached(14 const loadProofKey = yield* Effect.cached(
19 loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)),15 loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)),
20 );16 );
21 return ManagedRelayDpopSigner.of({17 return ManagedRelay.ManagedRelayDpopSigner.of({
22 thumbprint: loadProofKey.pipe(18 thumbprint: loadProofKey.pipe(
23 Effect.map((proofKey) => proofKey.thumbprint),19 Effect.map((proofKey) => proofKey.thumbprint),
24 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),20 Effect.mapError(
21 (error) =>
22 new ManagedRelay.ManagedRelayDpopKeyLoadError({
23 keyStore: "expo-secure-store",
24 cause: error,
25 }),
26 ),
25 Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"),27 Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"),
26 ),28 ),
27 createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(29 createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) {
28 function* (input) {30 const proofKey = yield* loadProofKey.pipe(
29 const proofKey = yield* loadProofKey;31 Effect.mapError(
30 return yield* createDpopProof({ ...input, proofKey }).pipe(32 (error) =>
31 Effect.provideService(Crypto.Crypto, crypto),33 new ManagedRelay.ManagedRelayDpopProofCreationError({
32 Effect.map((proof) => proof.proof),34 method: input.method,
33 );35 url: input.url,
34 },36 cause: error,
35 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),37 }),
36 ),38 ),
39 );
40 return yield* createDpopProof({ ...input, proofKey }).pipe(
41 Effect.provideService(Crypto.Crypto, crypto),
42 Effect.map((proof) => proof.proof),
43 Effect.mapError(
44 (error) =>
45 new ManagedRelay.ManagedRelayDpopProofCreationError({
46 method: input.method,
47 url: input.url,
48 cause: error,
49 }),
50 ),
51 );
52 }),
37 });53 });
38 }),54 }),
39);55);
4056
41export const managedRelayClientLayer = (relayUrl: string) =>57export const managedRelayClientLayer = (relayUrl: string) =>
42 makeManagedRelayClientLayer({58 ManagedRelay.layer({
43 relayUrl,59 relayUrl,
44 clientId: RelayMobileClientId,60 clientId: RelayMobileClientId,
45 accessTokenStore: managedRelayAccessTokenStore,61 accessTokenStore: managedRelayAccessTokenStore,
apps/mobile/src/features/cloud/managedRelayTokenStore.ts+6-7
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1import {1import { ManagedRelay } from "@t3tools/client-runtime/relay";
2 type ManagedRelayAccessTokenCacheEntry,
3 type ManagedRelayAccessTokenStore,
4} from "@t3tools/client-runtime/relay";
5import * as Data from "effect/Data";2import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
7import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
...@@ -60,7 +57,7 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({...@@ -60,7 +57,7 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({
60}).pipe(57}).pipe(
61 Effect.flatMap((encoded) =>58 Effect.flatMap((encoded) =>
62 encoded === null59 encoded === null
63 ? Effect.succeed<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>([])60 ? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])
64 : decodeManagedRelayAccessTokenCache(encoded).pipe(61 : decodeManagedRelayAccessTokenCache(encoded).pipe(
65 Effect.map((cache) => cache.entries),62 Effect.map((cache) => cache.entries),
66 Effect.mapError(storeError("Persisted relay access tokens are invalid.")),63 Effect.mapError(storeError("Persisted relay access tokens are invalid.")),
...@@ -68,7 +65,9 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({...@@ -68,7 +65,9 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({
68 ),65 ),
69);66);
7067
71const saveManagedRelayAccessTokens = (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) =>68const saveManagedRelayAccessTokens = (
69 entries: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>,
70) =>
72 encodeManagedRelayAccessTokenCache({71 encodeManagedRelayAccessTokenCache({
73 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,72 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
74 entries,73 entries,
...@@ -87,7 +86,7 @@ const clearManagedRelayAccessTokens = Effect.tryPromise({...@@ -87,7 +86,7 @@ const clearManagedRelayAccessTokens = Effect.tryPromise({
87 catch: storeError("Could not clear persisted relay access tokens."),86 catch: storeError("Could not clear persisted relay access tokens."),
88});87});
8988
90export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = {89export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
91 load: loadManagedRelayAccessTokens.pipe(90 load: loadManagedRelayAccessTokens.pipe(
92 Effect.tapError(logStoreFailure("load")),91 Effect.tapError(logStoreFailure("load")),
93 Effect.orElseSucceed(() => []),92 Effect.orElseSucceed(() => []),
apps/mobile/src/lib/runtime.ts+19-3
...@@ -15,7 +15,17 @@ function configuredRelayUrl(): string {...@@ -15,7 +15,17 @@ function configuredRelayUrl(): string {
1515
16const httpClientLayer = remoteHttpClientLayer(fetch);16const httpClientLayer = remoteHttpClientLayer(fetch);
1717
18export const runtimeLayer = Layer.merge(18type RuntimeLayerSource =
19 | ReturnType<typeof managedRelayClientLayer>
20 | typeof Socket.layerWebSocketConstructorGlobal
21 | typeof cryptoLayer
22 | typeof httpClientLayer
23 | typeof tracingLayer;
24
25export const runtimeLayer: Layer.Layer<
26 Layer.Success<RuntimeLayerSource>,
27 Layer.Error<RuntimeLayerSource>
28> = Layer.merge(
19 managedRelayClientLayer(configuredRelayUrl()),29 managedRelayClientLayer(configuredRelayUrl()),
20 Socket.layerWebSocketConstructorGlobal,30 Socket.layerWebSocketConstructorGlobal,
21).pipe(31).pipe(
...@@ -24,6 +34,12 @@ export const runtimeLayer = Layer.merge(...@@ -24,6 +34,12 @@ export const runtimeLayer = Layer.merge(
24 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),34 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),
25);35);
2636
27export const runtime = ManagedRuntime.make(runtimeLayer);37export const runtime: ManagedRuntime.ManagedRuntime<
38 Layer.Success<typeof runtimeLayer>,
39 Layer.Error<typeof runtimeLayer>
40> = ManagedRuntime.make(runtimeLayer);
2841
29export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);42export const runtimeContextLayer: Layer.Layer<
43 Layer.Success<typeof runtimeLayer>,
44 Layer.Error<typeof runtimeLayer>
45> = Layer.effectContext(runtime.contextEffect);
apps/mobile/src/state/relay.ts+1-1
...@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st...@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st
22
3import { connectionAtomRuntime } from "../connection/runtime";3import { connectionAtomRuntime } from "../connection/runtime";
44
5export const relayEnvironmentDiscovery =5export const relayEnvironmentDiscovery: ReturnType<typeof createRelayEnvironmentDiscoveryAtoms> =
6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
apps/web/src/cloud/linkEnvironment.test.ts+9-9
...@@ -21,11 +21,7 @@ import {...@@ -21,11 +21,7 @@ import {
21} from "@t3tools/client-runtime/connection";21} from "@t3tools/client-runtime/connection";
22import { type RpcSession } from "@t3tools/client-runtime/rpc";22import { type RpcSession } from "@t3tools/client-runtime/rpc";
23import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";23import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
24import {24import { ManagedRelay } from "@t3tools/client-runtime/relay";
25 managedRelayClientLayer,
26 ManagedRelayClient,
27 ManagedRelayDpopSigner,
28} from "@t3tools/client-runtime/relay";
29import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";25import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
30import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";26import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";
3127
...@@ -60,8 +56,8 @@ vi.mock("./relayClientInstallDialog", () => ({...@@ -60,8 +56,8 @@ vi.mock("./relayClientInstallDialog", () => ({
6056
61const createProof = vi.fn(() => Effect.succeed("dpop-proof"));57const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
62const dpopSignerLayer = Layer.succeed(58const dpopSignerLayer = Layer.succeed(
63 ManagedRelayDpopSigner,59 ManagedRelay.ManagedRelayDpopSigner,
64 ManagedRelayDpopSigner.of({60 ManagedRelay.ManagedRelayDpopSigner.of({
65 thumbprint: Effect.succeed("thumbprint"),61 thumbprint: Effect.succeed("thumbprint"),
66 createProof,62 createProof,
67 }),63 }),
...@@ -71,7 +67,7 @@ function relayLayer() {...@@ -71,7 +67,7 @@ function relayLayer() {
71 const http = remoteHttpClientLayer(globalThis.fetch);67 const http = remoteHttpClientLayer(globalThis.fetch);
72 return Layer.mergeAll(68 return Layer.mergeAll(
73 http,69 http,
74 managedRelayClientLayer({70 ManagedRelay.layer({
75 relayUrl: "https://relay.example.test",71 relayUrl: "https://relay.example.test",
76 clientId: RelayWebClientId,72 clientId: RelayWebClientId,
77 }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)),73 }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)),
...@@ -129,7 +125,11 @@ function services(options?: Parameters<typeof registryLayer>[0]) {...@@ -129,7 +125,11 @@ function services(options?: Parameters<typeof registryLayer>[0]) {
129}125}
130126
131function withServices<A, E>(127function withServices<A, E>(
132 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | EnvironmentRegistry>,128 effect: Effect.Effect<
129 A,
130 E,
131 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry
132 >,
133 options?: Parameters<typeof registryLayer>[0],133 options?: Parameters<typeof registryLayer>[0],
134) {134) {
135 return effect.pipe(Effect.provide(services(options)));135 return effect.pipe(Effect.provide(services(options)));
apps/web/src/cloud/linkEnvironment.ts+18-12
...@@ -25,7 +25,7 @@ import {...@@ -25,7 +25,7 @@ import {
25import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";25import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
26import { request, runStream } from "@t3tools/client-runtime/rpc";26import { request, runStream } from "@t3tools/client-runtime/rpc";
27import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";27import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
28import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay";28import { ManagedRelay } from "@t3tools/client-runtime/relay";
2929
30import {30import {
31 readPrimaryEnvironmentDescriptor,31 readPrimaryEnvironmentDescriptor,
...@@ -164,13 +164,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {...@@ -164,13 +164,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
164}164}
165165
166function decodedRelayClientError(message: string) {166function decodedRelayClientError(message: string) {
167 return (cause: ManagedRelayClientError) => {167 return (cause: ManagedRelay.ManagedRelayClientError) => {
168 const relayError = cause.relayError;168 const relayError =
169 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
170 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
169 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;171 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
170 return new CloudEnvironmentLinkError({172 return new CloudEnvironmentLinkError({
171 message: detail ? `${message}: ${detail}` : message,173 message: detail ? `${message}: ${detail}` : message,
172 cause,174 cause,
173 ...(cause.traceId ? { traceId: cause.traceId } : {}),175 ...(traceId ? { traceId } : {}),
174 });176 });
175 };177 };
176}178}
...@@ -268,7 +270,7 @@ export function listManagedCloudEnvironments(input: {...@@ -268,7 +270,7 @@ export function listManagedCloudEnvironments(input: {
268}): Effect.Effect<270}): Effect.Effect<
269 ReadonlyArray<RelayClientEnvironmentRecord>,271 ReadonlyArray<RelayClientEnvironmentRecord>,
270 CloudEnvironmentLinkError,272 CloudEnvironmentLinkError,
271 ManagedRelayClient273 ManagedRelay.ManagedRelayClient
272> {274> {
273 return Effect.gen(function* () {275 return Effect.gen(function* () {
274 const configuredRelayUrl = relayUrl();276 const configuredRelayUrl = relayUrl();
...@@ -277,7 +279,7 @@ export function listManagedCloudEnvironments(input: {...@@ -277,7 +279,7 @@ export function listManagedCloudEnvironments(input: {
277 message: "T3CODE_RELAY_URL is not configured.",279 message: "T3CODE_RELAY_URL is not configured.",
278 });280 });
279 }281 }
280 const relayClient = yield* ManagedRelayClient;282 const relayClient = yield* ManagedRelay.ManagedRelayClient;
281 return yield* relayClient283 return yield* relayClient
282 .listEnvironments({284 .listEnvironments({
283 clerkToken: input.clerkToken,285 clerkToken: input.clerkToken,
...@@ -299,7 +301,7 @@ export function listCloudDevices(input: {...@@ -299,7 +301,7 @@ export function listCloudDevices(input: {
299}): Effect.Effect<301}): Effect.Effect<
300 ReadonlyArray<RelayClientDeviceRecord>,302 ReadonlyArray<RelayClientDeviceRecord>,
301 CloudEnvironmentLinkError,303 CloudEnvironmentLinkError,
302 ManagedRelayClient304 ManagedRelay.ManagedRelayClient
303> {305> {
304 return Effect.gen(function* () {306 return Effect.gen(function* () {
305 if (!relayUrl()) {307 if (!relayUrl()) {
...@@ -307,7 +309,7 @@ export function listCloudDevices(input: {...@@ -307,7 +309,7 @@ export function listCloudDevices(input: {
307 message: "T3CODE_RELAY_URL is not configured.",309 message: "T3CODE_RELAY_URL is not configured.",
308 });310 });
309 }311 }
310 const relayClient = yield* ManagedRelayClient;312 const relayClient = yield* ManagedRelay.ManagedRelayClient;
311 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(313 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(
312 Effect.mapError(314 Effect.mapError(
313 (cause) =>315 (cause) =>
...@@ -351,7 +353,11 @@ export function updatePrimaryCloudPreferences(input: {...@@ -351,7 +353,11 @@ export function updatePrimaryCloudPreferences(input: {
351export function unlinkPrimaryEnvironmentFromCloud(input: {353export function unlinkPrimaryEnvironmentFromCloud(input: {
352 readonly target: CloudLinkTarget;354 readonly target: CloudLinkTarget;
353 readonly clerkToken: string | null;355 readonly clerkToken: string | null;
354}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {356}): Effect.Effect<
357 void,
358 CloudEnvironmentLinkError,
359 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
360> {
355 return Effect.gen(function* () {361 return Effect.gen(function* () {
356 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);362 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
357 yield* client.connect363 yield* client.connect
...@@ -360,7 +366,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {...@@ -360,7 +366,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
360366
361 const configuredRelayUrl = relayUrl();367 const configuredRelayUrl = relayUrl();
362 if (configuredRelayUrl && input.clerkToken) {368 if (configuredRelayUrl && input.clerkToken) {
363 const relayClient = yield* ManagedRelayClient;369 const relayClient = yield* ManagedRelay.ManagedRelayClient;
364 yield* relayClient370 yield* relayClient
365 .unlinkEnvironment({371 .unlinkEnvironment({
366 clerkToken: input.clerkToken,372 clerkToken: input.clerkToken,
...@@ -383,7 +389,7 @@ export function linkPrimaryEnvironmentToCloud(input: {...@@ -383,7 +389,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
383}): Effect.Effect<389}): Effect.Effect<
384 void,390 void,
385 CloudEnvironmentLinkError,391 CloudEnvironmentLinkError,
386 EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient392 EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
387> {393> {
388 return Effect.gen(function* () {394 return Effect.gen(function* () {
389 const configuredRelayUrl = relayUrl();395 const configuredRelayUrl = relayUrl();
...@@ -392,7 +398,7 @@ export function linkPrimaryEnvironmentToCloud(input: {...@@ -392,7 +398,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
392 message: "T3CODE_RELAY_URL is not configured.",398 message: "T3CODE_RELAY_URL is not configured.",
393 });399 });
394 }400 }
395 const relayClient = yield* ManagedRelayClient;401 const relayClient = yield* ManagedRelay.ManagedRelayClient;
396 const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);402 const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
397 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));403 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
398404
apps/web/src/cloud/managedAuth.tsx+4-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";2import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
3import {3import {
4 reportAtomCommandResult,4 reportAtomCommandResult,
5 settleAsyncResult,5 settleAsyncResult,
...@@ -64,7 +64,9 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac...@@ -64,7 +64,9 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac
64 removeRelayEnvironments(),64 removeRelayEnvironments(),
65 settleAsyncResult(() =>65 settleAsyncResult(() =>
66 runtime.runPromiseExit(66 runtime.runPromiseExit(
67 ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),67 ManagedRelay.ManagedRelayClient.pipe(
68 Effect.flatMap((client) => client.resetTokenCache),
69 ),
68 ),70 ),
69 ),71 ),
70 ]);72 ]);
apps/web/src/cloud/managedRelayLayer.ts+35-19
...@@ -1,8 +1,4 @@...@@ -1,8 +1,4 @@
1import {1import { ManagedRelay } from "@t3tools/client-runtime/relay";
2 managedRelayClientLayer as makeManagedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime/relay";
6import { RelayWebClientId } from "@t3tools/contracts/relay";2import { RelayWebClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -18,7 +14,7 @@ import {...@@ -18,7 +14,7 @@ import {
18} from "./dpop";14} from "./dpop";
1915
20export const relayDpopSignerLayer = Layer.effect(16export const relayDpopSignerLayer = Layer.effect(
21 ManagedRelayDpopSigner,17 ManagedRelay.ManagedRelayDpopSigner,
22 Effect.gen(function* () {18 Effect.gen(function* () {
23 const crypto = yield* Crypto.Crypto;19 const crypto = yield* Crypto.Crypto;
24 const keyLoadSemaphore = yield* Semaphore.make(1);20 const keyLoadSemaphore = yield* Semaphore.make(1);
...@@ -40,27 +36,47 @@ export const relayDpopSignerLayer = Layer.effect(...@@ -40,27 +36,47 @@ export const relayDpopSignerLayer = Layer.effect(
40 }),36 }),
41 );37 );
4238
43 return ManagedRelayDpopSigner.of({39 return ManagedRelay.ManagedRelayDpopSigner.of({
44 thumbprint: loadOrCreateBrowserDpopKey.pipe(40 thumbprint: loadOrCreateBrowserDpopKey.pipe(
45 Effect.map((proofKey) => proofKey.thumbprint),41 Effect.map((proofKey) => proofKey.thumbprint),
46 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),42 Effect.mapError(
43 (error) =>
44 new ManagedRelay.ManagedRelayDpopKeyLoadError({
45 keyStore: "indexed-db",
46 cause: error,
47 }),
48 ),
47 Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"),49 Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"),
48 ),50 ),
49 createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(51 createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) {
50 function* (input) {52 const proofKey = yield* loadOrCreateBrowserDpopKey.pipe(
51 const proofKey = yield* loadOrCreateBrowserDpopKey;53 Effect.mapError(
52 return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(54 (error) =>
53 Effect.provideService(Crypto.Crypto, crypto),55 new ManagedRelay.ManagedRelayDpopProofCreationError({
54 Effect.map((proof) => proof.proof),56 method: input.method,
55 );57 url: input.url,
56 },58 cause: error,
57 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),59 }),
58 ),60 ),
61 );
62 return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(
63 Effect.provideService(Crypto.Crypto, crypto),
64 Effect.map((proof) => proof.proof),
65 Effect.mapError(
66 (error) =>
67 new ManagedRelay.ManagedRelayDpopProofCreationError({
68 method: input.method,
69 url: input.url,
70 cause: error,
71 }),
72 ),
73 );
74 }),
59 });75 });
60 }),76 }),
61);77);
6278
63export const managedRelayClientLayer = (relayUrl: string) =>79export const managedRelayClientLayer = (relayUrl: string) =>
64 makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe(80 ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe(
65 Layer.provideMerge(relayDpopSignerLayer),81 Layer.provideMerge(relayDpopSignerLayer),
66 );82 );
apps/web/src/cloud/managedRelayState.ts+5-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 createManagedRelayQueryManager,3 createManagedRelayQueryManager,
4 ManagedRelayClient,4 ManagedRelay,
5 managedRelaySessionAtom,5 managedRelaySessionAtom,
6 readManagedRelaySnapshotState,6 readManagedRelaySnapshotState,
7} from "@t3tools/client-runtime/relay";7} from "@t3tools/client-runtime/relay";
...@@ -20,8 +20,10 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -20,8 +20,10 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
2020
21const managedRelayAtomRuntime = Atom.runtime(21const managedRelayAtomRuntime = Atom.runtime(
22 Layer.effect(22 Layer.effect(
23 ManagedRelayClient,23 ManagedRelay.ManagedRelayClient,
24 runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))),24 runtime.contextEffect.pipe(
25 Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)),
26 ),
25 ),27 ),
26);28);
2729
apps/web/src/lib/runtime.ts+19-3
...@@ -24,6 +24,13 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(...@@ -24,6 +24,13 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(
24 client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web",24 client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web",
25}).pipe(Layer.provide(httpClientLayer));25}).pipe(Layer.provide(httpClientLayer));
2626
27type RuntimeLayerSource =
28 | typeof httpClientLayer
29 | typeof browserCryptoLayer
30 | typeof Socket.layerWebSocketConstructorGlobal
31 | typeof relayTracingLayer
32 | ReturnType<typeof managedRelayClientLayer>;
33
27export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer);34export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer);
2835
29const primaryHttpRuntime = ManagedRuntime.make(36const primaryHttpRuntime = ManagedRuntime.make(
...@@ -47,7 +54,10 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner)...@@ -47,7 +54,10 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner)
47 primaryHttpRunner = runner ?? livePrimaryHttpRunner;54 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
48}55}
4956
50export const runtimeLayer = Layer.mergeAll(57export const runtimeLayer: Layer.Layer<
58 Layer.Success<RuntimeLayerSource>,
59 Layer.Error<RuntimeLayerSource>
60> = Layer.mergeAll(
51 httpClientLayer,61 httpClientLayer,
52 browserCryptoLayer,62 browserCryptoLayer,
53 Socket.layerWebSocketConstructorGlobal,63 Socket.layerWebSocketConstructorGlobal,
...@@ -57,6 +67,12 @@ export const runtimeLayer = Layer.mergeAll(...@@ -57,6 +67,12 @@ export const runtimeLayer = Layer.mergeAll(
57 ),67 ),
58);68);
5969
60export const runtime = ManagedRuntime.make(runtimeLayer);70export const runtime: ManagedRuntime.ManagedRuntime<
71 Layer.Success<typeof runtimeLayer>,
72 Layer.Error<typeof runtimeLayer>
73> = ManagedRuntime.make(runtimeLayer);
6174
62export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);75export const runtimeContextLayer: Layer.Layer<
76 Layer.Success<typeof runtimeLayer>,
77 Layer.Error<typeof runtimeLayer>
78> = Layer.effectContext(runtime.contextEffect);
apps/web/src/state/environments.ts+2-1
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 connectionCatalogDisplayUrl,3 connectionCatalogDisplayUrl,
4 type EnvironmentPresentation as BaseEnvironmentPresentation,4 type EnvironmentPresentation as BaseEnvironmentPresentation,
5} from "@t3tools/client-runtime/connection";5} from "@t3tools/client-runtime/connection";
6import { Discovery } from "@t3tools/client-runtime/relay";
6import type { EnvironmentId } from "@t3tools/contracts";7import type { EnvironmentId } from "@t3tools/contracts";
7import * as Option from "effect/Option";8import * as Option from "effect/Option";
8import { useMemo } from "react";9import { useMemo } from "react";
...@@ -81,7 +82,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null):...@@ -81,7 +82,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null):
81 return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null;82 return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null;
82}83}
8384
84export function useRelayEnvironmentDiscovery() {85export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState {
85 return useAtomValue(relayEnvironmentDiscovery.stateValueAtom);86 return useAtomValue(relayEnvironmentDiscovery.stateValueAtom);
86}87}
8788
apps/web/src/state/relay.ts+1-1
...@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st...@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st
22
3import { connectionAtomRuntime } from "../connection/runtime";3import { connectionAtomRuntime } from "../connection/runtime";
44
5export const relayEnvironmentDiscovery =5export const relayEnvironmentDiscovery: ReturnType<typeof createRelayEnvironmentDiscoveryAtoms> =
6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);6 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
packages/client-runtime/src/authorization/layer.test.ts-1
...@@ -118,7 +118,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -118,7 +118,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
118 createProof: (proofInput) =>118 createProof: (proofInput) =>
119 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(119 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(
120 Effect.as(`proof:${proofInput.url}`),120 Effect.as(`proof:${proofInput.url}`),
121 Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })),
122 ),121 ),
123 });122 });
124 const layer = RemoteEnvironmentAuthorization.layer.pipe(123 const layer = RemoteEnvironmentAuthorization.layer.pipe(
packages/client-runtime/src/connection/errors.ts+32-14
...@@ -74,21 +74,39 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError...@@ -74,21 +74,39 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
74}74}
7575
76export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError {76export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError {
77 if (error.relayError) {77 switch (error._tag) {
78 return relayProtectedError(error.relayError);78 case "ManagedRelayRequestFailedError":
79 }79 if (error.relayError) {
80 if (error.cause?._tag === "ManagedRelayRequestTimeoutError") {80 return relayProtectedError(error.relayError);
81 return new ConnectionTransientError({81 }
82 reason: "timeout",82 return new ConnectionTransientError({
83 detail: error.message,83 reason: "relay-unavailable",
84 ...(error.traceId ? { traceId: error.traceId } : {}),84 detail: error.message,
85 });85 ...(error.traceId ? { traceId: error.traceId } : {}),
86 });
87 case "ManagedRelayRequestTimeoutError":
88 return new ConnectionTransientError({
89 reason: "timeout",
90 detail: error.message,
91 });
92 case "ManagedRelayUrlInvalidError":
93 return new ConnectionBlockedError({
94 reason: "configuration",
95 detail: error.message,
96 });
97 case "ManagedRelayAccessTokenScopesUnexpectedError":
98 return new ConnectionBlockedError({
99 reason: "permission",
100 detail: error.message,
101 });
102 case "ManagedRelayDpopKeyLoadError":
103 case "ManagedRelayTokenProofCreationError":
104 case "ManagedRelayRequestProofCreationError":
105 return new ConnectionBlockedError({
106 reason: "authentication",
107 detail: error.message,
108 });
86 }109 }
87 return new ConnectionTransientError({
88 reason: "relay-unavailable",
89 detail: error.message,
90 ...(error.traceId ? { traceId: error.traceId } : {}),
91 });
92}110}
93111
94export function mapRemoteEnvironmentError(112export function mapRemoteEnvironmentError(
packages/client-runtime/src/connection/resolver.test.ts+3-5
...@@ -446,11 +446,9 @@ describe("ConnectionResolver", () => {...@@ -446,11 +446,9 @@ describe("ConnectionResolver", () => {
446 const brokerLayer = yield* makeDependencies({446 const brokerLayer = yield* makeDependencies({
447 connectEnvironment: () =>447 connectEnvironment: () =>
448 Effect.fail(448 Effect.fail(
449 new ManagedRelay.ManagedRelayClientError({449 new ManagedRelay.ManagedRelayRequestTimeoutError({
450 message: "Relay timed out.",450 activity: "Relay environment connection",
451 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({451 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
452 message: "Relay timed out.",
453 }),
454 }),452 }),
455 ),453 ),
456 });454 });
packages/client-runtime/src/relay/discovery.test.ts+6-7
...@@ -258,11 +258,9 @@ describe("RelayEnvironmentDiscovery", () => {...@@ -258,11 +258,9 @@ describe("RelayEnvironmentDiscovery", () => {
258 relayUrl: "https://relay.example.test",258 relayUrl: "https://relay.example.test",
259 listEnvironments: () =>259 listEnvironments: () =>
260 Effect.fail(260 Effect.fail(
261 new ManagedRelay.ManagedRelayClientError({261 new ManagedRelay.ManagedRelayRequestTimeoutError({
262 message: "Relay environment listing timed out.",262 activity: "Relay environment listing",
263 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({263 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
264 message: "Relay environment listing timed out.",
265 }),
266 }),264 }),
267 ),265 ),
268 getEnvironmentStatus: () => Effect.die("unused"),266 getEnvironmentStatus: () => Effect.die("unused"),
...@@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => {...@@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => {
327325
328 yield* Ref.set(326 yield* Ref.set(
329 harness.listFailure,327 harness.listFailure,
330 new ManagedRelay.ManagedRelayClientError({328 new ManagedRelay.ManagedRelayRequestFailedError({
331 message: "Relay environment listing failed.",329 action: "list relay-managed environments",
330 cause: new Error("Relay request failed."),
332 }),331 }),
333 );332 );
334 yield* discovery.refresh;333 yield* discovery.refresh;
packages/client-runtime/src/relay/index.ts+2-2
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1export * from "./discovery.ts";1export * as Discovery from "./discovery.ts";
2export * from "./managedRelay.ts";2export * as ManagedRelay from "./managedRelay.ts";
3export * from "./managedRelayState.ts";3export * from "./managedRelayState.ts";
packages/client-runtime/src/relay/managedRelay.test.ts+30-34
...@@ -8,31 +8,24 @@ import * as Layer from "effect/Layer";...@@ -8,31 +8,24 @@ import * as Layer from "effect/Layer";
8import * as Tracer from "effect/Tracer";8import * as Tracer from "effect/Tracer";
9import * as TestClock from "effect/testing/TestClock";9import * as TestClock from "effect/testing/TestClock";
1010
11import {11import * as ManagedRelay from "./managedRelay.ts";
12 MANAGED_RELAY_REQUEST_TIMEOUT_MS,
13 ManagedRelayClient,
14 ManagedRelayDpopSigner,
15 managedRelayClientLayer,
16 type ManagedRelayAccessTokenCacheEntry,
17 type ManagedRelayAccessTokenStore,
18 type ManagedRelayDpopProofInput,
19} from "./managedRelay.ts";
20import { remoteHttpClientLayer } from "../rpc/http.ts";12import { remoteHttpClientLayer } from "../rpc/http.ts";
2113
22function managedRelayTestLayer(14function managedRelayTestLayer(
23 fetchFn: typeof globalThis.fetch,15 fetchFn: typeof globalThis.fetch,
24 relayUrl = "https://relay.example.test",16 relayUrl = "https://relay.example.test",
25 accessTokenStore?: ManagedRelayAccessTokenStore,17 accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore,
26) {18) {
27 const httpClientLayer = remoteHttpClientLayer(fetchFn);19 const httpClientLayer = remoteHttpClientLayer(fetchFn);
28 const signerLayer = Layer.succeed(20 const signerLayer = Layer.succeed(
29 ManagedRelayDpopSigner,21 ManagedRelay.ManagedRelayDpopSigner,
30 ManagedRelayDpopSigner.of({22 ManagedRelay.ManagedRelayDpopSigner.of({
31 thumbprint: Effect.succeed("client-thumbprint"),23 thumbprint: Effect.succeed("client-thumbprint"),
32 createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`),24 createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) =>
25 Effect.succeed(`proof:${input.url}`),
33 }),26 }),
34 );27 );
35 return managedRelayClientLayer({28 return ManagedRelay.layer({
36 relayUrl,29 relayUrl,
37 clientId: "t3-mobile",30 clientId: "t3-mobile",
38 ...(accessTokenStore ? { accessTokenStore } : {}),31 ...(accessTokenStore ? { accessTokenStore } : {}),
...@@ -90,7 +83,7 @@ describe("ManagedRelayClient", () => {...@@ -90,7 +83,7 @@ describe("ManagedRelayClient", () => {
90 }) satisfies typeof globalThis.fetch;83 }) satisfies typeof globalThis.fetch;
9184
92 return Effect.gen(function* () {85 return Effect.gen(function* () {
93 const relayClient = yield* ManagedRelayClient;86 const relayClient = yield* ManagedRelay.ManagedRelayClient;
94 yield* relayClient.getEnvironmentStatus({87 yield* relayClient.getEnvironmentStatus({
95 clerkToken: clerkToken("user-1", "session-1"),88 clerkToken: clerkToken("user-1", "session-1"),
96 scopes: [RelayEnvironmentStatusScope],89 scopes: [RelayEnvironmentStatusScope],
...@@ -124,13 +117,14 @@ describe("ManagedRelayClient", () => {...@@ -124,13 +117,14 @@ describe("ManagedRelayClient", () => {
124 }) satisfies typeof globalThis.fetch;117 }) satisfies typeof globalThis.fetch;
125118
126 return Effect.gen(function* () {119 return Effect.gen(function* () {
127 const relayClient = yield* ManagedRelayClient;120 const relayClient = yield* ManagedRelay.ManagedRelayClient;
128 const error = yield* relayClient121 const error = yield* relayClient
129 .listEnvironments({ clerkToken: "clerk-token" })122 .listEnvironments({ clerkToken: "clerk-token" })
130 .pipe(Effect.flip);123 .pipe(Effect.flip);
131124
132 expect(error).toMatchObject({125 expect(error).toMatchObject({
133 _tag: "ManagedRelayClientError",126 _tag: "ManagedRelayUrlInvalidError",
127 relayUrl: "http://relay.example.test",
134 message: "Relay URL must be a secure absolute HTTPS origin.",128 message: "Relay URL must be a secure absolute HTTPS origin.",
135 });129 });
136 expect(requestCount).toBe(0);130 expect(requestCount).toBe(0);
...@@ -175,7 +169,7 @@ describe("ManagedRelayClient", () => {...@@ -175,7 +169,7 @@ describe("ManagedRelayClient", () => {
175 }) satisfies typeof globalThis.fetch;169 }) satisfies typeof globalThis.fetch;
176170
177 return Effect.gen(function* () {171 return Effect.gen(function* () {
178 const relayClient = yield* ManagedRelayClient;172 const relayClient = yield* ManagedRelay.ManagedRelayClient;
179 const statusInput = {173 const statusInput = {
180 clerkToken: clerkToken("user-1", "session-1"),174 clerkToken: clerkToken("user-1", "session-1"),
181 scopes: [RelayEnvironmentStatusScope],175 scopes: [RelayEnvironmentStatusScope],
...@@ -198,8 +192,8 @@ describe("ManagedRelayClient", () => {...@@ -198,8 +192,8 @@ describe("ManagedRelayClient", () => {
198192
199 it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => {193 it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => {
200 let tokenExchangeCount = 0;194 let tokenExchangeCount = 0;
201 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [];195 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [];
202 const accessTokenStore: ManagedRelayAccessTokenStore = {196 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
203 load: Effect.sync(() => persistedTokens),197 load: Effect.sync(() => persistedTokens),
204 save: (entries) =>198 save: (entries) =>
205 Effect.sync(() => {199 Effect.sync(() => {
...@@ -252,7 +246,7 @@ describe("ManagedRelayClient", () => {...@@ -252,7 +246,7 @@ describe("ManagedRelayClient", () => {
252246
253 return Effect.gen(function* () {247 return Effect.gen(function* () {
254 yield* Effect.gen(function* () {248 yield* Effect.gen(function* () {
255 const relayClient = yield* ManagedRelayClient;249 const relayClient = yield* ManagedRelay.ManagedRelayClient;
256 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1")));250 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1")));
257 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));251 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));
258252
...@@ -260,7 +254,7 @@ describe("ManagedRelayClient", () => {...@@ -260,7 +254,7 @@ describe("ManagedRelayClient", () => {
260 expect(persistedTokens).toHaveLength(1);254 expect(persistedTokens).toHaveLength(1);
261255
262 yield* Effect.gen(function* () {256 yield* Effect.gen(function* () {
263 const relayClient = yield* ManagedRelayClient;257 const relayClient = yield* ManagedRelay.ManagedRelayClient;
264 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2")));258 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2")));
265 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));259 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));
266260
...@@ -271,7 +265,7 @@ describe("ManagedRelayClient", () => {...@@ -271,7 +265,7 @@ describe("ManagedRelayClient", () => {
271 it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => {265 it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => {
272 let tokenExchangeCount = 0;266 let tokenExchangeCount = 0;
273 const statusTokens: Array<string | null> = [];267 const statusTokens: Array<string | null> = [];
274 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [268 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [
275 {269 {
276 accountId: "user-1",270 accountId: "user-1",
277 clientId: "t3-mobile",271 clientId: "t3-mobile",
...@@ -282,7 +276,7 @@ describe("ManagedRelayClient", () => {...@@ -282,7 +276,7 @@ describe("ManagedRelayClient", () => {
282 expiresAtMillis: Number.MAX_SAFE_INTEGER,276 expiresAtMillis: Number.MAX_SAFE_INTEGER,
283 },277 },
284 ];278 ];
285 const accessTokenStore: ManagedRelayAccessTokenStore = {279 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
286 load: Effect.sync(() => persistedTokens),280 load: Effect.sync(() => persistedTokens),
287 save: (entries) =>281 save: (entries) =>
288 Effect.sync(() => {282 Effect.sync(() => {
...@@ -344,7 +338,7 @@ describe("ManagedRelayClient", () => {...@@ -344,7 +338,7 @@ describe("ManagedRelayClient", () => {
344 }) satisfies typeof globalThis.fetch;338 }) satisfies typeof globalThis.fetch;
345339
346 return Effect.gen(function* () {340 return Effect.gen(function* () {
347 const relayClient = yield* ManagedRelayClient;341 const relayClient = yield* ManagedRelay.ManagedRelayClient;
348 const result = yield* relayClient.getEnvironmentStatus({342 const result = yield* relayClient.getEnvironmentStatus({
349 clerkToken: clerkToken("user-1", "session-1"),343 clerkToken: clerkToken("user-1", "session-1"),
350 scopes: [RelayEnvironmentStatusScope],344 scopes: [RelayEnvironmentStatusScope],
...@@ -363,8 +357,8 @@ describe("ManagedRelayClient", () => {...@@ -363,8 +357,8 @@ describe("ManagedRelayClient", () => {
363 });357 });
364358
365 it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => {359 it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => {
366 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [];360 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [];
367 const accessTokenStore: ManagedRelayAccessTokenStore = {361 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
368 load: Effect.succeed([]),362 load: Effect.succeed([]),
369 save: (entries) =>363 save: (entries) =>
370 Effect.sync(() => {364 Effect.sync(() => {
...@@ -407,7 +401,7 @@ describe("ManagedRelayClient", () => {...@@ -407,7 +401,7 @@ describe("ManagedRelayClient", () => {
407 }) satisfies typeof globalThis.fetch;401 }) satisfies typeof globalThis.fetch;
408402
409 return Effect.gen(function* () {403 return Effect.gen(function* () {
410 const relayClient = yield* ManagedRelayClient;404 const relayClient = yield* ManagedRelay.ManagedRelayClient;
411 yield* relayClient.getEnvironmentStatus({405 yield* relayClient.getEnvironmentStatus({
412 clerkToken: "not-a-jwt",406 clerkToken: "not-a-jwt",
413 scopes: [RelayEnvironmentStatusScope],407 scopes: [RelayEnvironmentStatusScope],
...@@ -423,17 +417,19 @@ describe("ManagedRelayClient", () => {...@@ -423,17 +417,19 @@ describe("ManagedRelayClient", () => {
423 new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch;417 new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch;
424418
425 return Effect.gen(function* () {419 return Effect.gen(function* () {
426 const relayClient = yield* ManagedRelayClient;420 const relayClient = yield* ManagedRelay.ManagedRelayClient;
427 const errorFiber = yield* relayClient421 const errorFiber = yield* relayClient
428 .listEnvironments({ clerkToken: "clerk-token" })422 .listEnvironments({ clerkToken: "clerk-token" })
429 .pipe(Effect.flip, Effect.forkScoped);423 .pipe(Effect.flip, Effect.forkScoped);
430424
431 yield* Effect.yieldNow;425 yield* Effect.yieldNow;
432 yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS));426 yield* TestClock.adjust(Duration.millis(ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS));
433 const error = yield* Fiber.join(errorFiber);427 const error = yield* Fiber.join(errorFiber);
434428
435 expect(error).toMatchObject({429 expect(error).toMatchObject({
436 _tag: "ManagedRelayClientError",430 _tag: "ManagedRelayRequestTimeoutError",
431 activity: "Relay environment listing",
432 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
437 message: "Relay environment listing timed out.",433 message: "Relay environment listing timed out.",
438 });434 });
439 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));435 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));
...@@ -454,13 +450,13 @@ describe("ManagedRelayClient", () => {...@@ -454,13 +450,13 @@ describe("ManagedRelayClient", () => {
454 )) satisfies typeof globalThis.fetch;450 )) satisfies typeof globalThis.fetch;
455451
456 return Effect.gen(function* () {452 return Effect.gen(function* () {
457 const relayClient = yield* ManagedRelayClient;453 const relayClient = yield* ManagedRelay.ManagedRelayClient;
458 const error = yield* relayClient454 const error = yield* relayClient
459 .listEnvironments({ clerkToken: "clerk-token" })455 .listEnvironments({ clerkToken: "clerk-token" })
460 .pipe(Effect.flip);456 .pipe(Effect.flip);
461457
462 expect(error).toMatchObject({458 expect(error).toMatchObject({
463 _tag: "ManagedRelayClientError",459 _tag: "ManagedRelayRequestFailedError",
464 traceId: "trace-managed-relay",460 traceId: "trace-managed-relay",
465 });461 });
466 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));462 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
...@@ -499,7 +495,7 @@ describe("ManagedRelayClient", () => {...@@ -499,7 +495,7 @@ describe("ManagedRelayClient", () => {
499 }) satisfies typeof globalThis.fetch;495 }) satisfies typeof globalThis.fetch;
500496
501 return Effect.gen(function* () {497 return Effect.gen(function* () {
502 const relayClient = yield* ManagedRelayClient;498 const relayClient = yield* ManagedRelay.ManagedRelayClient;
503 const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" });499 const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" });
504 expect(devices).toMatchObject([500 expect(devices).toMatchObject([
505 {501 {
packages/client-runtime/src/relay/managedRelay.ts+661-546
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5 type RelayClientDeviceRecord,5 type RelayClientDeviceRecord,
6 RelayConnectEnvironmentEndpoint,6 RelayConnectEnvironmentEndpoint,
7 type RelayDeviceRegistrationRequest,7 type RelayDeviceRegistrationRequest,
8 type RelayDpopAccessTokenScope,8 RelayDpopAccessTokenScope,
9 RelayDpopTokenExchangeGrantType,9 RelayDpopTokenExchangeGrantType,
10 type RelayEnvironmentConnectRequest,10 type RelayEnvironmentConnectRequest,
11 type RelayEnvironmentConnectResponse,11 type RelayEnvironmentConnectResponse,
...@@ -33,58 +33,184 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing";...@@ -33,58 +33,184 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
33import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";33import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34import * as Clock from "effect/Clock";34import * as Clock from "effect/Clock";
35import * as Context from "effect/Context";35import * as Context from "effect/Context";
36import * as Data from "effect/Data";
37import * as Duration from "effect/Duration";36import * as Duration from "effect/Duration";
38import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
39import * as Layer from "effect/Layer";38import * as Layer from "effect/Layer";
40import * as Option from "effect/Option";39import * as Option from "effect/Option";
41import * as Schema from "effect/Schema";40import * as Schema from "effect/Schema";
42import * as SynchronizedRef from "effect/SynchronizedRef";41import * as SynchronizedRef from "effect/SynchronizedRef";
43import { HttpClientError } from "effect/unstable/http";42import * as HttpClientError from "effect/unstable/http/HttpClientError";
44import type { HttpMethod } from "effect/unstable/http/HttpMethod";43import type * as HttpMethod from "effect/unstable/http/HttpMethod";
45import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";44import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
4645
47export interface ManagedRelayDpopProofInput {46export interface ManagedRelayDpopProofInput {
48 readonly method: HttpMethod;47 readonly method: HttpMethod.HttpMethod;
49 readonly url: string;48 readonly url: string;
50 readonly accessToken?: string;49 readonly accessToken?: string;
51}50}
5251
53export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{52export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass<ManagedRelayDpopKeyLoadError>()(
54 readonly cause: unknown;53 "ManagedRelayDpopKeyLoadError",
55}> {}54 {
55 keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]),
56 cause: Schema.Defect(),
57 },
58) {
59 override get message(): string {
60 return "Could not load relay DPoP proof key.";
61 }
62}
63
64export class ManagedRelayDpopProofCreationError extends Schema.TaggedErrorClass<ManagedRelayDpopProofCreationError>()(
65 "ManagedRelayDpopProofCreationError",
66 {
67 method: Schema.String,
68 url: Schema.String,
69 cause: Schema.Defect(),
70 },
71) {
72 override get message(): string {
73 return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`;
74 }
75}
5676
57export class ManagedRelayRequestTimeoutError extends Data.TaggedError(77export const ManagedRelayDpopSignerError = Schema.Union([
78 ManagedRelayDpopKeyLoadError,
79 ManagedRelayDpopProofCreationError,
80]);
81export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type;
82
83export const ManagedRelayRequestAction = Schema.Literals([
84 "exchange relay DPoP access token",
85 "list relay-managed environments",
86 "list relay client devices",
87 "create relay environment link challenge",
88 "link relay environment",
89 "unlink relay environment",
90 "get relay environment status",
91 "connect relay environment",
92 "register relay mobile device",
93 "unregister relay mobile device",
94 "register relay live activity",
95]);
96export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;
97
98export const ManagedRelayRequestActivity = Schema.Literals([
99 "Relay DPoP access token exchange",
100 "Relay environment listing",
101 "Relay client device listing",
102 "Relay environment link challenge",
103 "Relay environment linking",
104 "Relay environment unlinking",
105 "Relay environment status request",
106 "Relay environment connection",
107 "Relay mobile device registration",
108 "Relay mobile device unregistration",
109 "Relay Live Activity registration",
110]);
111export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;
112
113export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<ManagedRelayRequestTimeoutError>()(
58 "ManagedRelayRequestTimeoutError",114 "ManagedRelayRequestTimeoutError",
59)<{115 {
60 readonly message: string;116 activity: ManagedRelayRequestActivity,
61}> {}117 timeoutMs: Schema.Number,
118 },
119) {
120 override get message(): string {
121 return `${this.activity} timed out.`;
122 }
123}
124
125export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass<ManagedRelayUrlInvalidError>()(
126 "ManagedRelayUrlInvalidError",
127 {
128 relayUrl: Schema.String,
129 },
130) {
131 override get message(): string {
132 return "Relay URL must be a secure absolute HTTPS origin.";
133 }
134}
135
136export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass<ManagedRelayRequestFailedError>()(
137 "ManagedRelayRequestFailedError",
138 {
139 action: ManagedRelayRequestAction,
140 cause: Schema.Defect(),
141 relayError: Schema.optional(RelayProtectedError),
142 traceId: Schema.optional(Schema.String),
143 },
144) {
145 override get message(): string {
146 return `Could not ${this.action}.`;
147 }
148}
149
150export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass<ManagedRelayAccessTokenScopesUnexpectedError>()(
151 "ManagedRelayAccessTokenScopesUnexpectedError",
152 {
153 requestedScopes: Schema.Array(RelayDpopAccessTokenScope),
154 grantedScope: Schema.String,
155 },
156) {
157 override get message(): string {
158 return "Relay granted unexpected DPoP access token scopes.";
159 }
160}
161
162export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass<ManagedRelayTokenProofCreationError>()(
163 "ManagedRelayTokenProofCreationError",
164 {
165 method: Schema.String,
166 url: Schema.String,
167 cause: Schema.Defect(),
168 },
169) {
170 override get message(): string {
171 return "Could not create relay token DPoP proof.";
172 }
173}
174
175export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorClass<ManagedRelayRequestProofCreationError>()(
176 "ManagedRelayRequestProofCreationError",
177 {
178 method: Schema.String,
179 url: Schema.String,
180 cause: Schema.Defect(),
181 },
182) {
183 override get message(): string {
184 return "Could not create relay request DPoP proof.";
185 }
186}
187
188export const ManagedRelayClientError = Schema.Union([
189 ManagedRelayUrlInvalidError,
190 ManagedRelayRequestFailedError,
191 ManagedRelayRequestTimeoutError,
192 ManagedRelayAccessTokenScopesUnexpectedError,
193 ManagedRelayDpopKeyLoadError,
194 ManagedRelayTokenProofCreationError,
195 ManagedRelayRequestProofCreationError,
196]);
197export type ManagedRelayClientError = typeof ManagedRelayClientError.Type;
62198
63type RelayHttpRequestError =199type RelayHttpRequestError =
64 | RelayProtectedErrorType200 | RelayProtectedErrorType
65 | HttpClientError.HttpClientError201 | HttpClientError.HttpClientError
66 | Schema.SchemaError202 | Schema.SchemaError;
67 | ManagedRelayRequestTimeoutError;
68
69export interface ManagedRelayDpopSignerShape {
70 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>;
71 readonly createProof: (
72 input: ManagedRelayDpopProofInput,
73 ) => Effect.Effect<string, ManagedRelayDpopSignerError>;
74}
75203
76export class ManagedRelayDpopSigner extends Context.Service<204export class ManagedRelayDpopSigner extends Context.Service<
77 ManagedRelayDpopSigner,205 ManagedRelayDpopSigner,
78 ManagedRelayDpopSignerShape206 {
207 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopKeyLoadError>;
208 readonly createProof: (
209 input: ManagedRelayDpopProofInput,
210 ) => Effect.Effect<string, ManagedRelayDpopProofCreationError>;
211 }
79>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}212>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}
80213
81export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{
82 readonly message: string;
83 readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError;
84 readonly relayError?: RelayProtectedErrorType;
85 readonly traceId?: string;
86}> {}
87
88export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;214export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
89215
90export interface ManagedRelayAccessTokenCacheEntry {216export interface ManagedRelayAccessTokenCacheEntry {
...@@ -115,100 +241,96 @@ export interface ManagedRelayClientLayerOptions {...@@ -115,100 +241,96 @@ export interface ManagedRelayClientLayerOptions {
115 readonly accessTokenStore?: ManagedRelayAccessTokenStore;241 readonly accessTokenStore?: ManagedRelayAccessTokenStore;
116}242}
117243
118export interface ManagedRelayClientShape {
119 readonly relayUrl: string;
120 readonly listEnvironments: (input: {
121 readonly clerkToken: string;
122 }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>;
123 readonly listDevices: (input: {
124 readonly clerkToken: string;
125 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>;
126 readonly createEnvironmentLinkChallenge: (input: {
127 readonly clerkToken: string;
128 readonly payload: RelayEnvironmentLinkChallengeRequest;
129 }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>;
130 readonly linkEnvironment: (input: {
131 readonly clerkToken: string;
132 readonly payload: RelayEnvironmentLinkRequest;
133 }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>;
134 readonly unlinkEnvironment: (input: {
135 readonly clerkToken: string;
136 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
137 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
138 readonly getEnvironmentStatus: (input: {
139 readonly clerkToken: string;
140 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
141 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
142 }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>;
143 readonly connectEnvironment: (input: {
144 readonly clerkToken: string;
145 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
146 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
147 readonly deviceId?: string;
148 }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>;
149 readonly registerDevice: (input: {
150 readonly clerkToken: string;
151 readonly payload: RelayDeviceRegistrationRequest;
152 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
153 readonly unregisterDevice: (input: {
154 readonly clerkToken: string;
155 readonly deviceId: string;
156 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
157 readonly registerLiveActivity: (input: {
158 readonly clerkToken: string;
159 readonly payload: RelayLiveActivityRegistrationRequest;
160 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
161 readonly resetTokenCache: Effect.Effect<void>;
162}
163
164export class ManagedRelayClient extends Context.Service<244export class ManagedRelayClient extends Context.Service<
165 ManagedRelayClient,245 ManagedRelayClient,
166 ManagedRelayClientShape246 {
247 readonly relayUrl: string;
248 readonly listEnvironments: (input: {
249 readonly clerkToken: string;
250 }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>;
251 readonly listDevices: (input: {
252 readonly clerkToken: string;
253 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>;
254 readonly createEnvironmentLinkChallenge: (input: {
255 readonly clerkToken: string;
256 readonly payload: RelayEnvironmentLinkChallengeRequest;
257 }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>;
258 readonly linkEnvironment: (input: {
259 readonly clerkToken: string;
260 readonly payload: RelayEnvironmentLinkRequest;
261 }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>;
262 readonly unlinkEnvironment: (input: {
263 readonly clerkToken: string;
264 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
265 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
266 readonly getEnvironmentStatus: (input: {
267 readonly clerkToken: string;
268 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
269 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
270 }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>;
271 readonly connectEnvironment: (input: {
272 readonly clerkToken: string;
273 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
274 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
275 readonly deviceId?: string;
276 }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>;
277 readonly registerDevice: (input: {
278 readonly clerkToken: string;
279 readonly payload: RelayDeviceRegistrationRequest;
280 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
281 readonly unregisterDevice: (input: {
282 readonly clerkToken: string;
283 readonly deviceId: string;
284 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
285 readonly registerLiveActivity: (input: {
286 readonly clerkToken: string;
287 readonly payload: RelayLiveActivityRegistrationRequest;
288 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
289 readonly resetTokenCache: Effect.Effect<void>;
290 }
167>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}291>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
168292
169const isRelayProtectedError = Schema.is(RelayProtectedError);293const isRelayProtectedError = Schema.is(RelayProtectedError);
170294
171function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError {295function relayRequestError(action: ManagedRelayRequestAction) {
172 return new ManagedRelayClientError({
173 message,
174 ...(cause === undefined ? {} : { cause }),
175 });
176}
177
178function relayLocalError(
179 message: string,
180 cause: ManagedRelayDpopSignerError,
181): ManagedRelayClientError {
182 return new ManagedRelayClientError({ message, cause });
183}
184
185function relayRequestError(message: string) {
186 return (cause: RelayHttpRequestError): ManagedRelayClientError =>296 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
187 new ManagedRelayClientError({297 new ManagedRelayRequestFailedError({
188 message,298 action,
189 cause,299 cause,
190 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),300 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
191 });301 });
192}302}
193303
304function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) {
305 return {
306 method: error.method,
307 url: error.url,
308 cause: error,
309 };
310}
311
194function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {312function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {
195 return (313 return (
314 error._tag === "ManagedRelayRequestFailedError" &&
196 error.relayError?._tag === "RelayAuthInvalidError" &&315 error.relayError?._tag === "RelayAuthInvalidError" &&
197 error.relayError.reason === "invalid_bearer"316 error.relayError.reason === "invalid_bearer"
198 );317 );
199}318}
200319
201function timeoutRelayRequest(message: string) {320function timeoutRelayRequest(activity: ManagedRelayRequestActivity) {
202 return <A, E, R>(321 return <A, E, R>(
203 request: Effect.Effect<A, E, R>,322 effect: Effect.Effect<A, E, R>,
204 ): Effect.Effect<A, E | ManagedRelayClientError, R> =>323 ): Effect.Effect<A, E | ManagedRelayClientError, R> =>
205 request.pipe(324 effect.pipe(
206 Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)),325 Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)),
207 Effect.flatMap(326 Effect.flatMap(
208 Option.match({327 Option.match({
209 onNone: () =>328 onNone: () =>
210 Effect.fail(329 Effect.fail(
211 relayClientError(message, new ManagedRelayRequestTimeoutError({ message })),330 new ManagedRelayRequestTimeoutError({
331 activity,
332 timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
333 }),
212 ),334 ),
213 onSome: Effect.succeed,335 onSome: Effect.succeed,
214 }),336 }),
...@@ -258,10 +380,10 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) {...@@ -258,10 +380,10 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) {
258 };380 };
259}381}
260382
261function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {383function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] {
262 const unavailable = (spanName: string) =>384 const unavailable = (spanName: string) =>
263 Effect.fn(spanName)(function* () {385 Effect.fn(spanName)(function* () {
264 return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin.");386 return yield* new ManagedRelayUrlInvalidError({ relayUrl });
265 });387 });
266 return ManagedRelayClient.of({388 return ManagedRelayClient.of({
267 relayUrl,389 relayUrl,
...@@ -283,482 +405,475 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {...@@ -283,482 +405,475 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {
283 });405 });
284}406}
285407
286export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) {408export const make = Effect.fn("ManagedRelayClient.make")(function* (
287 return Layer.effect(409 options: ManagedRelayClientLayerOptions,
288 ManagedRelayClient,410) {
289 Effect.gen(function* () {411 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
290 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);412 if (relayUrl === null) {
291 if (relayUrl === null) {413 return disabledManagedRelayClient(options.relayUrl);
292 return disabledManagedRelayClient(options.relayUrl);414 }
293 }415 const signer = yield* ManagedRelayDpopSigner;
294 const signer = yield* ManagedRelayDpopSigner;416 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
295 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });417 const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : [];
296 const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : [];418 const cachedTokens = yield* SynchronizedRef.make<
297 const cachedTokens = yield* SynchronizedRef.make<419 ReadonlyArray<ManagedRelayAccessTokenCacheEntry>
298 ReadonlyArray<ManagedRelayAccessTokenCacheEntry>420 >(initialTokens.filter((token) => token.clientId === options.clientId));
299 >(initialTokens.filter((token) => token.clientId === options.clientId));421 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
300 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });422
301423 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
302 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;424 const dpopProofTargets = {
303 const dpopProofTargets = {425 exchangeAccessToken: (): DpopProofTarget => ({
304 exchangeAccessToken: (): DpopProofTarget => ({426 method: RelayExchangeDpopAccessTokenEndpoint.method,
305 method: RelayExchangeDpopAccessTokenEndpoint.method,427 url: urlBuilder.token.exchangeDpopAccessToken(),
306 url: urlBuilder.token.exchangeDpopAccessToken(),428 }),
307 }),429 getEnvironmentStatus: (
308 getEnvironmentStatus: (430 environmentId: RelayClientEnvironmentRecord["environmentId"],
309 environmentId: RelayClientEnvironmentRecord["environmentId"],431 ): DpopProofTarget => ({
310 ): DpopProofTarget => ({432 method: RelayGetEnvironmentStatusEndpoint.method,
311 method: RelayGetEnvironmentStatusEndpoint.method,433 url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }),
312 url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }),434 }),
313 }),435 connectEnvironment: (
314 connectEnvironment: (436 environmentId: RelayClientEnvironmentRecord["environmentId"],
315 environmentId: RelayClientEnvironmentRecord["environmentId"],437 ): DpopProofTarget => ({
316 ): DpopProofTarget => ({438 method: RelayConnectEnvironmentEndpoint.method,
317 method: RelayConnectEnvironmentEndpoint.method,439 url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }),
318 url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }),440 }),
319 }),441 registerDevice: (): DpopProofTarget => ({
320 registerDevice: (): DpopProofTarget => ({442 method: RelayRegisterDeviceEndpoint.method,
321 method: RelayRegisterDeviceEndpoint.method,443 url: urlBuilder.mobile.registerDevice(),
322 url: urlBuilder.mobile.registerDevice(),444 }),
323 }),445 unregisterDevice: (deviceId: string): DpopProofTarget => ({
324 unregisterDevice: (deviceId: string): DpopProofTarget => ({446 method: RelayUnregisterDeviceEndpoint.method,
325 method: RelayUnregisterDeviceEndpoint.method,447 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),
326 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),448 }),
327 }),449 registerLiveActivity: (): DpopProofTarget => ({
328 registerLiveActivity: (): DpopProofTarget => ({450 method: RelayRegisterLiveActivityEndpoint.method,
329 method: RelayRegisterLiveActivityEndpoint.method,451 url: urlBuilder.mobile.registerLiveActivity(),
330 url: urlBuilder.mobile.registerLiveActivity(),452 }),
331 }),453 };
332 };
333454
334 const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")(455 const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")(
335 function* (input: {456 function* (input: {
336 readonly clerkToken: string;457 readonly clerkToken: string;
337 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;458 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
338 }) {459 }) {
339 yield* Effect.annotateCurrentSpan({460 yield* Effect.annotateCurrentSpan({
340 "relay.client_id": options.clientId,461 "relay.client_id": options.clientId,
341 "relay.scopes": input.scopes.join(" "),462 "relay.scopes": input.scopes.join(" "),
342 });463 });
343 const proof = yield* signer464 const proof = yield* signer
344 .createProof(dpopProofTargets.exchangeAccessToken())465 .createProof(dpopProofTargets.exchangeAccessToken())
345 .pipe(466 .pipe(
346 Effect.mapError((cause) =>467 Effect.mapError(
347 relayLocalError("Could not create relay token DPoP proof.", cause),468 (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)),
348 ),469 ),
349 );470 );
350 const response = yield* client.token471 const response = yield* client.token
351 .exchangeDpopAccessToken({472 .exchangeDpopAccessToken({
352 headers: { dpop: proof },473 headers: { dpop: proof },
353 payload: {474 payload: {
354 grant_type: RelayDpopTokenExchangeGrantType,475 grant_type: RelayDpopTokenExchangeGrantType,
355 subject_token: input.clerkToken,476 subject_token: input.clerkToken,
356 subject_token_type: RelayJwtSubjectTokenType,477 subject_token_type: RelayJwtSubjectTokenType,
357 requested_token_type: RelayAccessTokenType,478 requested_token_type: RelayAccessTokenType,
358 resource: relayUrl,479 resource: relayUrl,
359 scope: encodeOAuthScope(input.scopes),480 scope: encodeOAuthScope(input.scopes),
360 client_id: options.clientId,481 client_id: options.clientId,
361 },482 },
362 })483 })
363 .pipe(484 .pipe(
364 Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")),485 Effect.mapError(relayRequestError("exchange relay DPoP access token")),
365 timeoutRelayRequest("Relay DPoP access token exchange timed out."),486 timeoutRelayRequest("Relay DPoP access token exchange"),
366 );487 );
367 if (!oauthScopeSetEquals(response.scope, input.scopes)) {488 if (!oauthScopeSetEquals(response.scope, input.scopes)) {
368 return yield* relayClientError("Relay granted unexpected DPoP access token scopes.");489 return yield* new ManagedRelayAccessTokenScopesUnexpectedError({
369 }490 requestedScopes: input.scopes,
370 return response;491 grantedScope: response.scope,
371 },492 });
372 );493 }
494 return response;
495 },
496 );
373497
374 const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")(498 const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")(
375 function* (input: {499 function* (input: {
376 readonly clerkToken: string;500 readonly clerkToken: string;
377 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;501 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
378 readonly thumbprint: string;502 readonly thumbprint: string;
379 }) {503 }) {
380 yield* Effect.annotateCurrentSpan({504 yield* Effect.annotateCurrentSpan({
381 "relay.client_id": options.clientId,505 "relay.client_id": options.clientId,
382 "relay.scopes": input.scopes.join(" "),506 "relay.scopes": input.scopes.join(" "),
383 });507 });
384 const nowMillis = yield* Clock.currentTimeMillis;508 const nowMillis = yield* Clock.currentTimeMillis;
385 const accountId = relayAccountId(input.clerkToken);509 const accountId = relayAccountId(input.clerkToken);
386 if (Option.isNone(accountId)) {510 if (Option.isNone(accountId)) {
387 yield* Effect.annotateCurrentSpan({511 yield* Effect.annotateCurrentSpan({
388 "relay.token_cache.result": "bypass",512 "relay.token_cache.result": "bypass",
389 "relay.token_cache.bypass_reason": "invalid_subject_token",513 "relay.token_cache.bypass_reason": "invalid_subject_token",
390 });514 });
391 const response = yield* exchangeAccessToken(input);515 const response = yield* exchangeAccessToken(input);
392 return {516 return {
393 accountId: "",517 accountId: "",
518 clientId: options.clientId,
519 relayUrl,
520 thumbprint: input.thumbprint,
521 scopes: input.scopes,
522 accessToken: response.access_token,
523 expiresAtMillis: nowMillis + response.expires_in * 1_000,
524 } satisfies ManagedRelayAccessTokenCacheEntry;
525 }
526 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) =>
527 Effect.gen(function* () {
528 const activeTokens = tokens.filter((token) => token.expiresAtMillis > nowMillis + 5_000);
529 const cached = activeTokens.find((token) =>
530 tokenMatches(token, {
531 accountId: accountId.value,
394 clientId: options.clientId,532 clientId: options.clientId,
395 relayUrl,533 relayUrl,
396 thumbprint: input.thumbprint,534 thumbprint: input.thumbprint,
397 scopes: input.scopes,535 scopes: input.scopes,
398 accessToken: response.access_token,536 nowMillis,
399 expiresAtMillis: nowMillis + response.expires_in * 1_000,537 }),
400 } satisfies ManagedRelayAccessTokenCacheEntry;538 );
539 if (cached) {
540 yield* Effect.annotateCurrentSpan({
541 "relay.token_cache.result": "hit",
542 });
543 return [cached, activeTokens] as const;
401 }544 }
402 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) =>545 yield* Effect.annotateCurrentSpan({
403 Effect.gen(function* () {546 "relay.token_cache.result": "miss",
404 const activeTokens = tokens.filter(547 });
405 (token) => token.expiresAtMillis > nowMillis + 5_000,548 const response = yield* exchangeAccessToken(input);
406 );549 const next: ManagedRelayAccessTokenCacheEntry = {
407 const cached = activeTokens.find((token) =>550 accountId: accountId.value,
408 tokenMatches(token, {551 clientId: options.clientId,
409 accountId: accountId.value,552 relayUrl,
410 clientId: options.clientId,553 thumbprint: input.thumbprint,
411 relayUrl,554 scopes: input.scopes,
412 thumbprint: input.thumbprint,555 accessToken: response.access_token,
413 scopes: input.scopes,556 expiresAtMillis: nowMillis + response.expires_in * 1_000,
414 nowMillis,557 };
415 }),558 const nextTokens = [...activeTokens, next];
416 );559 if (options.accessTokenStore) {
417 if (cached) {560 yield* options.accessTokenStore.save(nextTokens);
418 yield* Effect.annotateCurrentSpan({561 }
419 "relay.token_cache.result": "hit",562 return [next, nextTokens] as const;
420 });563 }),
421 return [cached, activeTokens] as const;564 ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection"));
422 }565 },
423 yield* Effect.annotateCurrentSpan({566 );
424 "relay.token_cache.result": "miss",567
425 });568 const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: {
426 const response = yield* exchangeAccessToken(input);569 readonly clerkToken: string;
427 const next: ManagedRelayAccessTokenCacheEntry = {570 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
428 accountId: accountId.value,571 readonly target: DpopProofTarget;
429 clientId: options.clientId,572 }) {
430 relayUrl,573 yield* Effect.annotateCurrentSpan({
431 thumbprint: input.thumbprint,574 "relay.client_id": options.clientId,
432 scopes: input.scopes,575 "relay.scopes": input.scopes.join(" "),
433 accessToken: response.access_token,576 "http.request.method": input.target.method,
434 expiresAtMillis: nowMillis + response.expires_in * 1_000,577 "url.full": input.target.url,
435 };578 });
436 const nextTokens = [...activeTokens, next];579 const thumbprint = yield* signer.thumbprint;
437 if (options.accessTokenStore) {580 const token = yield* obtainAccessToken({
438 yield* options.accessTokenStore.save(nextTokens);581 clerkToken: input.clerkToken,
582 scopes: input.scopes,
583 thumbprint,
584 });
585 const proof = yield* signer
586 .createProof({
587 ...input.target,
588 accessToken: token.accessToken,
589 })
590 .pipe(
591 Effect.mapError(
592 (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)),
593 ),
594 );
595 return { accessToken: token.accessToken, proof, thumbprint };
596 });
597
598 const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")(
599 function* (accessToken: string) {
600 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => {
601 const nextTokens = tokens.filter((token) => token.accessToken !== accessToken);
602 if (nextTokens.length === tokens.length) {
603 return Effect.succeed([false, tokens] as const);
604 }
605 return (
606 options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void
607 ).pipe(Effect.as([true, nextTokens] as const));
608 });
609 },
610 );
611
612 const runDpopRequest = <A>(
613 input: {
614 readonly clerkToken: string;
615 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
616 readonly target: DpopProofTarget;
617 },
618 request: (
619 authorization: ManagedRelayAuthorization,
620 ) => Effect.Effect<A, ManagedRelayClientError>,
621 ): Effect.Effect<A, ManagedRelayClientError> => {
622 const attempt = (refreshRejectedToken: boolean): Effect.Effect<A, ManagedRelayClientError> =>
623 authorize(input).pipe(
624 Effect.flatMap((authorization) =>
625 request(authorization).pipe(
626 Effect.catch((error) => {
627 if (!isRejectedDpopAccessToken(error)) {
628 return Effect.fail(error);
439 }629 }
440 return [next, nextTokens] as const;630 return invalidateAccessToken(authorization.accessToken).pipe(
631 Effect.tap((invalidated) =>
632 Effect.annotateCurrentSpan({
633 "relay.token_cache.invalidated": invalidated,
634 "relay.token_cache.invalidation_reason": "invalid_bearer",
635 "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
636 }),
637 ),
638 Effect.tap((invalidated) =>
639 invalidated && refreshRejectedToken
640 ? Effect.logWarning(
641 "Relay rejected a cached DPoP access token; refreshing it once.",
642 )
643 : Effect.void,
644 ),
645 Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
646 );
441 }),647 }),
442 ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection"));648 ),
443 },649 ),
444 );650 );
651 return attempt(true);
652 };
445653
446 const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: {654 const mobileRegistrationRequest = <A>(
447 readonly clerkToken: string;655 input: {
448 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;656 readonly clerkToken: string;
449 readonly target: DpopProofTarget;657 readonly target: DpopProofTarget;
450 }) {658 },
451 yield* Effect.annotateCurrentSpan({659 request: (
452 "relay.client_id": options.clientId,660 authorization: ManagedRelayAuthorization,
453 "relay.scopes": input.scopes.join(" "),661 ) => Effect.Effect<A, ManagedRelayClientError>,
454 "http.request.method": input.target.method,662 ) =>
455 "url.full": input.target.url,663 runDpopRequest(
456 });664 {
457 const thumbprint = yield* signer.thumbprint.pipe(665 ...input,
458 Effect.mapError((cause) =>666 scopes: [RelayMobileRegistrationScope],
459 relayLocalError("Could not load relay DPoP proof key.", cause),667 },
460 ),668 request,
461 );669 );
462 const token = yield* obtainAccessToken({670
463 clerkToken: input.clerkToken,671 return ManagedRelayClient.of({
464 scopes: input.scopes,672 relayUrl,
465 thumbprint,673 listEnvironments: Effect.fnUntraced(
466 });674 function* (input) {
467 const proof = yield* signer675 return yield* client.client
468 .createProof({676 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
469 ...input.target,677 .pipe(
470 accessToken: token.accessToken,678 Effect.map((response) => response.environments),
679 Effect.mapError(relayRequestError("list relay-managed environments")),
680 timeoutRelayRequest("Relay environment listing"),
681 );
682 },
683 Effect.withSpan("clientRuntime.managedRelay.listEnvironments"),
684 withRelayClientTracing,
685 ),
686 listDevices: Effect.fnUntraced(
687 function* (input) {
688 return yield* client.client
689 .listDevices({
690 headers: bearerHeaders(input.clerkToken),
471 })691 })
472 .pipe(692 .pipe(
473 Effect.mapError((cause) =>693 Effect.map((response) => response.devices),
474 relayLocalError("Could not create relay request DPoP proof.", cause),694 Effect.mapError(relayRequestError("list relay client devices")),
475 ),695 timeoutRelayRequest("Relay client device listing"),
476 );696 );
477 return { accessToken: token.accessToken, proof, thumbprint };697 },
478 });698 Effect.withSpan("clientRuntime.managedRelay.listDevices"),
479699 withRelayClientTracing,
480 const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")(700 ),
481 function* (accessToken: string) {701 createEnvironmentLinkChallenge: Effect.fnUntraced(
482 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => {702 function* (input) {
483 const nextTokens = tokens.filter((token) => token.accessToken !== accessToken);703 return yield* client.client
484 if (nextTokens.length === tokens.length) {704 .createEnvironmentLinkChallenge({
485 return Effect.succeed([false, tokens] as const);705 headers: bearerHeaders(input.clerkToken),
486 }706 payload: input.payload,
487 return (707 })
488 options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void708 .pipe(
489 ).pipe(Effect.as([true, nextTokens] as const));709 Effect.mapError(relayRequestError("create relay environment link challenge")),
490 });710 timeoutRelayRequest("Relay environment link challenge"),
491 },711 );
492 );712 },
493713 Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"),
494 const runDpopRequest = <A>(714 withRelayClientTracing,
495 input: {715 ),
496 readonly clerkToken: string;716 linkEnvironment: Effect.fnUntraced(
497 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;717 function* (input) {
498 readonly target: DpopProofTarget;718 return yield* client.client
499 },719 .linkEnvironment({
500 request: (720 headers: bearerHeaders(input.clerkToken),
501 authorization: ManagedRelayAuthorization,721 payload: input.payload,
502 ) => Effect.Effect<A, ManagedRelayClientError>,722 })
503 ): Effect.Effect<A, ManagedRelayClientError> => {723 .pipe(
504 const attempt = (724 Effect.mapError(relayRequestError("link relay environment")),
505 refreshRejectedToken: boolean,725 timeoutRelayRequest("Relay environment linking"),
506 ): Effect.Effect<A, ManagedRelayClientError> =>
507 authorize(input).pipe(
508 Effect.flatMap((authorization) =>
509 request(authorization).pipe(
510 Effect.catch((error) => {
511 if (!isRejectedDpopAccessToken(error)) {
512 return Effect.fail(error);
513 }
514 return invalidateAccessToken(authorization.accessToken).pipe(
515 Effect.tap((invalidated) =>
516 Effect.annotateCurrentSpan({
517 "relay.token_cache.invalidated": invalidated,
518 "relay.token_cache.invalidation_reason": "invalid_bearer",
519 "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
520 }),
521 ),
522 Effect.tap((invalidated) =>
523 invalidated && refreshRejectedToken
524 ? Effect.logWarning(
525 "Relay rejected a cached DPoP access token; refreshing it once.",
526 )
527 : Effect.void,
528 ),
529 Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
530 );
531 }),
532 ),
533 ),
534 );726 );
535 return attempt(true);727 },
536 };728 Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"),
537729 withRelayClientTracing,
538 const mobileRegistrationRequest = <A>(730 ),
539 input: {731 unlinkEnvironment: Effect.fnUntraced(
540 readonly clerkToken: string;732 function* (input) {
541 readonly target: DpopProofTarget;733 return yield* client.client
542 },734 .unlinkEnvironment({
543 request: (735 headers: bearerHeaders(input.clerkToken),
544 authorization: ManagedRelayAuthorization,736 params: { environmentId: input.environmentId },
545 ) => Effect.Effect<A, ManagedRelayClientError>,737 })
546 ) =>738 .pipe(
547 runDpopRequest(739 Effect.mapError(relayRequestError("unlink relay environment")),
740 timeoutRelayRequest("Relay environment unlinking"),
741 );
742 },
743 Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"),
744 withRelayClientTracing,
745 ),
746 getEnvironmentStatus: Effect.fnUntraced(
747 function* (input) {
748 yield* Effect.annotateCurrentSpan({
749 "environment.id": input.environmentId,
750 });
751 return yield* runDpopRequest(
548 {752 {
549 ...input,753 clerkToken: input.clerkToken,
550 scopes: [RelayMobileRegistrationScope],754 scopes: input.scopes,
755 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
551 },756 },
552 request,757 (authorization) =>
553 );758 client.dpopClient
554759 .getEnvironmentStatus({
555 return ManagedRelayClient.of({760 headers: dpopHeaders(authorization),
556 relayUrl,761 params: { environmentId: input.environmentId },
557 listEnvironments: Effect.fnUntraced(762 })
558 function* (input) {
559 return yield* client.client
560 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
561 .pipe(763 .pipe(
562 Effect.map((response) => response.environments),764 Effect.mapError(relayRequestError("get relay environment status")),
563 Effect.mapError(relayRequestError("Could not list relay-managed environments.")),765 timeoutRelayRequest("Relay environment status request"),
564 timeoutRelayRequest("Relay environment listing timed out."),766 ),
565 );767 );
768 },
769 Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"),
770 withRelayClientTracing,
771 ),
772 connectEnvironment: Effect.fnUntraced(
773 function* (input) {
774 yield* Effect.annotateCurrentSpan({
775 "environment.id": input.environmentId,
776 });
777 return yield* runDpopRequest(
778 {
779 clerkToken: input.clerkToken,
780 scopes: input.scopes,
781 target: dpopProofTargets.connectEnvironment(input.environmentId),
566 },782 },
567 Effect.withSpan("clientRuntime.managedRelay.listEnvironments"),783 (authorization) => {
568 withRelayClientTracing,784 const payload: RelayEnvironmentConnectRequest = {
569 ),785 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
570 listDevices: Effect.fnUntraced(786 clientKeyThumbprint: authorization.thumbprint,
571 function* (input) {787 };
572 return yield* client.client788 return client.dpopClient
573 .listDevices({789 .connectEnvironment({
574 headers: bearerHeaders(input.clerkToken),790 headers: dpopHeaders(authorization),
791 params: { environmentId: input.environmentId },
792 payload,
575 })793 })
576 .pipe(794 .pipe(
577 Effect.map((response) => response.devices),795 Effect.mapError(relayRequestError("connect relay environment")),
578 Effect.mapError(relayRequestError("Could not list relay client devices.")),796 timeoutRelayRequest("Relay environment connection"),
579 timeoutRelayRequest("Relay client device listing timed out."),
580 );797 );
581 },798 },
582 Effect.withSpan("clientRuntime.managedRelay.listDevices"),799 );
583 withRelayClientTracing,800 },
584 ),801 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),
585 createEnvironmentLinkChallenge: Effect.fnUntraced(802 withRelayClientTracing,
586 function* (input) {803 ),
587 return yield* client.client804 registerDevice: Effect.fnUntraced(
588 .createEnvironmentLinkChallenge({805 function* (input) {
589 headers: bearerHeaders(input.clerkToken),806 return yield* mobileRegistrationRequest(
807 {
808 clerkToken: input.clerkToken,
809 target: dpopProofTargets.registerDevice(),
810 },
811 (authorization) =>
812 client.mobile
813 .registerDevice({
814 headers: dpopHeaders(authorization),
590 payload: input.payload,815 payload: input.payload,
591 })816 })
592 .pipe(817 .pipe(
593 Effect.mapError(818 Effect.mapError(relayRequestError("register relay mobile device")),
594 relayRequestError("Could not create relay environment link challenge."),819 timeoutRelayRequest("Relay mobile device registration"),
595 ),820 ),
596 timeoutRelayRequest("Relay environment link challenge timed out."),821 );
597 );822 },
823 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),
824 withRelayClientTracing,
825 ),
826 unregisterDevice: Effect.fnUntraced(
827 function* (input) {
828 return yield* mobileRegistrationRequest(
829 {
830 clerkToken: input.clerkToken,
831 target: dpopProofTargets.unregisterDevice(input.deviceId),
598 },832 },
599 Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"),833 (authorization) =>
600 withRelayClientTracing,834 client.mobile
601 ),835 .unregisterDevice({
602 linkEnvironment: Effect.fnUntraced(836 headers: dpopHeaders(authorization),
603 function* (input) {837 params: { deviceId: input.deviceId },
604 return yield* client.client
605 .linkEnvironment({
606 headers: bearerHeaders(input.clerkToken),
607 payload: input.payload,
608 })838 })
609 .pipe(839 .pipe(
610 Effect.mapError(relayRequestError("Could not link relay environment.")),840 Effect.mapError(relayRequestError("unregister relay mobile device")),
611 timeoutRelayRequest("Relay environment linking timed out."),841 timeoutRelayRequest("Relay mobile device unregistration"),
612 );842 ),
843 );
844 },
845 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
846 withRelayClientTracing,
847 ),
848 registerLiveActivity: Effect.fnUntraced(
849 function* (input) {
850 return yield* mobileRegistrationRequest(
851 {
852 clerkToken: input.clerkToken,
853 target: dpopProofTargets.registerLiveActivity(),
613 },854 },
614 Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"),855 (authorization) =>
615 withRelayClientTracing,856 client.mobile
616 ),857 .registerLiveActivity({
617 unlinkEnvironment: Effect.fnUntraced(858 headers: dpopHeaders(authorization),
618 function* (input) {859 payload: input.payload,
619 return yield* client.client
620 .unlinkEnvironment({
621 headers: bearerHeaders(input.clerkToken),
622 params: { environmentId: input.environmentId },
623 })860 })
624 .pipe(861 .pipe(
625 Effect.mapError(relayRequestError("Could not unlink relay environment.")),862 Effect.mapError(relayRequestError("register relay live activity")),
626 timeoutRelayRequest("Relay environment unlinking timed out."),863 timeoutRelayRequest("Relay Live Activity registration"),
627 );864 ),
628 },865 );
629 Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"),866 },
630 withRelayClientTracing,867 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),
631 ),868 withRelayClientTracing,
632 getEnvironmentStatus: Effect.fnUntraced(869 ),
633 function* (input) {870 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
634 yield* Effect.annotateCurrentSpan({871 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
635 "environment.id": input.environmentId,872 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
636 });873 withRelayClientTracing,
637 return yield* runDpopRequest(874 ),
638 {875 });
639 clerkToken: input.clerkToken,876});
640 scopes: input.scopes,877
641 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),878export const layer = (options: ManagedRelayClientLayerOptions) =>
642 },879 Layer.effect(ManagedRelayClient, make(options));
643 (authorization) =>
644 client.dpopClient
645 .getEnvironmentStatus({
646 headers: dpopHeaders(authorization),
647 params: { environmentId: input.environmentId },
648 })
649 .pipe(
650 Effect.mapError(relayRequestError("Could not get relay environment status.")),
651 timeoutRelayRequest("Relay environment status request timed out."),
652 ),
653 );
654 },
655 Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"),
656 withRelayClientTracing,
657 ),
658 connectEnvironment: Effect.fnUntraced(
659 function* (input) {
660 yield* Effect.annotateCurrentSpan({
661 "environment.id": input.environmentId,
662 });
663 return yield* runDpopRequest(
664 {
665 clerkToken: input.clerkToken,
666 scopes: input.scopes,
667 target: dpopProofTargets.connectEnvironment(input.environmentId),
668 },
669 (authorization) => {
670 const payload: RelayEnvironmentConnectRequest = {
671 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
672 clientKeyThumbprint: authorization.thumbprint,
673 };
674 return client.dpopClient
675 .connectEnvironment({
676 headers: dpopHeaders(authorization),
677 params: { environmentId: input.environmentId },
678 payload,
679 })
680 .pipe(
681 Effect.mapError(relayRequestError("Could not connect relay environment.")),
682 timeoutRelayRequest("Relay environment connection timed out."),
683 );
684 },
685 );
686 },
687 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),
688 withRelayClientTracing,
689 ),
690 registerDevice: Effect.fnUntraced(
691 function* (input) {
692 return yield* mobileRegistrationRequest(
693 {
694 clerkToken: input.clerkToken,
695 target: dpopProofTargets.registerDevice(),
696 },
697 (authorization) =>
698 client.mobile
699 .registerDevice({
700 headers: dpopHeaders(authorization),
701 payload: input.payload,
702 })
703 .pipe(
704 Effect.mapError(relayRequestError("Could not register relay mobile device.")),
705 timeoutRelayRequest("Relay mobile device registration timed out."),
706 ),
707 );
708 },
709 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),
710 withRelayClientTracing,
711 ),
712 unregisterDevice: Effect.fnUntraced(
713 function* (input) {
714 return yield* mobileRegistrationRequest(
715 {
716 clerkToken: input.clerkToken,
717 target: dpopProofTargets.unregisterDevice(input.deviceId),
718 },
719 (authorization) =>
720 client.mobile
721 .unregisterDevice({
722 headers: dpopHeaders(authorization),
723 params: { deviceId: input.deviceId },
724 })
725 .pipe(
726 Effect.mapError(relayRequestError("Could not unregister relay mobile device.")),
727 timeoutRelayRequest("Relay mobile device unregistration timed out."),
728 ),
729 );
730 },
731 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
732 withRelayClientTracing,
733 ),
734 registerLiveActivity: Effect.fnUntraced(
735 function* (input) {
736 return yield* mobileRegistrationRequest(
737 {
738 clerkToken: input.clerkToken,
739 target: dpopProofTargets.registerLiveActivity(),
740 },
741 (authorization) =>
742 client.mobile
743 .registerLiveActivity({
744 headers: dpopHeaders(authorization),
745 payload: input.payload,
746 })
747 .pipe(
748 Effect.mapError(relayRequestError("Could not register relay live activity.")),
749 timeoutRelayRequest("Relay Live Activity registration timed out."),
750 ),
751 );
752 },
753 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),
754 withRelayClientTracing,
755 ),
756 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
757 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
758 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
759 withRelayClientTracing,
760 ),
761 });
762 }),
763 );
764}
packages/client-runtime/src/relay/managedRelayState.test.ts+7-10
...@@ -12,11 +12,7 @@ import * as Stream from "effect/Stream";...@@ -12,11 +12,7 @@ import * as Stream from "effect/Stream";
12import { Atom, AtomRegistry } from "effect/unstable/reactivity";12import { Atom, AtomRegistry } from "effect/unstable/reactivity";
13import { afterEach, vi } from "vite-plus/test";13import { afterEach, vi } from "vite-plus/test";
1414
15import {15import * as ManagedRelay from "./managedRelay.ts";
16 ManagedRelayClient,
17 ManagedRelayClientError,
18 type ManagedRelayClientShape,
19} from "./managedRelay.ts";
20import {16import {
21 createManagedRelayQueryManager,17 createManagedRelayQueryManager,
22 createManagedRelaySession,18 createManagedRelaySession,
...@@ -66,10 +62,10 @@ function resetRegistry() {...@@ -66,10 +62,10 @@ function resetRegistry() {
66}62}
6763
68function createManager(64function createManager(
69 overrides?: Partial<ManagedRelayClientShape>,65 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,
70 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,66 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
71) {67) {
72 const client = ManagedRelayClient.of({68 const client = ManagedRelay.ManagedRelayClient.of({
73 relayUrl: "https://relay.example.test",69 relayUrl: "https://relay.example.test",
74 listEnvironments: () => Effect.succeed([environment]),70 listEnvironments: () => Effect.succeed([environment]),
75 listDevices: () => Effect.succeed([device]),71 listDevices: () => Effect.succeed([device]),
...@@ -90,7 +86,7 @@ function createManager(...@@ -90,7 +86,7 @@ function createManager(
90 resetTokenCache: Effect.void,86 resetTokenCache: Effect.void,
91 ...overrides,87 ...overrides,
92 });88 });
93 const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client));89 const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client));
94 return createManagedRelayQueryManager(runtime, {90 return createManagedRelayQueryManager(runtime, {
95 staleTimeMs: 60_000,91 staleTimeMs: 60_000,
96 ...(onQueryEvent ? { onQueryEvent } : {}),92 ...(onQueryEvent ? { onQueryEvent } : {}),
...@@ -363,8 +359,9 @@ describe("createManagedRelayQueryManager", () => {...@@ -363,8 +359,9 @@ describe("createManagedRelayQueryManager", () => {
363 const manager = createManager({359 const manager = createManager({
364 getEnvironmentStatus: () =>360 getEnvironmentStatus: () =>
365 Effect.fail(361 Effect.fail(
366 new ManagedRelayClientError({362 new ManagedRelay.ManagedRelayRequestFailedError({
367 message: "Could not get relay environment status.",363 action: "get relay environment status",
364 cause: new Error("Relay request failed."),
368 traceId: "trace-status",365 traceId: "trace-status",
369 }),366 }),
370 ),367 ),
packages/client-runtime/src/relay/managedRelayState.ts+5-5
...@@ -16,7 +16,7 @@ import * as Stream from "effect/Stream";...@@ -16,7 +16,7 @@ import * as Stream from "effect/Stream";
16import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";16import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1717
18import { findErrorTraceId } from "../errors/errorTrace.ts";18import { findErrorTraceId } from "../errors/errorTrace.ts";
19import { ManagedRelayClient } from "./managedRelay.ts";19import * as ManagedRelay from "./managedRelay.ts";
2020
21const DEFAULT_STALE_TIME_MS = 15_000;21const DEFAULT_STALE_TIME_MS = 15_000;
22const DEFAULT_IDLE_TTL_MS = 5 * 60_000;22const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
...@@ -308,7 +308,7 @@ export function readManagedRelaySnapshotState<A>(...@@ -308,7 +308,7 @@ export function readManagedRelaySnapshotState<A>(
308}308}
309309
310export function createManagedRelayQueryManager(310export function createManagedRelayQueryManager(
311 runtime: Atom.AtomRuntime<ManagedRelayClient>,311 runtime: Atom.AtomRuntime<ManagedRelay.ManagedRelayClient>,
312 options?: {312 options?: {
313 readonly staleTimeMs?: number;313 readonly staleTimeMs?: number;
314 readonly idleTtlMs?: number;314 readonly idleTtlMs?: number;
...@@ -351,7 +351,7 @@ export function createManagedRelayQueryManager(...@@ -351,7 +351,7 @@ export function createManagedRelayQueryManager(
351 { ...base, stage: "clerk-token" },351 { ...base, stage: "clerk-token" },
352 requireClerkToken(get, accountId),352 requireClerkToken(get, accountId),
353 );353 );
354 const relay = yield* ManagedRelayClient;354 const relay = yield* ManagedRelay.ManagedRelayClient;
355 return yield* observe(355 return yield* observe(
356 { ...base, stage: "relay-request" },356 { ...base, stage: "relay-request" },
357 relay.listEnvironments({ clerkToken }),357 relay.listEnvironments({ clerkToken }),
...@@ -374,7 +374,7 @@ export function createManagedRelayQueryManager(...@@ -374,7 +374,7 @@ export function createManagedRelayQueryManager(
374 { ...base, stage: "clerk-token" },374 { ...base, stage: "clerk-token" },
375 requireClerkToken(get, accountId),375 requireClerkToken(get, accountId),
376 );376 );
377 const relay = yield* ManagedRelayClient;377 const relay = yield* ManagedRelay.ManagedRelayClient;
378 return yield* observe(378 return yield* observe(
379 { ...base, stage: "relay-request" },379 { ...base, stage: "relay-request" },
380 relay.listDevices({ clerkToken }),380 relay.listDevices({ clerkToken }),
...@@ -402,7 +402,7 @@ export function createManagedRelayQueryManager(...@@ -402,7 +402,7 @@ export function createManagedRelayQueryManager(
402 { ...base, stage: "clerk-token" },402 { ...base, stage: "clerk-token" },
403 requireClerkToken(get, accountId),403 requireClerkToken(get, accountId),
404 );404 );
405 const relay = yield* ManagedRelayClient;405 const relay = yield* ManagedRelay.ManagedRelayClient;
406 const status = yield* observe(406 const status = yield* observe(
407 { ...base, stage: "relay-request" },407 { ...base, stage: "relay-request" },
408 relay.getEnvironmentStatus({408 relay.getEnvironmentStatus({
packages/client-runtime/src/state/relayDiscovery.ts+8-9
...@@ -4,34 +4,33 @@ import * as Stream from "effect/Stream";...@@ -4,34 +4,33 @@ import * as Stream from "effect/Stream";
4import * as SubscriptionRef from "effect/SubscriptionRef";4import * as SubscriptionRef from "effect/SubscriptionRef";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";5import { AsyncResult, Atom } from "effect/unstable/reactivity";
66
7import {7import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
8 EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
9 RelayEnvironmentDiscovery,
10} from "../relay/discovery.ts";
11import { createRuntimeCommand } from "./runtime.ts";8import { createRuntimeCommand } from "./runtime.ts";
129
13export function createRelayEnvironmentDiscoveryAtoms<R, E>(10export function createRelayEnvironmentDiscoveryAtoms<R, E>(
14 runtime: Atom.AtomRuntime<RelayEnvironmentDiscovery | R, E>,11 runtime: Atom.AtomRuntime<RelayEnvironmentDiscovery.RelayEnvironmentDiscovery | R, E>,
15) {12) {
16 const stateAtom = runtime.atom(13 const stateAtom = runtime.atom(
17 Stream.unwrap(14 Stream.unwrap(
18 RelayEnvironmentDiscovery.pipe(15 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe(
19 Effect.map((discovery) => SubscriptionRef.changes(discovery.state)),16 Effect.map((discovery) => SubscriptionRef.changes(discovery.state)),
20 ),17 ),
21 ),18 ),
22 { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE },19 { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE },
23 );20 );
24 const stateValueAtom = Atom.make((get) =>21 const stateValueAtom = Atom.make((get) =>
25 Option.getOrElse(22 Option.getOrElse(
26 AsyncResult.value(get(stateAtom)),23 AsyncResult.value(get(stateAtom)),
27 () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,24 () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
28 ),25 ),
29 ).pipe(Atom.withLabel("relay-environment-discovery-value"));26 ).pipe(Atom.withLabel("relay-environment-discovery-value"));
30 const refresh = createRuntimeCommand(runtime, {27 const refresh = createRuntimeCommand(runtime, {
31 label: "relay-environment-discovery:refresh",28 label: "relay-environment-discovery:refresh",
32 concurrency: { mode: "singleFlight", key: () => "refresh" },29 concurrency: { mode: "singleFlight", key: () => "refresh" },
33 execute: (_input: void) =>30 execute: (_input: void) =>
34 RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)),31 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe(
32 Effect.flatMap((discovery) => discovery.refresh),
33 ),
35 });34 });
3635
37 return {36 return {