From 8809759a2d9f29b968da99ddb4455d96551ae494 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:57:36 -0700 Subject: [PATCH 1/4] Refactor client runtime Effect services Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 4 +- .../liveActivityPreferences.ts | 4 +- .../remoteRegistration.test.ts | 4 +- .../agent-awareness/remoteRegistration.ts | 30 +- .../src/features/cloud/CloudAuthProvider.tsx | 4 +- .../features/cloud/linkEnvironment.test.ts | 18 +- .../src/features/cloud/linkEnvironment.ts | 42 +- .../src/features/cloud/managedRelayLayer.ts | 54 +- .../features/cloud/managedRelayTokenStore.ts | 13 +- apps/mobile/src/lib/runtime.ts | 22 +- apps/mobile/src/state/relay.ts | 2 +- apps/web/src/cloud/linkEnvironment.test.ts | 18 +- apps/web/src/cloud/linkEnvironment.ts | 30 +- apps/web/src/cloud/managedAuth.tsx | 6 +- apps/web/src/cloud/managedRelayLayer.ts | 54 +- apps/web/src/cloud/managedRelayState.ts | 8 +- apps/web/src/lib/runtime.ts | 22 +- apps/web/src/state/environments.ts | 3 +- apps/web/src/state/relay.ts | 2 +- .../src/authorization/layer.test.ts | 1 - .../src/connection/errors.test.ts | 61 + .../client-runtime/src/connection/errors.ts | 46 +- .../src/connection/resolver.test.ts | 8 +- .../src/relay/discovery.test.ts | 15 +- packages/client-runtime/src/relay/index.ts | 4 +- .../src/relay/managedRelay.test.ts | 68 +- .../client-runtime/src/relay/managedRelay.ts | 1192 +++++++++-------- .../src/relay/managedRelayState.test.ts | 19 +- .../src/relay/managedRelayState.ts | 10 +- .../src/state/relayDiscovery.ts | 17 +- 30 files changed, 1012 insertions(+), 769 deletions(-) create mode 100644 packages/client-runtime/src/connection/errors.test.ts diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index ec50e4ae9ce..5de14ea76fc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -2,7 +2,7 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; @@ -35,7 +35,7 @@ const connection: SavedRemoteConnection = { }; const testLayer = Layer.mergeAll( - Layer.succeed(ManagedRelayClient, null as never), + Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index a522129d40d..8f73ffdf65e 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; @@ -11,7 +11,7 @@ export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; readonly connections: ReadonlyArray; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { yield* Effect.tryPromise({ try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 257b914fe97..43d62b81622 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -9,7 +9,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; -import { type ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; @@ -158,7 +158,7 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO } idlePasses = 0; const exit = yield* Effect.exit( - pending.operation as Effect.Effect, + pending.operation as Effect.Effect, ); yield* Effect.sync(() => { pending.resolve(exit); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 24e6a094661..98e38c74055 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -9,7 +9,7 @@ import { type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, settleAsyncResult, @@ -175,7 +175,7 @@ const relayToken = Effect.gen(function* () { function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, expectedGeneration: number, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled before relay request", { @@ -198,7 +198,7 @@ function registerDeviceWithRelay( return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; logRegistrationDebug("relay device registration request started", { expectedGeneration, }); @@ -215,7 +215,7 @@ function registerDeviceWithRelay( function unregisterDeviceWithRelay(input: { readonly deviceId: string; readonly tokenProvider: () => Promise; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ @@ -227,7 +227,7 @@ function unregisterDeviceWithRelay(input: { return; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.unregisterDevice({ clerkToken: token, deviceId: input.deviceId, @@ -237,7 +237,7 @@ function unregisterDeviceWithRelay(input: { function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; const token = yield* relayToken; @@ -246,7 +246,7 @@ function registerLiveActivityWithRelay( return false; } - const client = yield* ManagedRelayClient; + const client = yield* ManagedRelay.ManagedRelayClient; yield* client.registerLiveActivity({ clerkToken: token, payload: body, @@ -274,7 +274,7 @@ function logRegistrationDebug(context: string, details?: unknown): void { } function runRegistrationInBackground( - operation: Effect.Effect, + operation: Effect.Effect, context: string, ): void { void (async () => { @@ -370,7 +370,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri function registerDevice( input: DeviceRegistrationInput = {}, expectedGeneration = deviceRegistrationGeneration, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { logRegistrationDebug("device registration skipped; platform does not support it"); @@ -411,7 +411,7 @@ function registerDevice( function registerDeviceForCurrentUser( pushToStartToken?: string, -): Effect.Effect { +): Effect.Effect { return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); } @@ -485,7 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void { export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return registerDeviceForCurrentUser().pipe( Effect.catch((error) => @@ -515,7 +515,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { export function unregisterAgentAwarenessDeviceForCurrentUser( tokenProvider: () => Promise, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), @@ -536,7 +536,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( export function registerLiveActivityPushToken(input: { readonly activity: LiveActivity; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { return false; @@ -588,7 +588,7 @@ export function registerLiveActivityPushToken(input: { function registerLiveActivityPushTokenValue(input: { readonly activityPushToken: string; -}): Effect.Effect { +}): Effect.Effect { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), @@ -624,7 +624,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void { export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< void, never, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index b8349fc60d3..c89aeb9249a 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,6 +1,6 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { reportAtomCommandResult, settleAsyncResult, @@ -22,7 +22,7 @@ import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publi function resetManagedRelayTokenCache() { return settleAsyncResult(() => runtime.runPromiseExit( - ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), ), ); } diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index aa1071fd3c2..b9ab3aeab05 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,11 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; @@ -62,8 +58,8 @@ const createProofMock = vi.fn( Effect.succeed(`dpop:${input.method}:${input.url}`), ); const testDpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("client-proof-key-thumbprint"), createProof: (input) => createProofMock(input), }), @@ -73,7 +69,7 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, - managedRelayClientLayer({ + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), @@ -81,7 +77,11 @@ function cloudClientLayer() { } const withCloudServices = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + >, ) => effect.pipe(Effect.provide(cloudClientLayer())); function validLinkProof() { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 680e6e80cfa..a77ca628978 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -25,11 +25,7 @@ import { import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - ManagedRelayClient, - type ManagedRelayClientError, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -156,13 +152,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { } function decodedRelayClientError(message: string) { - return (cause: ManagedRelayClientError) => { - const relayError = cause.relayError; + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, - ...(cause.traceId ? { traceId: cause.traceId } : {}), + ...(traceId ? { traceId } : {}), }); }; } @@ -261,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: { export function linkEnvironmentToCloud(input: { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -270,7 +272,7 @@ export function linkEnvironmentToCloud(input: { } const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), @@ -353,11 +355,11 @@ export function listCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ @@ -374,11 +376,11 @@ export function getCloudEnvironmentStatus(input: { }): Effect.Effect< RelayEnvironmentStatusResponseType, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const status = yield* relayClient .getEnvironmentStatus({ clerkToken: input.clerkToken, @@ -413,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.forEach( input.environments, @@ -445,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const environments = yield* listCloudEnvironments(input); @@ -473,7 +475,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag }) { yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient @@ -514,7 +516,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag message: "Connected endpoint descriptor does not match the selected environment.", }); } - const signer = yield* ManagedRelayDpopSigner; + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; const bootstrapDpop = yield* signer .createProof({ method: "POST", @@ -555,7 +557,7 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -570,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 6678d13047e..6a53bfe53a8 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer as makeManagedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -12,34 +8,54 @@ import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; const relayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const loadProofKey = yield* Effect.cached( loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), ); - return ManagedRelayDpopSigner.of({ + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadProofKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + operation: "load-or-create", + cause: error, + }), + ), Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")( - function* (input) { - const proofKey = yield* loadProofKey; - return yield* createDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }, - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), - ), + createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadProofKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); export const managedRelayClientLayer = (relayUrl: string) => - makeManagedRelayClientLayer({ + ManagedRelay.layer({ relayUrl, clientId: RelayMobileClientId, accessTokenStore: managedRelayAccessTokenStore, diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts index 54153a426a1..460c71c1fa7 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -1,7 +1,4 @@ -import { - type ManagedRelayAccessTokenCacheEntry, - type ManagedRelayAccessTokenStore, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -60,7 +57,7 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({ }).pipe( Effect.flatMap((encoded) => encoded === null - ? Effect.succeed>([]) + ? Effect.succeed>([]) : decodeManagedRelayAccessTokenCache(encoded).pipe( Effect.map((cache) => cache.entries), Effect.mapError(storeError("Persisted relay access tokens are invalid.")), @@ -68,7 +65,9 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({ ), ); -const saveManagedRelayAccessTokens = (entries: ReadonlyArray) => +const saveManagedRelayAccessTokens = ( + entries: ReadonlyArray, +) => encodeManagedRelayAccessTokenCache({ version: MANAGED_RELAY_TOKEN_CACHE_VERSION, entries, @@ -87,7 +86,7 @@ const clearManagedRelayAccessTokens = Effect.tryPromise({ catch: storeError("Could not clear persisted relay access tokens."), }); -export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = { +export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: loadManagedRelayAccessTokens.pipe( Effect.tapError(logStoreFailure("load")), Effect.orElseSucceed(() => []), diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index bb8c1e8398a..f760bef3459 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -15,7 +15,17 @@ function configuredRelayUrl(): string { const httpClientLayer = remoteHttpClientLayer(fetch); -export const runtimeLayer = Layer.merge( +type RuntimeLayerSource = + | ReturnType + | typeof Socket.layerWebSocketConstructorGlobal + | typeof cryptoLayer + | typeof httpClientLayer + | typeof tracingLayer; + +export const runtimeLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.merge( managedRelayClientLayer(configuredRelayUrl()), Socket.layerWebSocketConstructorGlobal, ).pipe( @@ -24,6 +34,12 @@ export const runtimeLayer = Layer.merge( Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), ); -export const runtime = ManagedRuntime.make(runtimeLayer); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); -export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts index f078572736b..3cbac7a1875 100644 --- a/apps/mobile/src/state/relay.ts +++ b/apps/mobile/src/state/relay.ts @@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st import { connectionAtomRuntime } from "../connection/runtime"; -export const relayEnvironmentDiscovery = +export const relayEnvironmentDiscovery: ReturnType = createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 51251975557..f823016ddf0 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -21,11 +21,7 @@ import { } from "@t3tools/client-runtime/connection"; import { type RpcSession } from "@t3tools/client-runtime/rpc"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; -import { - managedRelayClientLayer, - ManagedRelayClient, - ManagedRelayDpopSigner, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; @@ -60,8 +56,8 @@ vi.mock("./relayClientInstallDialog", () => ({ const createProof = vi.fn(() => Effect.succeed("dpop-proof")); const dpopSignerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("thumbprint"), createProof, }), @@ -71,7 +67,7 @@ function relayLayer() { const http = remoteHttpClientLayer(globalThis.fetch); return Layer.mergeAll( http, - managedRelayClientLayer({ + ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayWebClientId, }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)), @@ -129,7 +125,11 @@ function services(options?: Parameters[0]) { } function withServices( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry + >, options?: Parameters[0], ) { return effect.pipe(Effect.provide(services(options))); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a8f410acdfa..20bf75c7d6d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -25,7 +25,7 @@ import { import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { readPrimaryEnvironmentDescriptor, @@ -164,13 +164,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { } function decodedRelayClientError(message: string) { - return (cause: ManagedRelayClientError) => { - const relayError = cause.relayError; + return (cause: ManagedRelay.ManagedRelayClientError) => { + const relayError = + cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; + const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, - ...(cause.traceId ? { traceId: cause.traceId } : {}), + ...(traceId ? { traceId } : {}), }); }; } @@ -268,7 +270,7 @@ export function listManagedCloudEnvironments(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -277,7 +279,7 @@ export function listManagedCloudEnvironments(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient .listEnvironments({ clerkToken: input.clerkToken, @@ -299,7 +301,7 @@ export function listCloudDevices(input: { }): Effect.Effect< ReadonlyArray, CloudEnvironmentLinkError, - ManagedRelayClient + ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { if (!relayUrl()) { @@ -307,7 +309,7 @@ export function listCloudDevices(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( Effect.mapError( (cause) => @@ -351,7 +353,11 @@ export function updatePrimaryCloudPreferences(input: { export function unlinkPrimaryEnvironmentFromCloud(input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient +> { return Effect.gen(function* () { const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* client.connect @@ -360,7 +366,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, @@ -383,7 +389,7 @@ export function linkPrimaryEnvironmentToCloud(input: { }): Effect.Effect< void, CloudEnvironmentLinkError, - EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient > { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); @@ -392,7 +398,7 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "T3CODE_RELAY_URL is not configured.", }); } - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index a708f6df0e7..2f631214501 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/react"; -import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { reportAtomCommandResult, settleAsyncResult, @@ -64,7 +64,9 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac removeRelayEnvironments(), settleAsyncResult(() => runtime.runPromiseExit( - ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ManagedRelay.ManagedRelayClient.pipe( + Effect.flatMap((client) => client.resetTokenCache), + ), ), ), ]); diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 53a3e24c6d8..25cbfb9a763 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,4 @@ -import { - managedRelayClientLayer as makeManagedRelayClientLayer, - ManagedRelayDpopSigner, - ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -18,7 +14,7 @@ import { } from "./dpop"; export const relayDpopSignerLayer = Layer.effect( - ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const keyLoadSemaphore = yield* Semaphore.make(1); @@ -40,27 +36,47 @@ export const relayDpopSignerLayer = Layer.effect( }), ); - return ManagedRelayDpopSigner.of({ + return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + operation: "load-or-create", + cause: error, + }), + ), Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), ), - createProof: Effect.fn("web.managedRelayDpopSigner.createProof")( - function* (input) { - const proofKey = yield* loadOrCreateBrowserDpopKey; - return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - ); - }, - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), - ), + createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) { + const proofKey = yield* loadOrCreateBrowserDpopKey.pipe( + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + Effect.mapError( + (error) => + new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: input.method, + url: input.url, + cause: error, + }), + ), + ); + }), }); }), ); export const managedRelayClientLayer = (relayUrl: string) => - makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( + ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe( Layer.provideMerge(relayDpopSignerLayer), ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 0a1ec61a3cc..5f29c121dbc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, - ManagedRelayClient, + ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; @@ -20,8 +20,10 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; const managedRelayAtomRuntime = Atom.runtime( Layer.effect( - ManagedRelayClient, - runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))), + ManagedRelay.ManagedRelayClient, + runtime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)), + ), ), ); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index e4bea61f143..a4d87a7ae01 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -24,6 +24,13 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig( client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(httpClientLayer)); +type RuntimeLayerSource = + | typeof httpClientLayer + | typeof browserCryptoLayer + | typeof Socket.layerWebSocketConstructorGlobal + | typeof relayTracingLayer + | ReturnType; + export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( @@ -47,7 +54,10 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const runtimeLayer = Layer.mergeAll( +export const runtimeLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, @@ -57,6 +67,12 @@ export const runtimeLayer = Layer.mergeAll( ), ); -export const runtime = ManagedRuntime.make(runtimeLayer); +export const runtime: ManagedRuntime.ManagedRuntime< + Layer.Success, + Layer.Error +> = ManagedRuntime.make(runtimeLayer); -export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); +export const runtimeContextLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Layer.effectContext(runtime.contextEffect); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 211c981c5f6..443e99b84cd 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -3,6 +3,7 @@ import { connectionCatalogDisplayUrl, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; +import { Discovery } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { useMemo } from "react"; @@ -81,7 +82,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; } -export function useRelayEnvironmentDiscovery() { +export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } diff --git a/apps/web/src/state/relay.ts b/apps/web/src/state/relay.ts index f078572736b..3cbac7a1875 100644 --- a/apps/web/src/state/relay.ts +++ b/apps/web/src/state/relay.ts @@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st import { connectionAtomRuntime } from "../connection/runtime"; -export const relayEnvironmentDiscovery = +export const relayEnvironmentDiscovery: ReturnType = createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index d950c241d50..1d2c6c6cca7 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -118,7 +118,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( createProof: (proofInput) => Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( Effect.as(`proof:${proofInput.url}`), - Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })), ), }); const layer = RemoteEnvironmentAuthorization.layer.pipe( diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts new file mode 100644 index 00000000000..8fc06aac312 --- /dev/null +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -0,0 +1,61 @@ +import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; + +import * as ManagedRelay from "../relay/managedRelay.ts"; +import { mapManagedRelayError } from "./errors.ts"; +import { ConnectionBlockedError } from "./model.ts"; + +function proofCreationError(): ManagedRelay.ManagedRelayDpopProofCreationError { + return new ManagedRelay.ManagedRelayDpopProofCreationError({ + method: "POST", + url: "https://relay.example.test/v1/client/dpop-token", + cause: new Error("Proof creation failed."), + }); +} + +describe("connection error mapping", () => { + it("blocks invalid relay configuration", () => { + const error = mapManagedRelayError( + new ManagedRelay.ManagedRelayUrlInvalidError({ + relayUrl: "http://relay.example.test", + }), + ); + + expect(error).toBeInstanceOf(ConnectionBlockedError); + expect(error).toMatchObject({ reason: "configuration" }); + }); + + it("blocks relay credentials with unexpected scopes", () => { + const error = mapManagedRelayError( + new ManagedRelay.ManagedRelayAccessTokenScopesUnexpectedError({ + requestedScopes: [RelayEnvironmentStatusScope], + grantedScope: "unexpected:scope", + }), + ); + + expect(error).toBeInstanceOf(ConnectionBlockedError); + expect(error).toMatchObject({ reason: "permission" }); + }); + + it.each([ + new ManagedRelay.ManagedRelayDpopKeyLoadError({ + operation: "load-or-create", + cause: new Error("Key load failed."), + }), + new ManagedRelay.ManagedRelayTokenProofCreationError({ + method: "POST", + url: "https://relay.example.test/v1/client/dpop-token", + cause: proofCreationError(), + }), + new ManagedRelay.ManagedRelayRequestProofCreationError({ + method: "POST", + url: "https://relay.example.test/v1/client/environments/environment-1/connect", + cause: proofCreationError(), + }), + ])("blocks relay authentication failures ($._tag)", (relayError) => { + const error = mapManagedRelayError(relayError); + + expect(error).toBeInstanceOf(ConnectionBlockedError); + expect(error).toMatchObject({ reason: "authentication" }); + }); +}); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index 5d9d361c06d..f70e41adfe7 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -74,21 +74,39 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError } export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { - if (error.relayError) { - return relayProtectedError(error.relayError); - } - if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { - return new ConnectionTransientError({ - reason: "timeout", - detail: error.message, - ...(error.traceId ? { traceId: error.traceId } : {}), - }); + switch (error._tag) { + case "ManagedRelayRequestFailedError": + if (error.relayError) { + return relayProtectedError(error.relayError); + } + return new ConnectionTransientError({ + reason: "relay-unavailable", + detail: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); + case "ManagedRelayRequestTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + detail: error.message, + }); + case "ManagedRelayUrlInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + detail: error.message, + }); + case "ManagedRelayAccessTokenScopesUnexpectedError": + return new ConnectionBlockedError({ + reason: "permission", + detail: error.message, + }); + case "ManagedRelayDpopKeyLoadError": + case "ManagedRelayTokenProofCreationError": + case "ManagedRelayRequestProofCreationError": + return new ConnectionBlockedError({ + reason: "authentication", + detail: error.message, + }); } - return new ConnectionTransientError({ - reason: "relay-unavailable", - detail: error.message, - ...(error.traceId ? { traceId: error.traceId } : {}), - }); } export function mapRemoteEnvironmentError( diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 7d165b22ea2..c235e788721 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -446,11 +446,9 @@ describe("ConnectionResolver", () => { const brokerLayer = yield* makeDependencies({ connectEnvironment: () => Effect.fail( - new ManagedRelay.ManagedRelayClientError({ - message: "Relay timed out.", - cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ - message: "Relay timed out.", - }), + new ManagedRelay.ManagedRelayRequestTimeoutError({ + request: "connect-environment", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), }); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index e05302195db..d8f37ee3543 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -258,11 +258,9 @@ describe("RelayEnvironmentDiscovery", () => { relayUrl: "https://relay.example.test", listEnvironments: () => Effect.fail( - new ManagedRelay.ManagedRelayClientError({ - message: "Relay environment listing timed out.", - cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ - message: "Relay environment listing timed out.", - }), + new ManagedRelay.ManagedRelayRequestTimeoutError({ + request: "list-environments", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), getEnvironmentStatus: () => Effect.die("unused"), @@ -304,7 +302,7 @@ describe("RelayEnvironmentDiscovery", () => { expect(Option.getOrThrow(state.error)).toMatchObject({ _tag: "ConnectionTransientError", reason: "timeout", - message: "Relay environment listing timed out.", + message: `Managed relay request 'list-environments' timed out after ${ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS}ms.`, }); }).pipe(Effect.provide(layer)); }), @@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, - new ManagedRelay.ManagedRelayClientError({ - message: "Relay environment listing failed.", + new ManagedRelay.ManagedRelayRequestFailedError({ + request: "list-environments", + cause: new Error("Relay request failed."), }), ); yield* discovery.refresh; diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts index 8e76367c601..76f75535304 100644 --- a/packages/client-runtime/src/relay/index.ts +++ b/packages/client-runtime/src/relay/index.ts @@ -1,3 +1,3 @@ -export * from "./discovery.ts"; -export * from "./managedRelay.ts"; +export * as Discovery from "./discovery.ts"; +export * as ManagedRelay from "./managedRelay.ts"; export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 9c08c374bcd..bedb9d49dcd 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -8,31 +8,24 @@ import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; -import { - MANAGED_RELAY_REQUEST_TIMEOUT_MS, - ManagedRelayClient, - ManagedRelayDpopSigner, - managedRelayClientLayer, - type ManagedRelayAccessTokenCacheEntry, - type ManagedRelayAccessTokenStore, - type ManagedRelayDpopProofInput, -} from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; function managedRelayTestLayer( fetchFn: typeof globalThis.fetch, relayUrl = "https://relay.example.test", - accessTokenStore?: ManagedRelayAccessTokenStore, + accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore, ) { const httpClientLayer = remoteHttpClientLayer(fetchFn); const signerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ + ManagedRelay.ManagedRelayDpopSigner, + ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("client-thumbprint"), - createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), + createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) => + Effect.succeed(`proof:${input.url}`), }), ); - return managedRelayClientLayer({ + return ManagedRelay.layer({ relayUrl, clientId: "t3-mobile", ...(accessTokenStore ? { accessTokenStore } : {}), @@ -90,7 +83,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus({ clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -124,14 +117,15 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const error = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay URL must be a secure absolute HTTPS origin.", + _tag: "ManagedRelayUrlInvalidError", + relayUrl: "http://relay.example.test", + message: "Relay URL 'http://relay.example.test' must be a secure absolute HTTPS origin.", }); expect(requestCount).toBe(0); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); @@ -175,7 +169,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const statusInput = { clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -198,8 +192,8 @@ describe("ManagedRelayClient", () => { it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { let tokenExchangeCount = 0; - let persistedTokens: ReadonlyArray = []; - const accessTokenStore: ManagedRelayAccessTokenStore = { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.sync(() => persistedTokens), save: (entries) => Effect.sync(() => { @@ -252,7 +246,7 @@ describe("ManagedRelayClient", () => { return Effect.gen(function* () { yield* Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); @@ -260,7 +254,7 @@ describe("ManagedRelayClient", () => { expect(persistedTokens).toHaveLength(1); yield* Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); @@ -271,7 +265,7 @@ describe("ManagedRelayClient", () => { it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => { let tokenExchangeCount = 0; const statusTokens: Array = []; - let persistedTokens: ReadonlyArray = [ + let persistedTokens: ReadonlyArray = [ { accountId: "user-1", clientId: "t3-mobile", @@ -282,7 +276,7 @@ describe("ManagedRelayClient", () => { expiresAtMillis: Number.MAX_SAFE_INTEGER, }, ]; - const accessTokenStore: ManagedRelayAccessTokenStore = { + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.sync(() => persistedTokens), save: (entries) => Effect.sync(() => { @@ -344,7 +338,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const result = yield* relayClient.getEnvironmentStatus({ clerkToken: clerkToken("user-1", "session-1"), scopes: [RelayEnvironmentStatusScope], @@ -363,8 +357,8 @@ describe("ManagedRelayClient", () => { }); it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { - let persistedTokens: ReadonlyArray = []; - const accessTokenStore: ManagedRelayAccessTokenStore = { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = { load: Effect.succeed([]), save: (entries) => Effect.sync(() => { @@ -407,7 +401,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; yield* relayClient.getEnvironmentStatus({ clerkToken: "not-a-jwt", scopes: [RelayEnvironmentStatusScope], @@ -423,18 +417,20 @@ describe("ManagedRelayClient", () => { new Promise(() => undefined)) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const errorFiber = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip, Effect.forkScoped); yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + yield* TestClock.adjust(Duration.millis(ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS)); const error = yield* Fiber.join(errorFiber); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay environment listing timed out.", + _tag: "ManagedRelayRequestTimeoutError", + request: "list-environments", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + message: `Managed relay request 'list-environments' timed out after ${ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS}ms.`, }); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); }); @@ -454,13 +450,13 @@ describe("ManagedRelayClient", () => { )) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const error = yield* relayClient .listEnvironments({ clerkToken: "clerk-token" }) .pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", + _tag: "ManagedRelayRequestFailedError", traceId: "trace-managed-relay", }); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); @@ -499,7 +495,7 @@ describe("ManagedRelayClient", () => { }) satisfies typeof globalThis.fetch; return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; + const relayClient = yield* ManagedRelay.ManagedRelayClient; const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); expect(devices).toMatchObject([ { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 97484fe7d26..2c05d096a2b 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -5,7 +5,7 @@ import { type RelayClientDeviceRecord, RelayConnectEnvironmentEndpoint, type RelayDeviceRegistrationRequest, - type RelayDpopAccessTokenScope, + RelayDpopAccessTokenScope, RelayDpopTokenExchangeGrantType, type RelayEnvironmentConnectRequest, type RelayEnvironmentConnectResponse, @@ -33,58 +33,169 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import { HttpClientError } from "effect/unstable/http"; -import type { HttpMethod } from "effect/unstable/http/HttpMethod"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpMethod from "effect/unstable/http/HttpMethod"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; export interface ManagedRelayDpopProofInput { - readonly method: HttpMethod; + readonly method: HttpMethod.HttpMethod; readonly url: string; readonly accessToken?: string; } -export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ - readonly cause: unknown; -}> {} +export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopKeyLoadError", + { + operation: Schema.Literal("load-or-create"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not ${this.operation.replaceAll("-", " ")} the relay DPoP proof key.`; + } +} + +export class ManagedRelayDpopProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayDpopProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`; + } +} -export class ManagedRelayRequestTimeoutError extends Data.TaggedError( +export const ManagedRelayDpopSignerError = Schema.Union([ + ManagedRelayDpopKeyLoadError, + ManagedRelayDpopProofCreationError, +]); +export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type; + +export const ManagedRelayRequest = Schema.Literals([ + "exchange-access-token", + "list-environments", + "list-devices", + "create-environment-link-challenge", + "link-environment", + "unlink-environment", + "get-environment-status", + "connect-environment", + "register-device", + "unregister-device", + "register-live-activity", +]); +export type ManagedRelayRequest = typeof ManagedRelayRequest.Type; + +export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass()( "ManagedRelayRequestTimeoutError", -)<{ - readonly message: string; -}> {} + { + request: ManagedRelayRequest, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Managed relay request '${this.request}' timed out after ${this.timeoutMs}ms.`; + } +} + +export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass()( + "ManagedRelayUrlInvalidError", + { + relayUrl: Schema.String, + }, +) { + override get message(): string { + return `Relay URL '${this.relayUrl}' must be a secure absolute HTTPS origin.`; + } +} + +export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestFailedError", + { + request: ManagedRelayRequest, + cause: Schema.Defect(), + relayError: Schema.optional(RelayProtectedError), + traceId: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Managed relay request '${this.request}' failed.`; + } +} + +export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass()( + "ManagedRelayAccessTokenScopesUnexpectedError", + { + requestedScopes: Schema.Array(RelayDpopAccessTokenScope), + grantedScope: Schema.String, + }, +) { + override get message(): string { + return `Relay granted unexpected DPoP access token scopes: requested '${encodeOAuthScope(this.requestedScopes)}', received '${this.grantedScope}'.`; + } +} + +export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayTokenProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not create relay token DPoP proof for ${this.method} ${this.url}.`; + } +} + +export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestProofCreationError", + { + method: Schema.String, + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not create relay request DPoP proof for ${this.method} ${this.url}.`; + } +} + +export const ManagedRelayClientError = Schema.Union([ + ManagedRelayUrlInvalidError, + ManagedRelayRequestFailedError, + ManagedRelayRequestTimeoutError, + ManagedRelayAccessTokenScopesUnexpectedError, + ManagedRelayDpopKeyLoadError, + ManagedRelayTokenProofCreationError, + ManagedRelayRequestProofCreationError, +]); +export type ManagedRelayClientError = typeof ManagedRelayClientError.Type; type RelayHttpRequestError = | RelayProtectedErrorType | HttpClientError.HttpClientError - | Schema.SchemaError - | ManagedRelayRequestTimeoutError; - -export interface ManagedRelayDpopSignerShape { - readonly thumbprint: Effect.Effect; - readonly createProof: ( - input: ManagedRelayDpopProofInput, - ) => Effect.Effect; -} + | Schema.SchemaError; export class ManagedRelayDpopSigner extends Context.Service< ManagedRelayDpopSigner, - ManagedRelayDpopSignerShape + { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; + } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} -export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ - readonly message: string; - readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError; - readonly relayError?: RelayProtectedErrorType; - readonly traceId?: string; -}> {} - export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; export interface ManagedRelayAccessTokenCacheEntry { @@ -115,100 +226,96 @@ export interface ManagedRelayClientLayerOptions { readonly accessTokenStore?: ManagedRelayAccessTokenStore; } -export interface ManagedRelayClientShape { - readonly relayUrl: string; - readonly listEnvironments: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly listDevices: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly createEnvironmentLinkChallenge: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkChallengeRequest; - }) => Effect.Effect; - readonly linkEnvironment: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkRequest; - }) => Effect.Effect; - readonly unlinkEnvironment: (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly getEnvironmentStatus: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly connectEnvironment: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly deviceId?: string; - }) => Effect.Effect; - readonly registerDevice: (input: { - readonly clerkToken: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregisterDevice: (input: { - readonly clerkToken: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly registerLiveActivity: (input: { - readonly clerkToken: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly resetTokenCache: Effect.Effect; -} - export class ManagedRelayClient extends Context.Service< ManagedRelayClient, - ManagedRelayClientShape + { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; + } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} const isRelayProtectedError = Schema.is(RelayProtectedError); -function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError { - return new ManagedRelayClientError({ - message, - ...(cause === undefined ? {} : { cause }), - }); -} - -function relayLocalError( - message: string, - cause: ManagedRelayDpopSignerError, -): ManagedRelayClientError { - return new ManagedRelayClientError({ message, cause }); -} - -function relayRequestError(message: string) { +function relayRequestError(request: ManagedRelayRequest) { return (cause: RelayHttpRequestError): ManagedRelayClientError => - new ManagedRelayClientError({ - message, + new ManagedRelayRequestFailedError({ + request, cause, ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), }); } +function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) { + return { + method: error.method, + url: error.url, + cause: error, + }; +} + function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { return ( + error._tag === "ManagedRelayRequestFailedError" && error.relayError?._tag === "RelayAuthInvalidError" && error.relayError.reason === "invalid_bearer" ); } -function timeoutRelayRequest(message: string) { +function timeoutRelayRequest(request: ManagedRelayRequest) { return ( - request: Effect.Effect, + effect: Effect.Effect, ): Effect.Effect => - request.pipe( + effect.pipe( Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), Effect.flatMap( Option.match({ onNone: () => Effect.fail( - relayClientError(message, new ManagedRelayRequestTimeoutError({ message })), + new ManagedRelayRequestTimeoutError({ + request, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), ), onSome: Effect.succeed, }), @@ -258,10 +365,10 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { }; } -function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] { const unavailable = (spanName: string) => Effect.fn(spanName)(function* () { - return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin."); + return yield* new ManagedRelayUrlInvalidError({ relayUrl }); }); return ManagedRelayClient.of({ relayUrl, @@ -283,482 +390,475 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { }); } -export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { - return Layer.effect( - ManagedRelayClient, - Effect.gen(function* () { - const relayUrl = normalizeSecureRelayUrl(options.relayUrl); - if (relayUrl === null) { - return disabledManagedRelayClient(options.relayUrl); - } - const signer = yield* ManagedRelayDpopSigner; - const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); - const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; - const cachedTokens = yield* SynchronizedRef.make< - ReadonlyArray - >(initialTokens.filter((token) => token.clientId === options.clientId)); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); - - type DpopProofTarget = Pick; - const dpopProofTargets = { - exchangeAccessToken: (): DpopProofTarget => ({ - method: RelayExchangeDpopAccessTokenEndpoint.method, - url: urlBuilder.token.exchangeDpopAccessToken(), - }), - getEnvironmentStatus: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayGetEnvironmentStatusEndpoint.method, - url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), - }), - connectEnvironment: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayConnectEnvironmentEndpoint.method, - url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), - }), - registerDevice: (): DpopProofTarget => ({ - method: RelayRegisterDeviceEndpoint.method, - url: urlBuilder.mobile.registerDevice(), - }), - unregisterDevice: (deviceId: string): DpopProofTarget => ({ - method: RelayUnregisterDeviceEndpoint.method, - url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), - }), - registerLiveActivity: (): DpopProofTarget => ({ - method: RelayRegisterLiveActivityEndpoint.method, - url: urlBuilder.mobile.registerLiveActivity(), - }), - }; +export const make = Effect.fn("ManagedRelayClient.make")(function* ( + options: ManagedRelayClientLayerOptions, +) { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; + const cachedTokens = yield* SynchronizedRef.make< + ReadonlyArray + >(initialTokens.filter((token) => token.clientId === options.clientId)); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; - const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - }); - const proof = yield* signer - .createProof(dpopProofTargets.exchangeAccessToken()) - .pipe( - Effect.mapError((cause) => - relayLocalError("Could not create relay token DPoP proof.", cause), - ), - ); - const response = yield* client.token - .exchangeDpopAccessToken({ - headers: { dpop: proof }, - payload: { - grant_type: RelayDpopTokenExchangeGrantType, - subject_token: input.clerkToken, - subject_token_type: RelayJwtSubjectTokenType, - requested_token_type: RelayAccessTokenType, - resource: relayUrl, - scope: encodeOAuthScope(input.scopes), - client_id: options.clientId, - }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")), - timeoutRelayRequest("Relay DPoP access token exchange timed out."), - ); - if (!oauthScopeSetEquals(response.scope, input.scopes)) { - return yield* relayClientError("Relay granted unexpected DPoP access token scopes."); - } - return response; - }, - ); + const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError( + (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError(relayRequestError("exchange-access-token")), + timeoutRelayRequest("exchange-access-token"), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* new ManagedRelayAccessTokenScopesUnexpectedError({ + requestedScopes: input.scopes, + grantedScope: response.scope, + }); + } + return response; + }, + ); - const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly thumbprint: string; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - }); - const nowMillis = yield* Clock.currentTimeMillis; - const accountId = relayAccountId(input.clerkToken); - if (Option.isNone(accountId)) { - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "bypass", - "relay.token_cache.bypass_reason": "invalid_subject_token", - }); - const response = yield* exchangeAccessToken(input); - return { - accountId: "", + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const nowMillis = yield* Clock.currentTimeMillis; + const accountId = relayAccountId(input.clerkToken); + if (Option.isNone(accountId)) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "bypass", + "relay.token_cache.bypass_reason": "invalid_subject_token", + }); + const response = yield* exchangeAccessToken(input); + return { + accountId: "", + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + } satisfies ManagedRelayAccessTokenCacheEntry; + } + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => + Effect.gen(function* () { + const activeTokens = tokens.filter((token) => token.expiresAtMillis > nowMillis + 5_000); + const cached = activeTokens.find((token) => + tokenMatches(token, { + accountId: accountId.value, clientId: options.clientId, relayUrl, thumbprint: input.thumbprint, scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - } satisfies ManagedRelayAccessTokenCacheEntry; + nowMillis, + }), + ); + if (cached) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "hit", + }); + return [cached, activeTokens] as const; } - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => - Effect.gen(function* () { - const activeTokens = tokens.filter( - (token) => token.expiresAtMillis > nowMillis + 5_000, - ); - const cached = activeTokens.find((token) => - tokenMatches(token, { - accountId: accountId.value, - clientId: options.clientId, - relayUrl, - thumbprint: input.thumbprint, - scopes: input.scopes, - nowMillis, - }), - ); - if (cached) { - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "hit", - }); - return [cached, activeTokens] as const; - } - yield* Effect.annotateCurrentSpan({ - "relay.token_cache.result": "miss", - }); - const response = yield* exchangeAccessToken(input); - const next: ManagedRelayAccessTokenCacheEntry = { - accountId: accountId.value, - clientId: options.clientId, - relayUrl, - thumbprint: input.thumbprint, - scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - }; - const nextTokens = [...activeTokens, next]; - if (options.accessTokenStore) { - yield* options.accessTokenStore.save(nextTokens); + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "miss", + }); + const response = yield* exchangeAccessToken(input); + const next: ManagedRelayAccessTokenCacheEntry = { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + const nextTokens = [...activeTokens, next]; + if (options.accessTokenStore) { + yield* options.accessTokenStore.save(nextTokens); + } + return [next, nextTokens] as const; + }), + ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); + }, + ); + + const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + "http.request.method": input.target.method, + "url.full": input.target.url, + }); + const thumbprint = yield* signer.thumbprint; + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( + function* (accessToken: string) { + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { + const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); + if (nextTokens.length === tokens.length) { + return Effect.succeed([false, tokens] as const); + } + return ( + options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void + ).pipe(Effect.as([true, nextTokens] as const)); + }); + }, + ); + + const runDpopRequest = ( + input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ): Effect.Effect => { + const attempt = (refreshRejectedToken: boolean): Effect.Effect => + authorize(input).pipe( + Effect.flatMap((authorization) => + request(authorization).pipe( + Effect.catch((error) => { + if (!isRejectedDpopAccessToken(error)) { + return Effect.fail(error); } - return [next, nextTokens] as const; + return invalidateAccessToken(authorization.accessToken).pipe( + Effect.tap((invalidated) => + Effect.annotateCurrentSpan({ + "relay.token_cache.invalidated": invalidated, + "relay.token_cache.invalidation_reason": "invalid_bearer", + "relay.token_cache.retry_after_invalidation": refreshRejectedToken, + }), + ), + Effect.tap((invalidated) => + invalidated && refreshRejectedToken + ? Effect.logWarning( + "Relay rejected a cached DPoP access token; refreshing it once.", + ) + : Effect.void, + ), + Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), + ); }), - ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); - }, + ), + ), ); + return attempt(true); + }; - const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }) { - yield* Effect.annotateCurrentSpan({ - "relay.client_id": options.clientId, - "relay.scopes": input.scopes.join(" "), - "http.request.method": input.target.method, - "url.full": input.target.url, - }); - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError((cause) => - relayLocalError("Could not load relay DPoP proof key.", cause), - ), - ); - const token = yield* obtainAccessToken({ - clerkToken: input.clerkToken, - scopes: input.scopes, - thumbprint, - }); - const proof = yield* signer - .createProof({ - ...input.target, - accessToken: token.accessToken, + const mobileRegistrationRequest = ( + input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }, + request: ( + authorization: ManagedRelayAuthorization, + ) => Effect.Effect, + ) => + runDpopRequest( + { + ...input, + scopes: [RelayMobileRegistrationScope], + }, + request, + ); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("list-environments")), + timeoutRelayRequest("list-environments"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), + withRelayClientTracing, + ), + listDevices: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), }) .pipe( - Effect.mapError((cause) => - relayLocalError("Could not create relay request DPoP proof.", cause), - ), + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("list-devices")), + timeoutRelayRequest("list-devices"), ); - return { accessToken: token.accessToken, proof, thumbprint }; - }); - - const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")( - function* (accessToken: string) { - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { - const nextTokens = tokens.filter((token) => token.accessToken !== accessToken); - if (nextTokens.length === tokens.length) { - return Effect.succeed([false, tokens] as const); - } - return ( - options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void - ).pipe(Effect.as([true, nextTokens] as const)); - }); - }, - ); - - const runDpopRequest = ( - input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }, - request: ( - authorization: ManagedRelayAuthorization, - ) => Effect.Effect, - ): Effect.Effect => { - const attempt = ( - refreshRejectedToken: boolean, - ): Effect.Effect => - authorize(input).pipe( - Effect.flatMap((authorization) => - request(authorization).pipe( - Effect.catch((error) => { - if (!isRejectedDpopAccessToken(error)) { - return Effect.fail(error); - } - return invalidateAccessToken(authorization.accessToken).pipe( - Effect.tap((invalidated) => - Effect.annotateCurrentSpan({ - "relay.token_cache.invalidated": invalidated, - "relay.token_cache.invalidation_reason": "invalid_bearer", - "relay.token_cache.retry_after_invalidation": refreshRejectedToken, - }), - ), - Effect.tap((invalidated) => - invalidated && refreshRejectedToken - ? Effect.logWarning( - "Relay rejected a cached DPoP access token; refreshing it once.", - ) - : Effect.void, - ), - Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), - ); - }), - ), - ), + }, + Effect.withSpan("clientRuntime.managedRelay.listDevices"), + withRelayClientTracing, + ), + createEnvironmentLinkChallenge: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("create-environment-link-challenge")), + timeoutRelayRequest("create-environment-link-challenge"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), + withRelayClientTracing, + ), + linkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("link-environment")), + timeoutRelayRequest("link-environment"), ); - return attempt(true); - }; - - const mobileRegistrationRequest = ( - input: { - readonly clerkToken: string; - readonly target: DpopProofTarget; - }, - request: ( - authorization: ManagedRelayAuthorization, - ) => Effect.Effect, - ) => - runDpopRequest( + }, + Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), + withRelayClientTracing, + ), + unlinkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("unlink-environment")), + timeoutRelayRequest("unlink-environment"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), + withRelayClientTracing, + ), + getEnvironmentStatus: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( { - ...input, - scopes: [RelayMobileRegistrationScope], + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), }, - request, - ); - - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + (authorization) => + client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) .pipe( - Effect.map((response) => response.environments), - Effect.mapError(relayRequestError("Could not list relay-managed environments.")), - timeoutRelayRequest("Relay environment listing timed out."), - ); + Effect.mapError(relayRequestError("get-environment-status")), + timeoutRelayRequest("get-environment-status"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), + withRelayClientTracing, + ), + connectEnvironment: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + return yield* runDpopRequest( + { + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), }, - Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), - withRelayClientTracing, - ), - listDevices: Effect.fnUntraced( - function* (input) { - return yield* client.client - .listDevices({ - headers: bearerHeaders(input.clerkToken), + (authorization) => { + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, }) .pipe( - Effect.map((response) => response.devices), - Effect.mapError(relayRequestError("Could not list relay client devices.")), - timeoutRelayRequest("Relay client device listing timed out."), + Effect.mapError(relayRequestError("connect-environment")), + timeoutRelayRequest("connect-environment"), ); }, - Effect.withSpan("clientRuntime.managedRelay.listDevices"), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: Effect.fnUntraced( - function* (input) { - return yield* client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), + withRelayClientTracing, + ), + registerDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }, + (authorization) => + client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), payload: input.payload, }) .pipe( - Effect.mapError( - relayRequestError("Could not create relay environment link challenge."), - ), - timeoutRelayRequest("Relay environment link challenge timed out."), - ); + Effect.mapError(relayRequestError("register-device")), + timeoutRelayRequest("register-device"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerDevice"), + withRelayClientTracing, + ), + unregisterDevice: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), }, - Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), - withRelayClientTracing, - ), - linkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, + (authorization) => + client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, }) .pipe( - Effect.mapError(relayRequestError("Could not link relay environment.")), - timeoutRelayRequest("Relay environment linking timed out."), - ); + Effect.mapError(relayRequestError("unregister-device")), + timeoutRelayRequest("unregister-device"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), + withRelayClientTracing, + ), + registerLiveActivity: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), }, - Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), - withRelayClientTracing, - ), - unlinkEnvironment: Effect.fnUntraced( - function* (input) { - return yield* client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, + (authorization) => + client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("Could not unlink relay environment.")), - timeoutRelayRequest("Relay environment unlinking timed out."), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), - withRelayClientTracing, - ), - getEnvironmentStatus: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }, - (authorization) => - client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not get relay environment status.")), - timeoutRelayRequest("Relay environment status request timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), - withRelayClientTracing, - ), - connectEnvironment: Effect.fnUntraced( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "environment.id": input.environmentId, - }); - return yield* runDpopRequest( - { - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }, - (authorization) => { - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not connect relay environment.")), - timeoutRelayRequest("Relay environment connection timed out."), - ); - }, - ); - }, - Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), - withRelayClientTracing, - ), - registerDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }, - (authorization) => - client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not register relay mobile device.")), - timeoutRelayRequest("Relay mobile device registration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerDevice"), - withRelayClientTracing, - ), - unregisterDevice: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }, - (authorization) => - client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError(relayRequestError("Could not unregister relay mobile device.")), - timeoutRelayRequest("Relay mobile device unregistration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), - withRelayClientTracing, - ), - registerLiveActivity: Effect.fnUntraced( - function* (input) { - return yield* mobileRegistrationRequest( - { - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }, - (authorization) => - client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError(relayRequestError("Could not register relay live activity.")), - timeoutRelayRequest("Relay Live Activity registration timed out."), - ), - ); - }, - Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), - withRelayClientTracing, - ), - resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( - Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), - Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), - withRelayClientTracing, - ), - }); - }), - ); -} + Effect.mapError(relayRequestError("register-live-activity")), + timeoutRelayRequest("register-live-activity"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), + withRelayClientTracing, + ), + resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( + Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + withRelayClientTracing, + ), + }); +}); + +export const layer = (options: ManagedRelayClientLayerOptions) => + Layer.effect(ManagedRelayClient, make(options)); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 43b020d0840..6966efa9b83 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -12,11 +12,7 @@ import * as Stream from "effect/Stream"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, vi } from "vite-plus/test"; -import { - ManagedRelayClient, - ManagedRelayClientError, - type ManagedRelayClientShape, -} from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; import { createManagedRelayQueryManager, createManagedRelaySession, @@ -66,10 +62,10 @@ function resetRegistry() { } function createManager( - overrides?: Partial, + overrides?: Partial, onQueryEvent?: (event: ManagedRelayQueryEvent) => void, ) { - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.succeed([environment]), listDevices: () => Effect.succeed([device]), @@ -90,7 +86,7 @@ function createManager( resetTokenCache: Effect.void, ...overrides, }); - const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); + const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000, ...(onQueryEvent ? { onQueryEvent } : {}), @@ -363,8 +359,9 @@ describe("createManagedRelayQueryManager", () => { const manager = createManager({ getEnvironmentStatus: () => Effect.fail( - new ManagedRelayClientError({ - message: "Could not get relay environment status.", + new ManagedRelay.ManagedRelayRequestFailedError({ + request: "get-environment-status", + cause: new Error("Relay request failed."), traceId: "trace-status", }), ), @@ -375,7 +372,7 @@ describe("createManagedRelayQueryManager", () => { registry.get(atom); await vi.waitFor(() => { expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ - error: "Could not get relay environment status.", + error: "Managed relay request 'get-environment-status' failed.", errorTraceId: "trace-status", }); }); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 8a26d2f698f..ec6a0710dd1 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -16,7 +16,7 @@ import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { findErrorTraceId } from "../errors/errorTrace.ts"; -import { ManagedRelayClient } from "./managedRelay.ts"; +import * as ManagedRelay from "./managedRelay.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; @@ -308,7 +308,7 @@ export function readManagedRelaySnapshotState( } export function createManagedRelayQueryManager( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options?: { readonly staleTimeMs?: number; readonly idleTtlMs?: number; @@ -351,7 +351,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; return yield* observe( { ...base, stage: "relay-request" }, relay.listEnvironments({ clerkToken }), @@ -374,7 +374,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; return yield* observe( { ...base, stage: "relay-request" }, relay.listDevices({ clerkToken }), @@ -402,7 +402,7 @@ export function createManagedRelayQueryManager( { ...base, stage: "clerk-token" }, requireClerkToken(get, accountId), ); - const relay = yield* ManagedRelayClient; + const relay = yield* ManagedRelay.ManagedRelayClient; const status = yield* observe( { ...base, stage: "relay-request" }, relay.getEnvironmentStatus({ diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index 927671e176f..bdf217d0880 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -4,34 +4,33 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { - EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, - RelayEnvironmentDiscovery, -} from "../relay/discovery.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import { createRuntimeCommand } from "./runtime.ts"; export function createRelayEnvironmentDiscoveryAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const stateAtom = runtime.atom( Stream.unwrap( - RelayEnvironmentDiscovery.pipe( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), ), ), - { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, + { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, ); const stateValueAtom = Atom.make((get) => Option.getOrElse( AsyncResult.value(get(stateAtom)), - () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); const refresh = createRuntimeCommand(runtime, { label: "relay-environment-discovery:refresh", concurrency: { mode: "singleFlight", key: () => "refresh" }, execute: (_input: void) => - RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)), + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe( + Effect.flatMap((discovery) => discovery.refresh), + ), }); return { From 5e8abad36549734201a8098ecc383d33d62935a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:23:42 -0700 Subject: [PATCH 2/4] Discard changes to packages/client-runtime/src/connection/errors.test.ts --- .../src/connection/errors.test.ts | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 packages/client-runtime/src/connection/errors.test.ts diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts deleted file mode 100644 index 8fc06aac312..00000000000 --- a/packages/client-runtime/src/connection/errors.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; -import { describe, expect, it } from "@effect/vitest"; - -import * as ManagedRelay from "../relay/managedRelay.ts"; -import { mapManagedRelayError } from "./errors.ts"; -import { ConnectionBlockedError } from "./model.ts"; - -function proofCreationError(): ManagedRelay.ManagedRelayDpopProofCreationError { - return new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: "POST", - url: "https://relay.example.test/v1/client/dpop-token", - cause: new Error("Proof creation failed."), - }); -} - -describe("connection error mapping", () => { - it("blocks invalid relay configuration", () => { - const error = mapManagedRelayError( - new ManagedRelay.ManagedRelayUrlInvalidError({ - relayUrl: "http://relay.example.test", - }), - ); - - expect(error).toBeInstanceOf(ConnectionBlockedError); - expect(error).toMatchObject({ reason: "configuration" }); - }); - - it("blocks relay credentials with unexpected scopes", () => { - const error = mapManagedRelayError( - new ManagedRelay.ManagedRelayAccessTokenScopesUnexpectedError({ - requestedScopes: [RelayEnvironmentStatusScope], - grantedScope: "unexpected:scope", - }), - ); - - expect(error).toBeInstanceOf(ConnectionBlockedError); - expect(error).toMatchObject({ reason: "permission" }); - }); - - it.each([ - new ManagedRelay.ManagedRelayDpopKeyLoadError({ - operation: "load-or-create", - cause: new Error("Key load failed."), - }), - new ManagedRelay.ManagedRelayTokenProofCreationError({ - method: "POST", - url: "https://relay.example.test/v1/client/dpop-token", - cause: proofCreationError(), - }), - new ManagedRelay.ManagedRelayRequestProofCreationError({ - method: "POST", - url: "https://relay.example.test/v1/client/environments/environment-1/connect", - cause: proofCreationError(), - }), - ])("blocks relay authentication failures ($._tag)", (relayError) => { - const error = mapManagedRelayError(relayError); - - expect(error).toBeInstanceOf(ConnectionBlockedError); - expect(error).toMatchObject({ reason: "authentication" }); - }); -}); From c20283d1c690f292fce7abb3cbce2227fc51cb3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:42:43 -0700 Subject: [PATCH 3/4] Remove redundant relay key operation discriminator Co-authored-by: codex --- apps/mobile/src/features/cloud/managedRelayLayer.ts | 2 +- apps/web/src/cloud/managedRelayLayer.ts | 2 +- packages/client-runtime/src/relay/managedRelay.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 6a53bfe53a8..2da1fa9157c 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -20,7 +20,7 @@ const relayDpopSignerLayer = Layer.effect( Effect.mapError( (error) => new ManagedRelay.ManagedRelayDpopKeyLoadError({ - operation: "load-or-create", + keyStore: "expo-secure-store", cause: error, }), ), diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 25cbfb9a763..52f9b6496c9 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -42,7 +42,7 @@ export const relayDpopSignerLayer = Layer.effect( Effect.mapError( (error) => new ManagedRelay.ManagedRelayDpopKeyLoadError({ - operation: "load-or-create", + keyStore: "indexed-db", cause: error, }), ), diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 2c05d096a2b..d067a4f8c3e 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -52,12 +52,12 @@ export interface ManagedRelayDpopProofInput { export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()( "ManagedRelayDpopKeyLoadError", { - operation: Schema.Literal("load-or-create"), + keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]), cause: Schema.Defect(), }, ) { override get message(): string { - return `Could not ${this.operation.replaceAll("-", " ")} the relay DPoP proof key.`; + return `Could not load or create the relay DPoP proof key from ${this.keyStore}.`; } } From f3a36b4565f15d0b6e54d11a618f0d7fed6596ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:55:56 -0700 Subject: [PATCH 4/4] Preserve managed relay error messages Co-authored-by: codex --- .../src/connection/resolver.test.ts | 2 +- .../src/relay/discovery.test.ts | 6 +- .../src/relay/managedRelay.test.ts | 6 +- .../client-runtime/src/relay/managedRelay.ts | 111 ++++++++++-------- .../src/relay/managedRelayState.test.ts | 4 +- 5 files changed, 72 insertions(+), 57 deletions(-) diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index c235e788721..0469e459d16 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -447,7 +447,7 @@ describe("ConnectionResolver", () => { connectEnvironment: () => Effect.fail( new ManagedRelay.ManagedRelayRequestTimeoutError({ - request: "connect-environment", + activity: "Relay environment connection", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index d8f37ee3543..6bdc7798fb2 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -259,7 +259,7 @@ describe("RelayEnvironmentDiscovery", () => { listEnvironments: () => Effect.fail( new ManagedRelay.ManagedRelayRequestTimeoutError({ - request: "list-environments", + activity: "Relay environment listing", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), @@ -302,7 +302,7 @@ describe("RelayEnvironmentDiscovery", () => { expect(Option.getOrThrow(state.error)).toMatchObject({ _tag: "ConnectionTransientError", reason: "timeout", - message: `Managed relay request 'list-environments' timed out after ${ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS}ms.`, + message: "Relay environment listing timed out.", }); }).pipe(Effect.provide(layer)); }), @@ -326,7 +326,7 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, new ManagedRelay.ManagedRelayRequestFailedError({ - request: "list-environments", + action: "list relay-managed environments", cause: new Error("Relay request failed."), }), ); diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index bedb9d49dcd..278c205883f 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -125,7 +125,7 @@ describe("ManagedRelayClient", () => { expect(error).toMatchObject({ _tag: "ManagedRelayUrlInvalidError", relayUrl: "http://relay.example.test", - message: "Relay URL 'http://relay.example.test' must be a secure absolute HTTPS origin.", + message: "Relay URL must be a secure absolute HTTPS origin.", }); expect(requestCount).toBe(0); }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); @@ -428,9 +428,9 @@ describe("ManagedRelayClient", () => { expect(error).toMatchObject({ _tag: "ManagedRelayRequestTimeoutError", - request: "list-environments", + activity: "Relay environment listing", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, - message: `Managed relay request 'list-environments' timed out after ${ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS}ms.`, + message: "Relay environment listing timed out.", }); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); }); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index d067a4f8c3e..08b720b46a3 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -57,7 +57,7 @@ export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass()( "ManagedRelayRequestTimeoutError", { - request: ManagedRelayRequest, + activity: ManagedRelayRequestActivity, timeoutMs: Schema.Number, }, ) { override get message(): string { - return `Managed relay request '${this.request}' timed out after ${this.timeoutMs}ms.`; + return `${this.activity} timed out.`; } } @@ -114,21 +129,21 @@ export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass()( "ManagedRelayRequestFailedError", { - request: ManagedRelayRequest, + action: ManagedRelayRequestAction, cause: Schema.Defect(), relayError: Schema.optional(RelayProtectedError), traceId: Schema.optional(Schema.String), }, ) { override get message(): string { - return `Managed relay request '${this.request}' failed.`; + return `Could not ${this.action}.`; } } @@ -140,7 +155,7 @@ export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedE }, ) { override get message(): string { - return `Relay granted unexpected DPoP access token scopes: requested '${encodeOAuthScope(this.requestedScopes)}', received '${this.grantedScope}'.`; + return "Relay granted unexpected DPoP access token scopes."; } } @@ -153,7 +168,7 @@ export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass }, ) { override get message(): string { - return `Could not create relay token DPoP proof for ${this.method} ${this.url}.`; + return "Could not create relay token DPoP proof."; } } @@ -166,7 +181,7 @@ export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorCla }, ) { override get message(): string { - return `Could not create relay request DPoP proof for ${this.method} ${this.url}.`; + return "Could not create relay request DPoP proof."; } } @@ -277,10 +292,10 @@ export class ManagedRelayClient extends Context.Service< const isRelayProtectedError = Schema.is(RelayProtectedError); -function relayRequestError(request: ManagedRelayRequest) { +function relayRequestError(action: ManagedRelayRequestAction) { return (cause: RelayHttpRequestError): ManagedRelayClientError => new ManagedRelayRequestFailedError({ - request, + action, cause, ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), }); @@ -302,7 +317,7 @@ function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean { ); } -function timeoutRelayRequest(request: ManagedRelayRequest) { +function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { return ( effect: Effect.Effect, ): Effect.Effect => @@ -313,7 +328,7 @@ function timeoutRelayRequest(request: ManagedRelayRequest) { onNone: () => Effect.fail( new ManagedRelayRequestTimeoutError({ - request, + activity, timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), @@ -467,8 +482,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( }, }) .pipe( - Effect.mapError(relayRequestError("exchange-access-token")), - timeoutRelayRequest("exchange-access-token"), + Effect.mapError(relayRequestError("exchange relay DPoP access token")), + timeoutRelayRequest("Relay DPoP access token exchange"), ); if (!oauthScopeSetEquals(response.scope, input.scopes)) { return yield* new ManagedRelayAccessTokenScopesUnexpectedError({ @@ -661,8 +676,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) .pipe( Effect.map((response) => response.environments), - Effect.mapError(relayRequestError("list-environments")), - timeoutRelayRequest("list-environments"), + Effect.mapError(relayRequestError("list relay-managed environments")), + timeoutRelayRequest("Relay environment listing"), ); }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), @@ -676,8 +691,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( }) .pipe( Effect.map((response) => response.devices), - Effect.mapError(relayRequestError("list-devices")), - timeoutRelayRequest("list-devices"), + Effect.mapError(relayRequestError("list relay client devices")), + timeoutRelayRequest("Relay client device listing"), ); }, Effect.withSpan("clientRuntime.managedRelay.listDevices"), @@ -691,8 +706,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("create-environment-link-challenge")), - timeoutRelayRequest("create-environment-link-challenge"), + Effect.mapError(relayRequestError("create relay environment link challenge")), + timeoutRelayRequest("Relay environment link challenge"), ); }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), @@ -706,8 +721,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("link-environment")), - timeoutRelayRequest("link-environment"), + Effect.mapError(relayRequestError("link relay environment")), + timeoutRelayRequest("Relay environment linking"), ); }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), @@ -721,8 +736,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( params: { environmentId: input.environmentId }, }) .pipe( - Effect.mapError(relayRequestError("unlink-environment")), - timeoutRelayRequest("unlink-environment"), + Effect.mapError(relayRequestError("unlink relay environment")), + timeoutRelayRequest("Relay environment unlinking"), ); }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), @@ -746,8 +761,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( params: { environmentId: input.environmentId }, }) .pipe( - Effect.mapError(relayRequestError("get-environment-status")), - timeoutRelayRequest("get-environment-status"), + Effect.mapError(relayRequestError("get relay environment status")), + timeoutRelayRequest("Relay environment status request"), ), ); }, @@ -777,8 +792,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( payload, }) .pipe( - Effect.mapError(relayRequestError("connect-environment")), - timeoutRelayRequest("connect-environment"), + Effect.mapError(relayRequestError("connect relay environment")), + timeoutRelayRequest("Relay environment connection"), ); }, ); @@ -800,8 +815,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("register-device")), - timeoutRelayRequest("register-device"), + Effect.mapError(relayRequestError("register relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), ), ); }, @@ -822,8 +837,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( params: { deviceId: input.deviceId }, }) .pipe( - Effect.mapError(relayRequestError("unregister-device")), - timeoutRelayRequest("unregister-device"), + Effect.mapError(relayRequestError("unregister relay mobile device")), + timeoutRelayRequest("Relay mobile device unregistration"), ), ); }, @@ -844,8 +859,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( payload: input.payload, }) .pipe( - Effect.mapError(relayRequestError("register-live-activity")), - timeoutRelayRequest("register-live-activity"), + Effect.mapError(relayRequestError("register relay live activity")), + timeoutRelayRequest("Relay Live Activity registration"), ), ); }, diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 6966efa9b83..49400d32aef 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -360,7 +360,7 @@ describe("createManagedRelayQueryManager", () => { getEnvironmentStatus: () => Effect.fail( new ManagedRelay.ManagedRelayRequestFailedError({ - request: "get-environment-status", + action: "get relay environment status", cause: new Error("Relay request failed."), traceId: "trace-status", }), @@ -372,7 +372,7 @@ describe("createManagedRelayQueryManager", () => { registry.get(atom); await vi.waitFor(() => { expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ - error: "Managed relay request 'get-environment-status' failed.", + error: "Could not get relay environment status.", errorTraceId: "trace-status", }); });