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..2da1fa9157c 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({ + keyStore: "expo-secure-store", + 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..52f9b6496c9 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({ + keyStore: "indexed-db", + 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.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..0469e459d16 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({ + 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 e05302195db..6bdc7798fb2 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({ + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, }), ), getEnvironmentStatus: () => Effect.die("unused"), @@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, - new ManagedRelay.ManagedRelayClientError({ - message: "Relay environment listing failed.", + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "list relay-managed 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..278c205883f 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,13 +117,14 @@ 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: "ManagedRelayUrlInvalidError", + relayUrl: "http://relay.example.test", message: "Relay URL must be a secure absolute HTTPS origin.", }); expect(requestCount).toBe(0); @@ -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,17 +417,19 @@ 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", + _tag: "ManagedRelayRequestTimeoutError", + activity: "Relay environment listing", + timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, message: "Relay environment listing timed out.", }); }).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..08b720b46a3 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,184 @@ 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", + { + keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not load 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 ManagedRelayRequestAction = Schema.Literals([ + "exchange relay DPoP access token", + "list relay-managed environments", + "list relay client devices", + "create relay environment link challenge", + "link relay environment", + "unlink relay environment", + "get relay environment status", + "connect relay environment", + "register relay mobile device", + "unregister relay mobile device", + "register relay live activity", +]); +export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; + +export const ManagedRelayRequestActivity = Schema.Literals([ + "Relay DPoP access token exchange", + "Relay environment listing", + "Relay client device listing", + "Relay environment link challenge", + "Relay environment linking", + "Relay environment unlinking", + "Relay environment status request", + "Relay environment connection", + "Relay mobile device registration", + "Relay mobile device unregistration", + "Relay Live Activity registration", +]); +export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; + +export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass()( "ManagedRelayRequestTimeoutError", -)<{ - readonly message: string; -}> {} + { + activity: ManagedRelayRequestActivity, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `${this.activity} timed out.`; + } +} + +export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass()( + "ManagedRelayUrlInvalidError", + { + relayUrl: Schema.String, + }, +) { + override get message(): string { + return "Relay URL must be a secure absolute HTTPS origin."; + } +} + +export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass()( + "ManagedRelayRequestFailedError", + { + action: ManagedRelayRequestAction, + cause: Schema.Defect(), + relayError: Schema.optional(RelayProtectedError), + traceId: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Could not ${this.action}.`; + } +} + +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."; + } +} + +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."; + } +} + +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."; + } +} + +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 +241,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(action: ManagedRelayRequestAction) { return (cause: RelayHttpRequestError): ManagedRelayClientError => - new ManagedRelayClientError({ - message, + new ManagedRelayRequestFailedError({ + action, 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(activity: ManagedRelayRequestActivity) { 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({ + activity, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + }), ), onSome: Effect.succeed, }), @@ -258,10 +380,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 +405,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 relay DPoP access token")), + timeoutRelayRequest("Relay DPoP access token exchange"), + ); + 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 relay-managed environments")), + timeoutRelayRequest("Relay environment listing"), + ); + }, + 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 relay client devices")), + timeoutRelayRequest("Relay client device listing"), ); - 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 relay environment link challenge")), + timeoutRelayRequest("Relay 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 relay environment")), + timeoutRelayRequest("Relay environment linking"), ); - 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 relay environment")), + timeoutRelayRequest("Relay environment unlinking"), + ); + }, + 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 relay environment status")), + timeoutRelayRequest("Relay environment status request"), + ), + ); + }, + 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 relay environment")), + timeoutRelayRequest("Relay environment connection"), ); }, - 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 relay mobile device")), + timeoutRelayRequest("Relay mobile device registration"), + ), + ); + }, + 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 relay mobile device")), + timeoutRelayRequest("Relay mobile device unregistration"), + ), + ); + }, + 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 relay live activity")), + timeoutRelayRequest("Relay Live Activity registration"), + ), + ); + }, + 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..49400d32aef 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({ + action: "get relay environment status", + cause: new Error("Relay request failed."), traceId: "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 {