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

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

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

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

apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+2-2
......@@ -2,7 +2,7 @@ import { beforeEach, vi } from "vite-plus/test";
22import { describe, expect, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import type { EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
5import { ManagedRelay } from "@t3tools/client-runtime/relay";
66import * as Layer from "effect/Layer";
77import { HttpClient } from "effect/unstable/http";
88
......@@ -35,7 +35,7 @@ const connection: SavedRemoteConnection = {
3535};
3636
3737const testLayer = Layer.mergeAll(
38 Layer.succeed(ManagedRelayClient, null as never),
38 Layer.succeed(ManagedRelay.ManagedRelayClient, null as never),
3939 Layer.succeed(
4040 HttpClient.HttpClient,
4141 HttpClient.make(() => Effect.die("unexpected HTTP request")),
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+2-2
......@@ -1,6 +1,6 @@
11import * as Effect from "effect/Effect";
22import { HttpClient } from "effect/unstable/http";
3import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
3import { ManagedRelay } from "@t3tools/client-runtime/relay";
44
55import type { SavedRemoteConnection } from "../../lib/connection";
66import { savePreferencesPatch } from "../../lib/storage";
......@@ -11,7 +11,7 @@ export function setLiveActivityUpdatesEnabled(input: {
1111 readonly enabled: boolean;
1212 readonly clerkToken: string | null;
1313 readonly connections: ReadonlyArray<SavedRemoteConnection>;
14}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelayClient> {
14}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> {
1515 return Effect.gen(function* () {
1616 yield* Effect.tryPromise({
1717 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+2-2
......@@ -9,7 +9,7 @@ import * as Effect from "effect/Effect";
99import * as Exit from "effect/Exit";
1010import * as Layer from "effect/Layer";
1111import { FetchHttpClient } from "effect/unstable/http";
12import { type ManagedRelayClient } from "@t3tools/client-runtime/relay";
12import { ManagedRelay } from "@t3tools/client-runtime/relay";
1313
1414import type { EnvironmentId } from "@t3tools/contracts";
1515import { verifyDpopProof } from "@t3tools/shared/dpop";
......@@ -158,7 +158,7 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO
158158 }
159159 idlePasses = 0;
160160 const exit = yield* Effect.exit(
161 pending.operation as Effect.Effect<unknown, unknown, ManagedRelayClient>,
161 pending.operation as Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
162162 );
163163 yield* Effect.sync(() => {
164164 pending.resolve(exit);
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+15-15
......@@ -9,7 +9,7 @@ import {
99 type RelayLiveActivityRegistrationRequest,
1010} from "@t3tools/contracts/relay";
1111import { findErrorTraceId } from "@t3tools/client-runtime/errors";
12import { ManagedRelayClient } from "@t3tools/client-runtime/relay";
12import { ManagedRelay } from "@t3tools/client-runtime/relay";
1313import {
1414 isAtomCommandInterrupted,
1515 settleAsyncResult,
......@@ -175,7 +175,7 @@ const relayToken = Effect.gen(function* () {
175175function registerDeviceWithRelay(
176176 body: RelayDeviceRegistrationRequest,
177177 expectedGeneration: number,
178): Effect.Effect<void, unknown, ManagedRelayClient> {
178): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
179179 return Effect.gen(function* () {
180180 if (expectedGeneration !== deviceRegistrationGeneration) {
181181 logRegistrationDebug("device registration cancelled before relay request", {
......@@ -198,7 +198,7 @@ function registerDeviceWithRelay(
198198 return;
199199 }
200200
201 const client = yield* ManagedRelayClient;
201 const client = yield* ManagedRelay.ManagedRelayClient;
202202 logRegistrationDebug("relay device registration request started", {
203203 expectedGeneration,
204204 });
......@@ -215,7 +215,7 @@ function registerDeviceWithRelay(
215215function unregisterDeviceWithRelay(input: {
216216 readonly deviceId: string;
217217 readonly tokenProvider: () => Promise<string | null>;
218}): Effect.Effect<void, unknown, ManagedRelayClient> {
218}): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
219219 return Effect.gen(function* () {
220220 if (!readRelayConfig()) return;
221221 const token = yield* Effect.tryPromise({
......@@ -227,7 +227,7 @@ function unregisterDeviceWithRelay(input: {
227227 return;
228228 }
229229
230 const client = yield* ManagedRelayClient;
230 const client = yield* ManagedRelay.ManagedRelayClient;
231231 yield* client.unregisterDevice({
232232 clerkToken: token,
233233 deviceId: input.deviceId,
......@@ -237,7 +237,7 @@ function unregisterDeviceWithRelay(input: {
237237
238238function registerLiveActivityWithRelay(
239239 body: RelayLiveActivityRegistrationRequest,
240): Effect.Effect<boolean, unknown, ManagedRelayClient> {
240): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
241241 return Effect.gen(function* () {
242242 if (!readRelayConfig()) return false;
243243 const token = yield* relayToken;
......@@ -246,7 +246,7 @@ function registerLiveActivityWithRelay(
246246 return false;
247247 }
248248
249 const client = yield* ManagedRelayClient;
249 const client = yield* ManagedRelay.ManagedRelayClient;
250250 yield* client.registerLiveActivity({
251251 clerkToken: token,
252252 payload: body,
......@@ -274,7 +274,7 @@ function logRegistrationDebug(context: string, details?: unknown): void {
274274}
275275
276276function runRegistrationInBackground(
277 operation: Effect.Effect<unknown, unknown, ManagedRelayClient>,
277 operation: Effect.Effect<unknown, unknown, ManagedRelay.ManagedRelayClient>,
278278 context: string,
279279): void {
280280 void (async () => {
......@@ -370,7 +370,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri
370370function registerDevice(
371371 input: DeviceRegistrationInput = {},
372372 expectedGeneration = deviceRegistrationGeneration,
373): Effect.Effect<void, unknown, ManagedRelayClient> {
373): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
374374 return Effect.gen(function* () {
375375 if (!canRegisterRemoteLiveActivities()) {
376376 logRegistrationDebug("device registration skipped; platform does not support it");
......@@ -411,7 +411,7 @@ function registerDevice(
411411
412412function registerDeviceForCurrentUser(
413413 pushToStartToken?: string,
414): Effect.Effect<void, unknown, ManagedRelayClient> {
414): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
415415 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
416416}
417417
......@@ -485,7 +485,7 @@ export function unregisterAllAgentAwarenessConnections(): void {
485485export function refreshAgentAwarenessRegistration(): Effect.Effect<
486486 void,
487487 never,
488 ManagedRelayClient
488 ManagedRelay.ManagedRelayClient
489489> {
490490 return registerDeviceForCurrentUser().pipe(
491491 Effect.catch((error) =>
......@@ -515,7 +515,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
515515
516516export function unregisterAgentAwarenessDeviceForCurrentUser(
517517 tokenProvider: () => Promise<string | null>,
518): Effect.Effect<void, never, ManagedRelayClient> {
518): Effect.Effect<void, never, ManagedRelay.ManagedRelayClient> {
519519 return Effect.gen(function* () {
520520 const deviceId = yield* Effect.tryPromise({
521521 try: () => loadAgentAwarenessDeviceId(),
......@@ -536,7 +536,7 @@ export function unregisterAgentAwarenessDeviceForCurrentUser(
536536
537537export function registerLiveActivityPushToken(input: {
538538 readonly activity: LiveActivity<AgentActivityProps>;
539}): Effect.Effect<boolean, unknown, ManagedRelayClient> {
539}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
540540 return Effect.gen(function* () {
541541 if (!canRegisterRemoteLiveActivities()) {
542542 return false;
......@@ -588,7 +588,7 @@ export function registerLiveActivityPushToken(input: {
588588
589589function registerLiveActivityPushTokenValue(input: {
590590 readonly activityPushToken: string;
591}): Effect.Effect<boolean, unknown, ManagedRelayClient> {
591}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
592592 return Effect.gen(function* () {
593593 const deviceId = yield* Effect.tryPromise({
594594 try: () => loadOrCreateAgentAwarenessDeviceId(),
......@@ -624,7 +624,7 @@ function scheduleActiveLiveActivityRegistrationRetry(): void {
624624export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
625625 void,
626626 never,
627 ManagedRelayClient
627 ManagedRelay.ManagedRelayClient
628628> {
629629 return Effect.gen(function* () {
630630 if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+2-2
......@@ -1,6 +1,6 @@
11import { ClerkProvider, useAuth } from "@clerk/expo";
22import { tokenCache } from "@clerk/expo/token-cache";
3import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";
3import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
44import {
55 reportAtomCommandResult,
66 settleAsyncResult,
......@@ -22,7 +22,7 @@ import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publi
2222function resetManagedRelayTokenCache() {
2323 return settleAsyncResult(() =>
2424 runtime.runPromiseExit(
25 ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
25 ManagedRelay.ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
2626 ),
2727 );
2828}
apps/mobile/src/features/cloud/linkEnvironment.test.ts+9-9
......@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import { EnvironmentId } from "@t3tools/contracts";
66import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import {
8 managedRelayClientLayer,
9 ManagedRelayClient,
10 ManagedRelayDpopSigner,
11} from "@t3tools/client-runtime/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
128import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
139import { HttpClient } from "effect/unstable/http";
1410
......@@ -62,8 +58,8 @@ const createProofMock = vi.fn(
6258 Effect.succeed(`dpop:${input.method}:${input.url}`),
6359);
6460const testDpopSignerLayer = Layer.succeed(
65 ManagedRelayDpopSigner,
66 ManagedRelayDpopSigner.of({
61 ManagedRelay.ManagedRelayDpopSigner,
62 ManagedRelay.ManagedRelayDpopSigner.of({
6763 thumbprint: Effect.succeed("client-proof-key-thumbprint"),
6864 createProof: (input) => createProofMock(input),
6965 }),
......@@ -73,7 +69,7 @@ function cloudClientLayer() {
7369 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
7470 return Layer.mergeAll(
7571 httpClientLayer,
76 managedRelayClientLayer({
72 ManagedRelay.layer({
7773 relayUrl: "https://relay.example.test",
7874 clientId: RelayMobileClientId,
7975 }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),
......@@ -81,7 +77,11 @@ function cloudClientLayer() {
8177}
8278
8379const withCloudServices = <A, E>(
84 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
80 effect: Effect.Effect<
81 A,
82 E,
83 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
84 >,
8585) => effect.pipe(Effect.provide(cloudClientLayer()));
8686
8787function validLinkProof() {
apps/mobile/src/features/cloud/linkEnvironment.ts+22-20
......@@ -25,11 +25,7 @@ import {
2525import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
2626import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
2727import { findErrorTraceId } from "@t3tools/client-runtime/errors";
28import {
29 ManagedRelayClient,
30 type ManagedRelayClientError,
31 ManagedRelayDpopSigner,
32} from "@t3tools/client-runtime/relay";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";
3329import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3430
3531import { authClientMetadata } from "../../lib/authClientMetadata";
......@@ -156,13 +152,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
156152}
157153
158154function decodedRelayClientError(message: string) {
159 return (cause: ManagedRelayClientError) => {
160 const relayError = cause.relayError;
155 return (cause: ManagedRelay.ManagedRelayClientError) => {
156 const relayError =
157 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
158 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
161159 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
162160 return new CloudEnvironmentLinkError({
163161 message: detail ? `${message}: ${detail}` : message,
164162 cause,
165 ...(cause.traceId ? { traceId: cause.traceId } : {}),
163 ...(traceId ? { traceId } : {}),
166164 });
167165 };
168166}
......@@ -261,7 +259,11 @@ function ensureConnectEndpointMatchesEnvironment(input: {
261259export function linkEnvironmentToCloud(input: {
262260 readonly connection: SavedRemoteConnection;
263261 readonly clerkToken: string;
264}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
262}): Effect.Effect<
263 void,
264 CloudEnvironmentLinkError,
265 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
266> {
265267 return Effect.gen(function* () {
266268 if (!input.connection.bearerToken) {
267269 return yield* new CloudEnvironmentLinkError({
......@@ -270,7 +272,7 @@ export function linkEnvironmentToCloud(input: {
270272 }
271273 const localBearerToken = input.connection.bearerToken;
272274 const relayUrl = yield* requireRelayUrl();
273 const relayClient = yield* ManagedRelayClient;
275 const relayClient = yield* ManagedRelay.ManagedRelayClient;
274276 const deviceId = yield* Effect.tryPromise({
275277 try: () => loadOrCreateAgentAwarenessDeviceId(),
276278 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
......@@ -353,11 +355,11 @@ export function listCloudEnvironments(input: {
353355}): Effect.Effect<
354356 ReadonlyArray<RelayClientEnvironmentRecord>,
355357 CloudEnvironmentLinkError,
356 ManagedRelayClient
358 ManagedRelay.ManagedRelayClient
357359> {
358360 return Effect.gen(function* () {
359361 const relayUrl = yield* requireRelayUrl();
360 const relayClient = yield* ManagedRelayClient;
362 const relayClient = yield* ManagedRelay.ManagedRelayClient;
361363
362364 return yield* relayClient
363365 .listEnvironments({
......@@ -374,11 +376,11 @@ export function getCloudEnvironmentStatus(input: {
374376}): Effect.Effect<
375377 RelayEnvironmentStatusResponseType,
376378 CloudEnvironmentLinkError,
377 ManagedRelayClient
379 ManagedRelay.ManagedRelayClient
378380> {
379381 return Effect.gen(function* () {
380382 const relayUrl = yield* requireRelayUrl();
381 const relayClient = yield* ManagedRelayClient;
383 const relayClient = yield* ManagedRelay.ManagedRelayClient;
382384 const status = yield* relayClient
383385 .getEnvironmentStatus({
384386 clerkToken: input.clerkToken,
......@@ -413,7 +415,7 @@ export function loadCloudEnvironmentStatuses(input: {
413415}): Effect.Effect<
414416 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
415417 CloudEnvironmentLinkError,
416 ManagedRelayClient
418 ManagedRelay.ManagedRelayClient
417419> {
418420 return Effect.forEach(
419421 input.environments,
......@@ -445,7 +447,7 @@ export function listCloudEnvironmentsWithStatus(input: {
445447}): Effect.Effect<
446448 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
447449 CloudEnvironmentLinkError,
448 ManagedRelayClient
450 ManagedRelay.ManagedRelayClient
449451> {
450452 return Effect.gen(function* () {
451453 const environments = yield* listCloudEnvironments(input);
......@@ -473,7 +475,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
473475 }) {
474476 yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });
475477 const relayUrl = yield* requireRelayUrl();
476 const relayClient = yield* ManagedRelayClient;
478 const relayClient = yield* ManagedRelay.ManagedRelayClient;
477479
478480 const deviceId = yield* loadAgentAwarenessDeviceId();
479481 const connect = yield* relayClient
......@@ -514,7 +516,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
514516 message: "Connected endpoint descriptor does not match the selected environment.",
515517 });
516518 }
517 const signer = yield* ManagedRelayDpopSigner;
519 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
518520 const bootstrapDpop = yield* signer
519521 .createProof({
520522 method: "POST",
......@@ -555,7 +557,7 @@ export function connectCloudEnvironment(input: {
555557}): Effect.Effect<
556558 SavedRemoteConnection,
557559 CloudEnvironmentLinkError,
558 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
560 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
559561> {
560562 return connectRelayManagedEnvironment({
561563 clerkToken: input.clerkToken,
......@@ -570,7 +572,7 @@ export function refreshCloudEnvironmentConnection(input: {
570572}): Effect.Effect<
571573 SavedRemoteConnection,
572574 CloudEnvironmentLinkError,
573 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
575 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
574576> {
575577 return connectRelayManagedEnvironment({
576578 clerkToken: input.clerkToken,
apps/mobile/src/features/cloud/managedRelayLayer.ts+35-19
......@@ -1,8 +1,4 @@
1import {
2 managedRelayClientLayer as makeManagedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime/relay";
1import { ManagedRelay } from "@t3tools/client-runtime/relay";
62import { RelayMobileClientId } from "@t3tools/contracts/relay";
73import * as Crypto from "effect/Crypto";
84import * as Effect from "effect/Effect";
......@@ -12,34 +8,54 @@ import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
128import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
139
1410const relayDpopSignerLayer = Layer.effect(
15 ManagedRelayDpopSigner,
11 ManagedRelay.ManagedRelayDpopSigner,
1612 Effect.gen(function* () {
1713 const crypto = yield* Crypto.Crypto;
1814 const loadProofKey = yield* Effect.cached(
1915 loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)),
2016 );
21 return ManagedRelayDpopSigner.of({
17 return ManagedRelay.ManagedRelayDpopSigner.of({
2218 thumbprint: loadProofKey.pipe(
2319 Effect.map((proofKey) => proofKey.thumbprint),
24 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
20 Effect.mapError(
21 (error) =>
22 new ManagedRelay.ManagedRelayDpopKeyLoadError({
23 keyStore: "expo-secure-store",
24 cause: error,
25 }),
26 ),
2527 Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"),
2628 ),
27 createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(
28 function* (input) {
29 const proofKey = yield* loadProofKey;
30 return yield* createDpopProof({ ...input, proofKey }).pipe(
31 Effect.provideService(Crypto.Crypto, crypto),
32 Effect.map((proof) => proof.proof),
33 );
34 },
35 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
36 ),
29 createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) {
30 const proofKey = yield* loadProofKey.pipe(
31 Effect.mapError(
32 (error) =>
33 new ManagedRelay.ManagedRelayDpopProofCreationError({
34 method: input.method,
35 url: input.url,
36 cause: error,
37 }),
38 ),
39 );
40 return yield* createDpopProof({ ...input, proofKey }).pipe(
41 Effect.provideService(Crypto.Crypto, crypto),
42 Effect.map((proof) => proof.proof),
43 Effect.mapError(
44 (error) =>
45 new ManagedRelay.ManagedRelayDpopProofCreationError({
46 method: input.method,
47 url: input.url,
48 cause: error,
49 }),
50 ),
51 );
52 }),
3753 });
3854 }),
3955);
4056
4157export const managedRelayClientLayer = (relayUrl: string) =>
42 makeManagedRelayClientLayer({
58 ManagedRelay.layer({
4359 relayUrl,
4460 clientId: RelayMobileClientId,
4561 accessTokenStore: managedRelayAccessTokenStore,
apps/mobile/src/features/cloud/managedRelayTokenStore.ts+6-7
......@@ -1,7 +1,4 @@
1import {
2 type ManagedRelayAccessTokenCacheEntry,
3 type ManagedRelayAccessTokenStore,
4} from "@t3tools/client-runtime/relay";
1import { ManagedRelay } from "@t3tools/client-runtime/relay";
52import * as Data from "effect/Data";
63import * as Effect from "effect/Effect";
74import * as Schema from "effect/Schema";
......@@ -60,7 +57,7 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({
6057}).pipe(
6158 Effect.flatMap((encoded) =>
6259 encoded === null
63 ? Effect.succeed<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>([])
60 ? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])
6461 : decodeManagedRelayAccessTokenCache(encoded).pipe(
6562 Effect.map((cache) => cache.entries),
6663 Effect.mapError(storeError("Persisted relay access tokens are invalid.")),
......@@ -68,7 +65,9 @@ const loadManagedRelayAccessTokens = Effect.tryPromise({
6865 ),
6966);
7067
71const saveManagedRelayAccessTokens = (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) =>
68const saveManagedRelayAccessTokens = (
69 entries: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>,
70) =>
7271 encodeManagedRelayAccessTokenCache({
7372 version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
7473 entries,
......@@ -87,7 +86,7 @@ const clearManagedRelayAccessTokens = Effect.tryPromise({
8786 catch: storeError("Could not clear persisted relay access tokens."),
8887});
8988
90export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = {
89export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
9190 load: loadManagedRelayAccessTokens.pipe(
9291 Effect.tapError(logStoreFailure("load")),
9392 Effect.orElseSucceed(() => []),
apps/mobile/src/lib/runtime.ts+19-3
......@@ -15,7 +15,17 @@ function configuredRelayUrl(): string {
1515
1616const httpClientLayer = remoteHttpClientLayer(fetch);
1717
18export const runtimeLayer = Layer.merge(
18type RuntimeLayerSource =
19 | ReturnType<typeof managedRelayClientLayer>
20 | typeof Socket.layerWebSocketConstructorGlobal
21 | typeof cryptoLayer
22 | typeof httpClientLayer
23 | typeof tracingLayer;
24
25export const runtimeLayer: Layer.Layer<
26 Layer.Success<RuntimeLayerSource>,
27 Layer.Error<RuntimeLayerSource>
28> = Layer.merge(
1929 managedRelayClientLayer(configuredRelayUrl()),
2030 Socket.layerWebSocketConstructorGlobal,
2131).pipe(
......@@ -24,6 +34,12 @@ export const runtimeLayer = Layer.merge(
2434 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),
2535);
2636
27export const runtime = ManagedRuntime.make(runtimeLayer);
37export const runtime: ManagedRuntime.ManagedRuntime<
38 Layer.Success<typeof runtimeLayer>,
39 Layer.Error<typeof runtimeLayer>
40> = ManagedRuntime.make(runtimeLayer);
2841
29export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);
42export const runtimeContextLayer: Layer.Layer<
43 Layer.Success<typeof runtimeLayer>,
44 Layer.Error<typeof runtimeLayer>
45> = Layer.effectContext(runtime.contextEffect);
apps/mobile/src/state/relay.ts+1-1
......@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st
22
33import { connectionAtomRuntime } from "../connection/runtime";
44
5export const relayEnvironmentDiscovery =
5export const relayEnvironmentDiscovery: ReturnType<typeof createRelayEnvironmentDiscoveryAtoms> =
66 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
apps/web/src/cloud/linkEnvironment.test.ts+9-9
......@@ -21,11 +21,7 @@ import {
2121} from "@t3tools/client-runtime/connection";
2222import { type RpcSession } from "@t3tools/client-runtime/rpc";
2323import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
24import {
25 managedRelayClientLayer,
26 ManagedRelayClient,
27 ManagedRelayDpopSigner,
28} from "@t3tools/client-runtime/relay";
24import { ManagedRelay } from "@t3tools/client-runtime/relay";
2925import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
3026import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";
3127
......@@ -60,8 +56,8 @@ vi.mock("./relayClientInstallDialog", () => ({
6056
6157const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
6258const dpopSignerLayer = Layer.succeed(
63 ManagedRelayDpopSigner,
64 ManagedRelayDpopSigner.of({
59 ManagedRelay.ManagedRelayDpopSigner,
60 ManagedRelay.ManagedRelayDpopSigner.of({
6561 thumbprint: Effect.succeed("thumbprint"),
6662 createProof,
6763 }),
......@@ -71,7 +67,7 @@ function relayLayer() {
7167 const http = remoteHttpClientLayer(globalThis.fetch);
7268 return Layer.mergeAll(
7369 http,
74 managedRelayClientLayer({
70 ManagedRelay.layer({
7571 relayUrl: "https://relay.example.test",
7672 clientId: RelayWebClientId,
7773 }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)),
......@@ -129,7 +125,11 @@ function services(options?: Parameters<typeof registryLayer>[0]) {
129125}
130126
131127function withServices<A, E>(
132 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | EnvironmentRegistry>,
128 effect: Effect.Effect<
129 A,
130 E,
131 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | EnvironmentRegistry
132 >,
133133 options?: Parameters<typeof registryLayer>[0],
134134) {
135135 return effect.pipe(Effect.provide(services(options)));
apps/web/src/cloud/linkEnvironment.ts+18-12
......@@ -25,7 +25,7 @@ import {
2525import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2626import { request, runStream } from "@t3tools/client-runtime/rpc";
2727import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
28import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay";
28import { ManagedRelay } from "@t3tools/client-runtime/relay";
2929
3030import {
3131 readPrimaryEnvironmentDescriptor,
......@@ -164,13 +164,15 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
164164}
165165
166166function decodedRelayClientError(message: string) {
167 return (cause: ManagedRelayClientError) => {
168 const relayError = cause.relayError;
167 return (cause: ManagedRelay.ManagedRelayClientError) => {
168 const relayError =
169 cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
170 const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
169171 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
170172 return new CloudEnvironmentLinkError({
171173 message: detail ? `${message}: ${detail}` : message,
172174 cause,
173 ...(cause.traceId ? { traceId: cause.traceId } : {}),
175 ...(traceId ? { traceId } : {}),
174176 });
175177 };
176178}
......@@ -268,7 +270,7 @@ export function listManagedCloudEnvironments(input: {
268270}): Effect.Effect<
269271 ReadonlyArray<RelayClientEnvironmentRecord>,
270272 CloudEnvironmentLinkError,
271 ManagedRelayClient
273 ManagedRelay.ManagedRelayClient
272274> {
273275 return Effect.gen(function* () {
274276 const configuredRelayUrl = relayUrl();
......@@ -277,7 +279,7 @@ export function listManagedCloudEnvironments(input: {
277279 message: "T3CODE_RELAY_URL is not configured.",
278280 });
279281 }
280 const relayClient = yield* ManagedRelayClient;
282 const relayClient = yield* ManagedRelay.ManagedRelayClient;
281283 return yield* relayClient
282284 .listEnvironments({
283285 clerkToken: input.clerkToken,
......@@ -299,7 +301,7 @@ export function listCloudDevices(input: {
299301}): Effect.Effect<
300302 ReadonlyArray<RelayClientDeviceRecord>,
301303 CloudEnvironmentLinkError,
302 ManagedRelayClient
304 ManagedRelay.ManagedRelayClient
303305> {
304306 return Effect.gen(function* () {
305307 if (!relayUrl()) {
......@@ -307,7 +309,7 @@ export function listCloudDevices(input: {
307309 message: "T3CODE_RELAY_URL is not configured.",
308310 });
309311 }
310 const relayClient = yield* ManagedRelayClient;
312 const relayClient = yield* ManagedRelay.ManagedRelayClient;
311313 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(
312314 Effect.mapError(
313315 (cause) =>
......@@ -351,7 +353,11 @@ export function updatePrimaryCloudPreferences(input: {
351353export function unlinkPrimaryEnvironmentFromCloud(input: {
352354 readonly target: CloudLinkTarget;
353355 readonly clerkToken: string | null;
354}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
356}): Effect.Effect<
357 void,
358 CloudEnvironmentLinkError,
359 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
360> {
355361 return Effect.gen(function* () {
356362 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
357363 yield* client.connect
......@@ -360,7 +366,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: {
360366
361367 const configuredRelayUrl = relayUrl();
362368 if (configuredRelayUrl && input.clerkToken) {
363 const relayClient = yield* ManagedRelayClient;
369 const relayClient = yield* ManagedRelay.ManagedRelayClient;
364370 yield* relayClient
365371 .unlinkEnvironment({
366372 clerkToken: input.clerkToken,
......@@ -383,7 +389,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
383389}): Effect.Effect<
384390 void,
385391 CloudEnvironmentLinkError,
386 EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient
392 EnvironmentRegistry | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
387393> {
388394 return Effect.gen(function* () {
389395 const configuredRelayUrl = relayUrl();
......@@ -392,7 +398,7 @@ export function linkPrimaryEnvironmentToCloud(input: {
392398 message: "T3CODE_RELAY_URL is not configured.",
393399 });
394400 }
395 const relayClient = yield* ManagedRelayClient;
401 const relayClient = yield* ManagedRelay.ManagedRelayClient;
396402 const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
397403 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
398404
apps/web/src/cloud/managedAuth.tsx+4-2
......@@ -1,5 +1,5 @@
11import { useAuth } from "@clerk/react";
2import { ManagedRelayClient, setManagedRelaySession } from "@t3tools/client-runtime/relay";
2import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay";
33import {
44 reportAtomCommandResult,
55 settleAsyncResult,
......@@ -64,7 +64,9 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac
6464 removeRelayEnvironments(),
6565 settleAsyncResult(() =>
6666 runtime.runPromiseExit(
67 ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)),
67 ManagedRelay.ManagedRelayClient.pipe(
68 Effect.flatMap((client) => client.resetTokenCache),
69 ),
6870 ),
6971 ),
7072 ]);
apps/web/src/cloud/managedRelayLayer.ts+35-19
......@@ -1,8 +1,4 @@
1import {
2 managedRelayClientLayer as makeManagedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime/relay";
1import { ManagedRelay } from "@t3tools/client-runtime/relay";
62import { RelayWebClientId } from "@t3tools/contracts/relay";
73import * as Crypto from "effect/Crypto";
84import * as Effect from "effect/Effect";
......@@ -18,7 +14,7 @@ import {
1814} from "./dpop";
1915
2016export const relayDpopSignerLayer = Layer.effect(
21 ManagedRelayDpopSigner,
17 ManagedRelay.ManagedRelayDpopSigner,
2218 Effect.gen(function* () {
2319 const crypto = yield* Crypto.Crypto;
2420 const keyLoadSemaphore = yield* Semaphore.make(1);
......@@ -40,27 +36,47 @@ export const relayDpopSignerLayer = Layer.effect(
4036 }),
4137 );
4238
43 return ManagedRelayDpopSigner.of({
39 return ManagedRelay.ManagedRelayDpopSigner.of({
4440 thumbprint: loadOrCreateBrowserDpopKey.pipe(
4541 Effect.map((proofKey) => proofKey.thumbprint),
46 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
42 Effect.mapError(
43 (error) =>
44 new ManagedRelay.ManagedRelayDpopKeyLoadError({
45 keyStore: "indexed-db",
46 cause: error,
47 }),
48 ),
4749 Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"),
4850 ),
49 createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(
50 function* (input) {
51 const proofKey = yield* loadOrCreateBrowserDpopKey;
52 return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(
53 Effect.provideService(Crypto.Crypto, crypto),
54 Effect.map((proof) => proof.proof),
55 );
56 },
57 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
58 ),
51 createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) {
52 const proofKey = yield* loadOrCreateBrowserDpopKey.pipe(
53 Effect.mapError(
54 (error) =>
55 new ManagedRelay.ManagedRelayDpopProofCreationError({
56 method: input.method,
57 url: input.url,
58 cause: error,
59 }),
60 ),
61 );
62 return yield* createBrowserDpopProof({ ...input, proofKey }).pipe(
63 Effect.provideService(Crypto.Crypto, crypto),
64 Effect.map((proof) => proof.proof),
65 Effect.mapError(
66 (error) =>
67 new ManagedRelay.ManagedRelayDpopProofCreationError({
68 method: input.method,
69 url: input.url,
70 cause: error,
71 }),
72 ),
73 );
74 }),
5975 });
6076 }),
6177);
6278
6379export const managedRelayClientLayer = (relayUrl: string) =>
64 makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe(
80 ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe(
6581 Layer.provideMerge(relayDpopSignerLayer),
6682 );
apps/web/src/cloud/managedRelayState.ts+5-3
......@@ -1,7 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 createManagedRelayQueryManager,
4 ManagedRelayClient,
4 ManagedRelay,
55 managedRelaySessionAtom,
66 readManagedRelaySnapshotState,
77} from "@t3tools/client-runtime/relay";
......@@ -20,8 +20,10 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
2020
2121const managedRelayAtomRuntime = Atom.runtime(
2222 Layer.effect(
23 ManagedRelayClient,
24 runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))),
23 ManagedRelay.ManagedRelayClient,
24 runtime.contextEffect.pipe(
25 Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)),
26 ),
2527 ),
2628);
2729
apps/web/src/lib/runtime.ts+19-3
......@@ -24,6 +24,13 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(
2424 client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web",
2525}).pipe(Layer.provide(httpClientLayer));
2626
27type RuntimeLayerSource =
28 | typeof httpClientLayer
29 | typeof browserCryptoLayer
30 | typeof Socket.layerWebSocketConstructorGlobal
31 | typeof relayTracingLayer
32 | ReturnType<typeof managedRelayClientLayer>;
33
2734export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer);
2835
2936const primaryHttpRuntime = ManagedRuntime.make(
......@@ -47,7 +54,10 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner)
4754 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
4855}
4956
50export const runtimeLayer = Layer.mergeAll(
57export const runtimeLayer: Layer.Layer<
58 Layer.Success<RuntimeLayerSource>,
59 Layer.Error<RuntimeLayerSource>
60> = Layer.mergeAll(
5161 httpClientLayer,
5262 browserCryptoLayer,
5363 Socket.layerWebSocketConstructorGlobal,
......@@ -57,6 +67,12 @@ export const runtimeLayer = Layer.mergeAll(
5767 ),
5868);
5969
60export const runtime = ManagedRuntime.make(runtimeLayer);
70export const runtime: ManagedRuntime.ManagedRuntime<
71 Layer.Success<typeof runtimeLayer>,
72 Layer.Error<typeof runtimeLayer>
73> = ManagedRuntime.make(runtimeLayer);
6174
62export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect);
75export const runtimeContextLayer: Layer.Layer<
76 Layer.Success<typeof runtimeLayer>,
77 Layer.Error<typeof runtimeLayer>
78> = Layer.effectContext(runtime.contextEffect);
apps/web/src/state/environments.ts+2-1
......@@ -3,6 +3,7 @@ import {
33 connectionCatalogDisplayUrl,
44 type EnvironmentPresentation as BaseEnvironmentPresentation,
55} from "@t3tools/client-runtime/connection";
6import { Discovery } from "@t3tools/client-runtime/relay";
67import type { EnvironmentId } from "@t3tools/contracts";
78import * as Option from "effect/Option";
89import { useMemo } from "react";
......@@ -81,7 +82,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null):
8182 return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null;
8283}
8384
84export function useRelayEnvironmentDiscovery() {
85export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState {
8586 return useAtomValue(relayEnvironmentDiscovery.stateValueAtom);
8687}
8788
apps/web/src/state/relay.ts+1-1
......@@ -2,5 +2,5 @@ import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/st
22
33import { connectionAtomRuntime } from "../connection/runtime";
44
5export const relayEnvironmentDiscovery =
5export const relayEnvironmentDiscovery: ReturnType<typeof createRelayEnvironmentDiscoveryAtoms> =
66 createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime);
packages/client-runtime/src/authorization/layer.test.ts-1
......@@ -118,7 +118,6 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
118118 createProof: (proofInput) =>
119119 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(
120120 Effect.as(`proof:${proofInput.url}`),
121 Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })),
122121 ),
123122 });
124123 const layer = RemoteEnvironmentAuthorization.layer.pipe(
packages/client-runtime/src/connection/errors.ts+32-14
......@@ -74,21 +74,39 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
7474}
7575
7676export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError {
77 if (error.relayError) {
78 return relayProtectedError(error.relayError);
79 }
80 if (error.cause?._tag === "ManagedRelayRequestTimeoutError") {
81 return new ConnectionTransientError({
82 reason: "timeout",
83 detail: error.message,
84 ...(error.traceId ? { traceId: error.traceId } : {}),
85 });
77 switch (error._tag) {
78 case "ManagedRelayRequestFailedError":
79 if (error.relayError) {
80 return relayProtectedError(error.relayError);
81 }
82 return new ConnectionTransientError({
83 reason: "relay-unavailable",
84 detail: error.message,
85 ...(error.traceId ? { traceId: error.traceId } : {}),
86 });
87 case "ManagedRelayRequestTimeoutError":
88 return new ConnectionTransientError({
89 reason: "timeout",
90 detail: error.message,
91 });
92 case "ManagedRelayUrlInvalidError":
93 return new ConnectionBlockedError({
94 reason: "configuration",
95 detail: error.message,
96 });
97 case "ManagedRelayAccessTokenScopesUnexpectedError":
98 return new ConnectionBlockedError({
99 reason: "permission",
100 detail: error.message,
101 });
102 case "ManagedRelayDpopKeyLoadError":
103 case "ManagedRelayTokenProofCreationError":
104 case "ManagedRelayRequestProofCreationError":
105 return new ConnectionBlockedError({
106 reason: "authentication",
107 detail: error.message,
108 });
86109 }
87 return new ConnectionTransientError({
88 reason: "relay-unavailable",
89 detail: error.message,
90 ...(error.traceId ? { traceId: error.traceId } : {}),
91 });
92110}
93111
94112export function mapRemoteEnvironmentError(
packages/client-runtime/src/connection/resolver.test.ts+3-5
......@@ -446,11 +446,9 @@ describe("ConnectionResolver", () => {
446446 const brokerLayer = yield* makeDependencies({
447447 connectEnvironment: () =>
448448 Effect.fail(
449 new ManagedRelay.ManagedRelayClientError({
450 message: "Relay timed out.",
451 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({
452 message: "Relay timed out.",
453 }),
449 new ManagedRelay.ManagedRelayRequestTimeoutError({
450 activity: "Relay environment connection",
451 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
454452 }),
455453 ),
456454 });
packages/client-runtime/src/relay/discovery.test.ts+6-7
......@@ -258,11 +258,9 @@ describe("RelayEnvironmentDiscovery", () => {
258258 relayUrl: "https://relay.example.test",
259259 listEnvironments: () =>
260260 Effect.fail(
261 new ManagedRelay.ManagedRelayClientError({
262 message: "Relay environment listing timed out.",
263 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({
264 message: "Relay environment listing timed out.",
265 }),
261 new ManagedRelay.ManagedRelayRequestTimeoutError({
262 activity: "Relay environment listing",
263 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
266264 }),
267265 ),
268266 getEnvironmentStatus: () => Effect.die("unused"),
......@@ -327,8 +325,9 @@ describe("RelayEnvironmentDiscovery", () => {
327325
328326 yield* Ref.set(
329327 harness.listFailure,
330 new ManagedRelay.ManagedRelayClientError({
331 message: "Relay environment listing failed.",
328 new ManagedRelay.ManagedRelayRequestFailedError({
329 action: "list relay-managed environments",
330 cause: new Error("Relay request failed."),
332331 }),
333332 );
334333 yield* discovery.refresh;
packages/client-runtime/src/relay/index.ts+2-2
......@@ -1,3 +1,3 @@
1export * from "./discovery.ts";
2export * from "./managedRelay.ts";
1export * as Discovery from "./discovery.ts";
2export * as ManagedRelay from "./managedRelay.ts";
33export * from "./managedRelayState.ts";
packages/client-runtime/src/relay/managedRelay.test.ts+30-34
......@@ -8,31 +8,24 @@ import * as Layer from "effect/Layer";
88import * as Tracer from "effect/Tracer";
99import * as TestClock from "effect/testing/TestClock";
1010
11import {
12 MANAGED_RELAY_REQUEST_TIMEOUT_MS,
13 ManagedRelayClient,
14 ManagedRelayDpopSigner,
15 managedRelayClientLayer,
16 type ManagedRelayAccessTokenCacheEntry,
17 type ManagedRelayAccessTokenStore,
18 type ManagedRelayDpopProofInput,
19} from "./managedRelay.ts";
11import * as ManagedRelay from "./managedRelay.ts";
2012import { remoteHttpClientLayer } from "../rpc/http.ts";
2113
2214function managedRelayTestLayer(
2315 fetchFn: typeof globalThis.fetch,
2416 relayUrl = "https://relay.example.test",
25 accessTokenStore?: ManagedRelayAccessTokenStore,
17 accessTokenStore?: ManagedRelay.ManagedRelayAccessTokenStore,
2618) {
2719 const httpClientLayer = remoteHttpClientLayer(fetchFn);
2820 const signerLayer = Layer.succeed(
29 ManagedRelayDpopSigner,
30 ManagedRelayDpopSigner.of({
21 ManagedRelay.ManagedRelayDpopSigner,
22 ManagedRelay.ManagedRelayDpopSigner.of({
3123 thumbprint: Effect.succeed("client-thumbprint"),
32 createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`),
24 createProof: (input: ManagedRelay.ManagedRelayDpopProofInput) =>
25 Effect.succeed(`proof:${input.url}`),
3326 }),
3427 );
35 return managedRelayClientLayer({
28 return ManagedRelay.layer({
3629 relayUrl,
3730 clientId: "t3-mobile",
3831 ...(accessTokenStore ? { accessTokenStore } : {}),
......@@ -90,7 +83,7 @@ describe("ManagedRelayClient", () => {
9083 }) satisfies typeof globalThis.fetch;
9184
9285 return Effect.gen(function* () {
93 const relayClient = yield* ManagedRelayClient;
86 const relayClient = yield* ManagedRelay.ManagedRelayClient;
9487 yield* relayClient.getEnvironmentStatus({
9588 clerkToken: clerkToken("user-1", "session-1"),
9689 scopes: [RelayEnvironmentStatusScope],
......@@ -124,13 +117,14 @@ describe("ManagedRelayClient", () => {
124117 }) satisfies typeof globalThis.fetch;
125118
126119 return Effect.gen(function* () {
127 const relayClient = yield* ManagedRelayClient;
120 const relayClient = yield* ManagedRelay.ManagedRelayClient;
128121 const error = yield* relayClient
129122 .listEnvironments({ clerkToken: "clerk-token" })
130123 .pipe(Effect.flip);
131124
132125 expect(error).toMatchObject({
133 _tag: "ManagedRelayClientError",
126 _tag: "ManagedRelayUrlInvalidError",
127 relayUrl: "http://relay.example.test",
134128 message: "Relay URL must be a secure absolute HTTPS origin.",
135129 });
136130 expect(requestCount).toBe(0);
......@@ -175,7 +169,7 @@ describe("ManagedRelayClient", () => {
175169 }) satisfies typeof globalThis.fetch;
176170
177171 return Effect.gen(function* () {
178 const relayClient = yield* ManagedRelayClient;
172 const relayClient = yield* ManagedRelay.ManagedRelayClient;
179173 const statusInput = {
180174 clerkToken: clerkToken("user-1", "session-1"),
181175 scopes: [RelayEnvironmentStatusScope],
......@@ -198,8 +192,8 @@ describe("ManagedRelayClient", () => {
198192
199193 it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => {
200194 let tokenExchangeCount = 0;
201 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [];
202 const accessTokenStore: ManagedRelayAccessTokenStore = {
195 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [];
196 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
203197 load: Effect.sync(() => persistedTokens),
204198 save: (entries) =>
205199 Effect.sync(() => {
......@@ -252,7 +246,7 @@ describe("ManagedRelayClient", () => {
252246
253247 return Effect.gen(function* () {
254248 yield* Effect.gen(function* () {
255 const relayClient = yield* ManagedRelayClient;
249 const relayClient = yield* ManagedRelay.ManagedRelayClient;
256250 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1")));
257251 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));
258252
......@@ -260,7 +254,7 @@ describe("ManagedRelayClient", () => {
260254 expect(persistedTokens).toHaveLength(1);
261255
262256 yield* Effect.gen(function* () {
263 const relayClient = yield* ManagedRelayClient;
257 const relayClient = yield* ManagedRelay.ManagedRelayClient;
264258 yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2")));
265259 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore)));
266260
......@@ -271,7 +265,7 @@ describe("ManagedRelayClient", () => {
271265 it.effect("refreshes a persisted DPoP token once when the relay rejects it", () => {
272266 let tokenExchangeCount = 0;
273267 const statusTokens: Array<string | null> = [];
274 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [
268 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [
275269 {
276270 accountId: "user-1",
277271 clientId: "t3-mobile",
......@@ -282,7 +276,7 @@ describe("ManagedRelayClient", () => {
282276 expiresAtMillis: Number.MAX_SAFE_INTEGER,
283277 },
284278 ];
285 const accessTokenStore: ManagedRelayAccessTokenStore = {
279 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
286280 load: Effect.sync(() => persistedTokens),
287281 save: (entries) =>
288282 Effect.sync(() => {
......@@ -344,7 +338,7 @@ describe("ManagedRelayClient", () => {
344338 }) satisfies typeof globalThis.fetch;
345339
346340 return Effect.gen(function* () {
347 const relayClient = yield* ManagedRelayClient;
341 const relayClient = yield* ManagedRelay.ManagedRelayClient;
348342 const result = yield* relayClient.getEnvironmentStatus({
349343 clerkToken: clerkToken("user-1", "session-1"),
350344 scopes: [RelayEnvironmentStatusScope],
......@@ -363,8 +357,8 @@ describe("ManagedRelayClient", () => {
363357 });
364358
365359 it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => {
366 let persistedTokens: ReadonlyArray<ManagedRelayAccessTokenCacheEntry> = [];
367 const accessTokenStore: ManagedRelayAccessTokenStore = {
360 let persistedTokens: ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry> = [];
361 const accessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
368362 load: Effect.succeed([]),
369363 save: (entries) =>
370364 Effect.sync(() => {
......@@ -407,7 +401,7 @@ describe("ManagedRelayClient", () => {
407401 }) satisfies typeof globalThis.fetch;
408402
409403 return Effect.gen(function* () {
410 const relayClient = yield* ManagedRelayClient;
404 const relayClient = yield* ManagedRelay.ManagedRelayClient;
411405 yield* relayClient.getEnvironmentStatus({
412406 clerkToken: "not-a-jwt",
413407 scopes: [RelayEnvironmentStatusScope],
......@@ -423,17 +417,19 @@ describe("ManagedRelayClient", () => {
423417 new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch;
424418
425419 return Effect.gen(function* () {
426 const relayClient = yield* ManagedRelayClient;
420 const relayClient = yield* ManagedRelay.ManagedRelayClient;
427421 const errorFiber = yield* relayClient
428422 .listEnvironments({ clerkToken: "clerk-token" })
429423 .pipe(Effect.flip, Effect.forkScoped);
430424
431425 yield* Effect.yieldNow;
432 yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS));
426 yield* TestClock.adjust(Duration.millis(ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS));
433427 const error = yield* Fiber.join(errorFiber);
434428
435429 expect(error).toMatchObject({
436 _tag: "ManagedRelayClientError",
430 _tag: "ManagedRelayRequestTimeoutError",
431 activity: "Relay environment listing",
432 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
437433 message: "Relay environment listing timed out.",
438434 });
439435 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn))));
......@@ -454,13 +450,13 @@ describe("ManagedRelayClient", () => {
454450 )) satisfies typeof globalThis.fetch;
455451
456452 return Effect.gen(function* () {
457 const relayClient = yield* ManagedRelayClient;
453 const relayClient = yield* ManagedRelay.ManagedRelayClient;
458454 const error = yield* relayClient
459455 .listEnvironments({ clerkToken: "clerk-token" })
460456 .pipe(Effect.flip);
461457
462458 expect(error).toMatchObject({
463 _tag: "ManagedRelayClientError",
459 _tag: "ManagedRelayRequestFailedError",
464460 traceId: "trace-managed-relay",
465461 });
466462 }).pipe(Effect.provide(managedRelayTestLayer(fetchFn)));
......@@ -499,7 +495,7 @@ describe("ManagedRelayClient", () => {
499495 }) satisfies typeof globalThis.fetch;
500496
501497 return Effect.gen(function* () {
502 const relayClient = yield* ManagedRelayClient;
498 const relayClient = yield* ManagedRelay.ManagedRelayClient;
503499 const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" });
504500 expect(devices).toMatchObject([
505501 {
packages/client-runtime/src/relay/managedRelay.ts+661-546
......@@ -5,7 +5,7 @@ import {
55 type RelayClientDeviceRecord,
66 RelayConnectEnvironmentEndpoint,
77 type RelayDeviceRegistrationRequest,
8 type RelayDpopAccessTokenScope,
8 RelayDpopAccessTokenScope,
99 RelayDpopTokenExchangeGrantType,
1010 type RelayEnvironmentConnectRequest,
1111 type RelayEnvironmentConnectResponse,
......@@ -33,58 +33,184 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3333import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3434import * as Clock from "effect/Clock";
3535import * as Context from "effect/Context";
36import * as Data from "effect/Data";
3736import * as Duration from "effect/Duration";
3837import * as Effect from "effect/Effect";
3938import * as Layer from "effect/Layer";
4039import * as Option from "effect/Option";
4140import * as Schema from "effect/Schema";
4241import * as SynchronizedRef from "effect/SynchronizedRef";
43import { HttpClientError } from "effect/unstable/http";
44import type { HttpMethod } from "effect/unstable/http/HttpMethod";
42import * as HttpClientError from "effect/unstable/http/HttpClientError";
43import type * as HttpMethod from "effect/unstable/http/HttpMethod";
4544import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
4645
4746export interface ManagedRelayDpopProofInput {
48 readonly method: HttpMethod;
47 readonly method: HttpMethod.HttpMethod;
4948 readonly url: string;
5049 readonly accessToken?: string;
5150}
5251
53export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{
54 readonly cause: unknown;
55}> {}
52export class ManagedRelayDpopKeyLoadError extends Schema.TaggedErrorClass<ManagedRelayDpopKeyLoadError>()(
53 "ManagedRelayDpopKeyLoadError",
54 {
55 keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]),
56 cause: Schema.Defect(),
57 },
58) {
59 override get message(): string {
60 return "Could not load relay DPoP proof key.";
61 }
62}
63
64export class ManagedRelayDpopProofCreationError extends Schema.TaggedErrorClass<ManagedRelayDpopProofCreationError>()(
65 "ManagedRelayDpopProofCreationError",
66 {
67 method: Schema.String,
68 url: Schema.String,
69 cause: Schema.Defect(),
70 },
71) {
72 override get message(): string {
73 return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`;
74 }
75}
5676
57export class ManagedRelayRequestTimeoutError extends Data.TaggedError(
77export const ManagedRelayDpopSignerError = Schema.Union([
78 ManagedRelayDpopKeyLoadError,
79 ManagedRelayDpopProofCreationError,
80]);
81export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type;
82
83export const ManagedRelayRequestAction = Schema.Literals([
84 "exchange relay DPoP access token",
85 "list relay-managed environments",
86 "list relay client devices",
87 "create relay environment link challenge",
88 "link relay environment",
89 "unlink relay environment",
90 "get relay environment status",
91 "connect relay environment",
92 "register relay mobile device",
93 "unregister relay mobile device",
94 "register relay live activity",
95]);
96export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;
97
98export const ManagedRelayRequestActivity = Schema.Literals([
99 "Relay DPoP access token exchange",
100 "Relay environment listing",
101 "Relay client device listing",
102 "Relay environment link challenge",
103 "Relay environment linking",
104 "Relay environment unlinking",
105 "Relay environment status request",
106 "Relay environment connection",
107 "Relay mobile device registration",
108 "Relay mobile device unregistration",
109 "Relay Live Activity registration",
110]);
111export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;
112
113export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass<ManagedRelayRequestTimeoutError>()(
58114 "ManagedRelayRequestTimeoutError",
59)<{
60 readonly message: string;
61}> {}
115 {
116 activity: ManagedRelayRequestActivity,
117 timeoutMs: Schema.Number,
118 },
119) {
120 override get message(): string {
121 return `${this.activity} timed out.`;
122 }
123}
124
125export class ManagedRelayUrlInvalidError extends Schema.TaggedErrorClass<ManagedRelayUrlInvalidError>()(
126 "ManagedRelayUrlInvalidError",
127 {
128 relayUrl: Schema.String,
129 },
130) {
131 override get message(): string {
132 return "Relay URL must be a secure absolute HTTPS origin.";
133 }
134}
135
136export class ManagedRelayRequestFailedError extends Schema.TaggedErrorClass<ManagedRelayRequestFailedError>()(
137 "ManagedRelayRequestFailedError",
138 {
139 action: ManagedRelayRequestAction,
140 cause: Schema.Defect(),
141 relayError: Schema.optional(RelayProtectedError),
142 traceId: Schema.optional(Schema.String),
143 },
144) {
145 override get message(): string {
146 return `Could not ${this.action}.`;
147 }
148}
149
150export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedErrorClass<ManagedRelayAccessTokenScopesUnexpectedError>()(
151 "ManagedRelayAccessTokenScopesUnexpectedError",
152 {
153 requestedScopes: Schema.Array(RelayDpopAccessTokenScope),
154 grantedScope: Schema.String,
155 },
156) {
157 override get message(): string {
158 return "Relay granted unexpected DPoP access token scopes.";
159 }
160}
161
162export class ManagedRelayTokenProofCreationError extends Schema.TaggedErrorClass<ManagedRelayTokenProofCreationError>()(
163 "ManagedRelayTokenProofCreationError",
164 {
165 method: Schema.String,
166 url: Schema.String,
167 cause: Schema.Defect(),
168 },
169) {
170 override get message(): string {
171 return "Could not create relay token DPoP proof.";
172 }
173}
174
175export class ManagedRelayRequestProofCreationError extends Schema.TaggedErrorClass<ManagedRelayRequestProofCreationError>()(
176 "ManagedRelayRequestProofCreationError",
177 {
178 method: Schema.String,
179 url: Schema.String,
180 cause: Schema.Defect(),
181 },
182) {
183 override get message(): string {
184 return "Could not create relay request DPoP proof.";
185 }
186}
187
188export const ManagedRelayClientError = Schema.Union([
189 ManagedRelayUrlInvalidError,
190 ManagedRelayRequestFailedError,
191 ManagedRelayRequestTimeoutError,
192 ManagedRelayAccessTokenScopesUnexpectedError,
193 ManagedRelayDpopKeyLoadError,
194 ManagedRelayTokenProofCreationError,
195 ManagedRelayRequestProofCreationError,
196]);
197export type ManagedRelayClientError = typeof ManagedRelayClientError.Type;
62198
63199type RelayHttpRequestError =
64200 | RelayProtectedErrorType
65201 | HttpClientError.HttpClientError
66 | Schema.SchemaError
67 | ManagedRelayRequestTimeoutError;
68
69export interface ManagedRelayDpopSignerShape {
70 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>;
71 readonly createProof: (
72 input: ManagedRelayDpopProofInput,
73 ) => Effect.Effect<string, ManagedRelayDpopSignerError>;
74}
202 | Schema.SchemaError;
75203
76204export class ManagedRelayDpopSigner extends Context.Service<
77205 ManagedRelayDpopSigner,
78 ManagedRelayDpopSignerShape
206 {
207 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopKeyLoadError>;
208 readonly createProof: (
209 input: ManagedRelayDpopProofInput,
210 ) => Effect.Effect<string, ManagedRelayDpopProofCreationError>;
211 }
79212>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}
80213
81export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{
82 readonly message: string;
83 readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError;
84 readonly relayError?: RelayProtectedErrorType;
85 readonly traceId?: string;
86}> {}
87
88214export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
89215
90216export interface ManagedRelayAccessTokenCacheEntry {
......@@ -115,100 +241,96 @@ export interface ManagedRelayClientLayerOptions {
115241 readonly accessTokenStore?: ManagedRelayAccessTokenStore;
116242}
117243
118export interface ManagedRelayClientShape {
119 readonly relayUrl: string;
120 readonly listEnvironments: (input: {
121 readonly clerkToken: string;
122 }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>;
123 readonly listDevices: (input: {
124 readonly clerkToken: string;
125 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>;
126 readonly createEnvironmentLinkChallenge: (input: {
127 readonly clerkToken: string;
128 readonly payload: RelayEnvironmentLinkChallengeRequest;
129 }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>;
130 readonly linkEnvironment: (input: {
131 readonly clerkToken: string;
132 readonly payload: RelayEnvironmentLinkRequest;
133 }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>;
134 readonly unlinkEnvironment: (input: {
135 readonly clerkToken: string;
136 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
137 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
138 readonly getEnvironmentStatus: (input: {
139 readonly clerkToken: string;
140 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
141 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
142 }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>;
143 readonly connectEnvironment: (input: {
144 readonly clerkToken: string;
145 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
146 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
147 readonly deviceId?: string;
148 }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>;
149 readonly registerDevice: (input: {
150 readonly clerkToken: string;
151 readonly payload: RelayDeviceRegistrationRequest;
152 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
153 readonly unregisterDevice: (input: {
154 readonly clerkToken: string;
155 readonly deviceId: string;
156 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
157 readonly registerLiveActivity: (input: {
158 readonly clerkToken: string;
159 readonly payload: RelayLiveActivityRegistrationRequest;
160 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
161 readonly resetTokenCache: Effect.Effect<void>;
162}
163
164244export class ManagedRelayClient extends Context.Service<
165245 ManagedRelayClient,
166 ManagedRelayClientShape
246 {
247 readonly relayUrl: string;
248 readonly listEnvironments: (input: {
249 readonly clerkToken: string;
250 }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>;
251 readonly listDevices: (input: {
252 readonly clerkToken: string;
253 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>;
254 readonly createEnvironmentLinkChallenge: (input: {
255 readonly clerkToken: string;
256 readonly payload: RelayEnvironmentLinkChallengeRequest;
257 }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>;
258 readonly linkEnvironment: (input: {
259 readonly clerkToken: string;
260 readonly payload: RelayEnvironmentLinkRequest;
261 }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>;
262 readonly unlinkEnvironment: (input: {
263 readonly clerkToken: string;
264 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
265 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
266 readonly getEnvironmentStatus: (input: {
267 readonly clerkToken: string;
268 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
269 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
270 }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>;
271 readonly connectEnvironment: (input: {
272 readonly clerkToken: string;
273 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
274 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
275 readonly deviceId?: string;
276 }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>;
277 readonly registerDevice: (input: {
278 readonly clerkToken: string;
279 readonly payload: RelayDeviceRegistrationRequest;
280 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
281 readonly unregisterDevice: (input: {
282 readonly clerkToken: string;
283 readonly deviceId: string;
284 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
285 readonly registerLiveActivity: (input: {
286 readonly clerkToken: string;
287 readonly payload: RelayLiveActivityRegistrationRequest;
288 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
289 readonly resetTokenCache: Effect.Effect<void>;
290 }
167291>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
168292
169293const isRelayProtectedError = Schema.is(RelayProtectedError);
170294
171function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError {
172 return new ManagedRelayClientError({
173 message,
174 ...(cause === undefined ? {} : { cause }),
175 });
176}
177
178function relayLocalError(
179 message: string,
180 cause: ManagedRelayDpopSignerError,
181): ManagedRelayClientError {
182 return new ManagedRelayClientError({ message, cause });
183}
184
185function relayRequestError(message: string) {
295function relayRequestError(action: ManagedRelayRequestAction) {
186296 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
187 new ManagedRelayClientError({
188 message,
297 new ManagedRelayRequestFailedError({
298 action,
189299 cause,
190300 ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}),
191301 });
192302}
193303
304function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) {
305 return {
306 method: error.method,
307 url: error.url,
308 cause: error,
309 };
310}
311
194312function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {
195313 return (
314 error._tag === "ManagedRelayRequestFailedError" &&
196315 error.relayError?._tag === "RelayAuthInvalidError" &&
197316 error.relayError.reason === "invalid_bearer"
198317 );
199318}
200319
201function timeoutRelayRequest(message: string) {
320function timeoutRelayRequest(activity: ManagedRelayRequestActivity) {
202321 return <A, E, R>(
203 request: Effect.Effect<A, E, R>,
322 effect: Effect.Effect<A, E, R>,
204323 ): Effect.Effect<A, E | ManagedRelayClientError, R> =>
205 request.pipe(
324 effect.pipe(
206325 Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)),
207326 Effect.flatMap(
208327 Option.match({
209328 onNone: () =>
210329 Effect.fail(
211 relayClientError(message, new ManagedRelayRequestTimeoutError({ message })),
330 new ManagedRelayRequestTimeoutError({
331 activity,
332 timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
333 }),
212334 ),
213335 onSome: Effect.succeed,
214336 }),
......@@ -258,10 +380,10 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) {
258380 };
259381}
260382
261function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {
383function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] {
262384 const unavailable = (spanName: string) =>
263385 Effect.fn(spanName)(function* () {
264 return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin.");
386 return yield* new ManagedRelayUrlInvalidError({ relayUrl });
265387 });
266388 return ManagedRelayClient.of({
267389 relayUrl,
......@@ -283,482 +405,475 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {
283405 });
284406}
285407
286export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) {
287 return Layer.effect(
288 ManagedRelayClient,
289 Effect.gen(function* () {
290 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
291 if (relayUrl === null) {
292 return disabledManagedRelayClient(options.relayUrl);
293 }
294 const signer = yield* ManagedRelayDpopSigner;
295 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
296 const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : [];
297 const cachedTokens = yield* SynchronizedRef.make<
298 ReadonlyArray<ManagedRelayAccessTokenCacheEntry>
299 >(initialTokens.filter((token) => token.clientId === options.clientId));
300 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
301
302 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
303 const dpopProofTargets = {
304 exchangeAccessToken: (): DpopProofTarget => ({
305 method: RelayExchangeDpopAccessTokenEndpoint.method,
306 url: urlBuilder.token.exchangeDpopAccessToken(),
307 }),
308 getEnvironmentStatus: (
309 environmentId: RelayClientEnvironmentRecord["environmentId"],
310 ): DpopProofTarget => ({
311 method: RelayGetEnvironmentStatusEndpoint.method,
312 url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }),
313 }),
314 connectEnvironment: (
315 environmentId: RelayClientEnvironmentRecord["environmentId"],
316 ): DpopProofTarget => ({
317 method: RelayConnectEnvironmentEndpoint.method,
318 url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }),
319 }),
320 registerDevice: (): DpopProofTarget => ({
321 method: RelayRegisterDeviceEndpoint.method,
322 url: urlBuilder.mobile.registerDevice(),
323 }),
324 unregisterDevice: (deviceId: string): DpopProofTarget => ({
325 method: RelayUnregisterDeviceEndpoint.method,
326 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),
327 }),
328 registerLiveActivity: (): DpopProofTarget => ({
329 method: RelayRegisterLiveActivityEndpoint.method,
330 url: urlBuilder.mobile.registerLiveActivity(),
331 }),
332 };
408export const make = Effect.fn("ManagedRelayClient.make")(function* (
409 options: ManagedRelayClientLayerOptions,
410) {
411 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
412 if (relayUrl === null) {
413 return disabledManagedRelayClient(options.relayUrl);
414 }
415 const signer = yield* ManagedRelayDpopSigner;
416 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
417 const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : [];
418 const cachedTokens = yield* SynchronizedRef.make<
419 ReadonlyArray<ManagedRelayAccessTokenCacheEntry>
420 >(initialTokens.filter((token) => token.clientId === options.clientId));
421 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
422
423 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
424 const dpopProofTargets = {
425 exchangeAccessToken: (): DpopProofTarget => ({
426 method: RelayExchangeDpopAccessTokenEndpoint.method,
427 url: urlBuilder.token.exchangeDpopAccessToken(),
428 }),
429 getEnvironmentStatus: (
430 environmentId: RelayClientEnvironmentRecord["environmentId"],
431 ): DpopProofTarget => ({
432 method: RelayGetEnvironmentStatusEndpoint.method,
433 url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }),
434 }),
435 connectEnvironment: (
436 environmentId: RelayClientEnvironmentRecord["environmentId"],
437 ): DpopProofTarget => ({
438 method: RelayConnectEnvironmentEndpoint.method,
439 url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }),
440 }),
441 registerDevice: (): DpopProofTarget => ({
442 method: RelayRegisterDeviceEndpoint.method,
443 url: urlBuilder.mobile.registerDevice(),
444 }),
445 unregisterDevice: (deviceId: string): DpopProofTarget => ({
446 method: RelayUnregisterDeviceEndpoint.method,
447 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),
448 }),
449 registerLiveActivity: (): DpopProofTarget => ({
450 method: RelayRegisterLiveActivityEndpoint.method,
451 url: urlBuilder.mobile.registerLiveActivity(),
452 }),
453 };
333454
334 const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")(
335 function* (input: {
336 readonly clerkToken: string;
337 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
338 }) {
339 yield* Effect.annotateCurrentSpan({
340 "relay.client_id": options.clientId,
341 "relay.scopes": input.scopes.join(" "),
342 });
343 const proof = yield* signer
344 .createProof(dpopProofTargets.exchangeAccessToken())
345 .pipe(
346 Effect.mapError((cause) =>
347 relayLocalError("Could not create relay token DPoP proof.", cause),
348 ),
349 );
350 const response = yield* client.token
351 .exchangeDpopAccessToken({
352 headers: { dpop: proof },
353 payload: {
354 grant_type: RelayDpopTokenExchangeGrantType,
355 subject_token: input.clerkToken,
356 subject_token_type: RelayJwtSubjectTokenType,
357 requested_token_type: RelayAccessTokenType,
358 resource: relayUrl,
359 scope: encodeOAuthScope(input.scopes),
360 client_id: options.clientId,
361 },
362 })
363 .pipe(
364 Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")),
365 timeoutRelayRequest("Relay DPoP access token exchange timed out."),
366 );
367 if (!oauthScopeSetEquals(response.scope, input.scopes)) {
368 return yield* relayClientError("Relay granted unexpected DPoP access token scopes.");
369 }
370 return response;
371 },
372 );
455 const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")(
456 function* (input: {
457 readonly clerkToken: string;
458 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
459 }) {
460 yield* Effect.annotateCurrentSpan({
461 "relay.client_id": options.clientId,
462 "relay.scopes": input.scopes.join(" "),
463 });
464 const proof = yield* signer
465 .createProof(dpopProofTargets.exchangeAccessToken())
466 .pipe(
467 Effect.mapError(
468 (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)),
469 ),
470 );
471 const response = yield* client.token
472 .exchangeDpopAccessToken({
473 headers: { dpop: proof },
474 payload: {
475 grant_type: RelayDpopTokenExchangeGrantType,
476 subject_token: input.clerkToken,
477 subject_token_type: RelayJwtSubjectTokenType,
478 requested_token_type: RelayAccessTokenType,
479 resource: relayUrl,
480 scope: encodeOAuthScope(input.scopes),
481 client_id: options.clientId,
482 },
483 })
484 .pipe(
485 Effect.mapError(relayRequestError("exchange relay DPoP access token")),
486 timeoutRelayRequest("Relay DPoP access token exchange"),
487 );
488 if (!oauthScopeSetEquals(response.scope, input.scopes)) {
489 return yield* new ManagedRelayAccessTokenScopesUnexpectedError({
490 requestedScopes: input.scopes,
491 grantedScope: response.scope,
492 });
493 }
494 return response;
495 },
496 );
373497
374 const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")(
375 function* (input: {
376 readonly clerkToken: string;
377 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
378 readonly thumbprint: string;
379 }) {
380 yield* Effect.annotateCurrentSpan({
381 "relay.client_id": options.clientId,
382 "relay.scopes": input.scopes.join(" "),
383 });
384 const nowMillis = yield* Clock.currentTimeMillis;
385 const accountId = relayAccountId(input.clerkToken);
386 if (Option.isNone(accountId)) {
387 yield* Effect.annotateCurrentSpan({
388 "relay.token_cache.result": "bypass",
389 "relay.token_cache.bypass_reason": "invalid_subject_token",
390 });
391 const response = yield* exchangeAccessToken(input);
392 return {
393 accountId: "",
498 const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")(
499 function* (input: {
500 readonly clerkToken: string;
501 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
502 readonly thumbprint: string;
503 }) {
504 yield* Effect.annotateCurrentSpan({
505 "relay.client_id": options.clientId,
506 "relay.scopes": input.scopes.join(" "),
507 });
508 const nowMillis = yield* Clock.currentTimeMillis;
509 const accountId = relayAccountId(input.clerkToken);
510 if (Option.isNone(accountId)) {
511 yield* Effect.annotateCurrentSpan({
512 "relay.token_cache.result": "bypass",
513 "relay.token_cache.bypass_reason": "invalid_subject_token",
514 });
515 const response = yield* exchangeAccessToken(input);
516 return {
517 accountId: "",
518 clientId: options.clientId,
519 relayUrl,
520 thumbprint: input.thumbprint,
521 scopes: input.scopes,
522 accessToken: response.access_token,
523 expiresAtMillis: nowMillis + response.expires_in * 1_000,
524 } satisfies ManagedRelayAccessTokenCacheEntry;
525 }
526 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) =>
527 Effect.gen(function* () {
528 const activeTokens = tokens.filter((token) => token.expiresAtMillis > nowMillis + 5_000);
529 const cached = activeTokens.find((token) =>
530 tokenMatches(token, {
531 accountId: accountId.value,
394532 clientId: options.clientId,
395533 relayUrl,
396534 thumbprint: input.thumbprint,
397535 scopes: input.scopes,
398 accessToken: response.access_token,
399 expiresAtMillis: nowMillis + response.expires_in * 1_000,
400 } satisfies ManagedRelayAccessTokenCacheEntry;
536 nowMillis,
537 }),
538 );
539 if (cached) {
540 yield* Effect.annotateCurrentSpan({
541 "relay.token_cache.result": "hit",
542 });
543 return [cached, activeTokens] as const;
401544 }
402 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) =>
403 Effect.gen(function* () {
404 const activeTokens = tokens.filter(
405 (token) => token.expiresAtMillis > nowMillis + 5_000,
406 );
407 const cached = activeTokens.find((token) =>
408 tokenMatches(token, {
409 accountId: accountId.value,
410 clientId: options.clientId,
411 relayUrl,
412 thumbprint: input.thumbprint,
413 scopes: input.scopes,
414 nowMillis,
415 }),
416 );
417 if (cached) {
418 yield* Effect.annotateCurrentSpan({
419 "relay.token_cache.result": "hit",
420 });
421 return [cached, activeTokens] as const;
422 }
423 yield* Effect.annotateCurrentSpan({
424 "relay.token_cache.result": "miss",
425 });
426 const response = yield* exchangeAccessToken(input);
427 const next: ManagedRelayAccessTokenCacheEntry = {
428 accountId: accountId.value,
429 clientId: options.clientId,
430 relayUrl,
431 thumbprint: input.thumbprint,
432 scopes: input.scopes,
433 accessToken: response.access_token,
434 expiresAtMillis: nowMillis + response.expires_in * 1_000,
435 };
436 const nextTokens = [...activeTokens, next];
437 if (options.accessTokenStore) {
438 yield* options.accessTokenStore.save(nextTokens);
545 yield* Effect.annotateCurrentSpan({
546 "relay.token_cache.result": "miss",
547 });
548 const response = yield* exchangeAccessToken(input);
549 const next: ManagedRelayAccessTokenCacheEntry = {
550 accountId: accountId.value,
551 clientId: options.clientId,
552 relayUrl,
553 thumbprint: input.thumbprint,
554 scopes: input.scopes,
555 accessToken: response.access_token,
556 expiresAtMillis: nowMillis + response.expires_in * 1_000,
557 };
558 const nextTokens = [...activeTokens, next];
559 if (options.accessTokenStore) {
560 yield* options.accessTokenStore.save(nextTokens);
561 }
562 return [next, nextTokens] as const;
563 }),
564 ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection"));
565 },
566 );
567
568 const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: {
569 readonly clerkToken: string;
570 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
571 readonly target: DpopProofTarget;
572 }) {
573 yield* Effect.annotateCurrentSpan({
574 "relay.client_id": options.clientId,
575 "relay.scopes": input.scopes.join(" "),
576 "http.request.method": input.target.method,
577 "url.full": input.target.url,
578 });
579 const thumbprint = yield* signer.thumbprint;
580 const token = yield* obtainAccessToken({
581 clerkToken: input.clerkToken,
582 scopes: input.scopes,
583 thumbprint,
584 });
585 const proof = yield* signer
586 .createProof({
587 ...input.target,
588 accessToken: token.accessToken,
589 })
590 .pipe(
591 Effect.mapError(
592 (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)),
593 ),
594 );
595 return { accessToken: token.accessToken, proof, thumbprint };
596 });
597
598 const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")(
599 function* (accessToken: string) {
600 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => {
601 const nextTokens = tokens.filter((token) => token.accessToken !== accessToken);
602 if (nextTokens.length === tokens.length) {
603 return Effect.succeed([false, tokens] as const);
604 }
605 return (
606 options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void
607 ).pipe(Effect.as([true, nextTokens] as const));
608 });
609 },
610 );
611
612 const runDpopRequest = <A>(
613 input: {
614 readonly clerkToken: string;
615 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
616 readonly target: DpopProofTarget;
617 },
618 request: (
619 authorization: ManagedRelayAuthorization,
620 ) => Effect.Effect<A, ManagedRelayClientError>,
621 ): Effect.Effect<A, ManagedRelayClientError> => {
622 const attempt = (refreshRejectedToken: boolean): Effect.Effect<A, ManagedRelayClientError> =>
623 authorize(input).pipe(
624 Effect.flatMap((authorization) =>
625 request(authorization).pipe(
626 Effect.catch((error) => {
627 if (!isRejectedDpopAccessToken(error)) {
628 return Effect.fail(error);
439629 }
440 return [next, nextTokens] as const;
630 return invalidateAccessToken(authorization.accessToken).pipe(
631 Effect.tap((invalidated) =>
632 Effect.annotateCurrentSpan({
633 "relay.token_cache.invalidated": invalidated,
634 "relay.token_cache.invalidation_reason": "invalid_bearer",
635 "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
636 }),
637 ),
638 Effect.tap((invalidated) =>
639 invalidated && refreshRejectedToken
640 ? Effect.logWarning(
641 "Relay rejected a cached DPoP access token; refreshing it once.",
642 )
643 : Effect.void,
644 ),
645 Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
646 );
441647 }),
442 ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection"));
443 },
648 ),
649 ),
444650 );
651 return attempt(true);
652 };
445653
446 const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: {
447 readonly clerkToken: string;
448 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
449 readonly target: DpopProofTarget;
450 }) {
451 yield* Effect.annotateCurrentSpan({
452 "relay.client_id": options.clientId,
453 "relay.scopes": input.scopes.join(" "),
454 "http.request.method": input.target.method,
455 "url.full": input.target.url,
456 });
457 const thumbprint = yield* signer.thumbprint.pipe(
458 Effect.mapError((cause) =>
459 relayLocalError("Could not load relay DPoP proof key.", cause),
460 ),
461 );
462 const token = yield* obtainAccessToken({
463 clerkToken: input.clerkToken,
464 scopes: input.scopes,
465 thumbprint,
466 });
467 const proof = yield* signer
468 .createProof({
469 ...input.target,
470 accessToken: token.accessToken,
654 const mobileRegistrationRequest = <A>(
655 input: {
656 readonly clerkToken: string;
657 readonly target: DpopProofTarget;
658 },
659 request: (
660 authorization: ManagedRelayAuthorization,
661 ) => Effect.Effect<A, ManagedRelayClientError>,
662 ) =>
663 runDpopRequest(
664 {
665 ...input,
666 scopes: [RelayMobileRegistrationScope],
667 },
668 request,
669 );
670
671 return ManagedRelayClient.of({
672 relayUrl,
673 listEnvironments: Effect.fnUntraced(
674 function* (input) {
675 return yield* client.client
676 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
677 .pipe(
678 Effect.map((response) => response.environments),
679 Effect.mapError(relayRequestError("list relay-managed environments")),
680 timeoutRelayRequest("Relay environment listing"),
681 );
682 },
683 Effect.withSpan("clientRuntime.managedRelay.listEnvironments"),
684 withRelayClientTracing,
685 ),
686 listDevices: Effect.fnUntraced(
687 function* (input) {
688 return yield* client.client
689 .listDevices({
690 headers: bearerHeaders(input.clerkToken),
471691 })
472692 .pipe(
473 Effect.mapError((cause) =>
474 relayLocalError("Could not create relay request DPoP proof.", cause),
475 ),
693 Effect.map((response) => response.devices),
694 Effect.mapError(relayRequestError("list relay client devices")),
695 timeoutRelayRequest("Relay client device listing"),
476696 );
477 return { accessToken: token.accessToken, proof, thumbprint };
478 });
479
480 const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")(
481 function* (accessToken: string) {
482 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => {
483 const nextTokens = tokens.filter((token) => token.accessToken !== accessToken);
484 if (nextTokens.length === tokens.length) {
485 return Effect.succeed([false, tokens] as const);
486 }
487 return (
488 options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void
489 ).pipe(Effect.as([true, nextTokens] as const));
490 });
491 },
492 );
493
494 const runDpopRequest = <A>(
495 input: {
496 readonly clerkToken: string;
497 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
498 readonly target: DpopProofTarget;
499 },
500 request: (
501 authorization: ManagedRelayAuthorization,
502 ) => Effect.Effect<A, ManagedRelayClientError>,
503 ): Effect.Effect<A, ManagedRelayClientError> => {
504 const attempt = (
505 refreshRejectedToken: boolean,
506 ): Effect.Effect<A, ManagedRelayClientError> =>
507 authorize(input).pipe(
508 Effect.flatMap((authorization) =>
509 request(authorization).pipe(
510 Effect.catch((error) => {
511 if (!isRejectedDpopAccessToken(error)) {
512 return Effect.fail(error);
513 }
514 return invalidateAccessToken(authorization.accessToken).pipe(
515 Effect.tap((invalidated) =>
516 Effect.annotateCurrentSpan({
517 "relay.token_cache.invalidated": invalidated,
518 "relay.token_cache.invalidation_reason": "invalid_bearer",
519 "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
520 }),
521 ),
522 Effect.tap((invalidated) =>
523 invalidated && refreshRejectedToken
524 ? Effect.logWarning(
525 "Relay rejected a cached DPoP access token; refreshing it once.",
526 )
527 : Effect.void,
528 ),
529 Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
530 );
531 }),
532 ),
533 ),
697 },
698 Effect.withSpan("clientRuntime.managedRelay.listDevices"),
699 withRelayClientTracing,
700 ),
701 createEnvironmentLinkChallenge: Effect.fnUntraced(
702 function* (input) {
703 return yield* client.client
704 .createEnvironmentLinkChallenge({
705 headers: bearerHeaders(input.clerkToken),
706 payload: input.payload,
707 })
708 .pipe(
709 Effect.mapError(relayRequestError("create relay environment link challenge")),
710 timeoutRelayRequest("Relay environment link challenge"),
711 );
712 },
713 Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"),
714 withRelayClientTracing,
715 ),
716 linkEnvironment: Effect.fnUntraced(
717 function* (input) {
718 return yield* client.client
719 .linkEnvironment({
720 headers: bearerHeaders(input.clerkToken),
721 payload: input.payload,
722 })
723 .pipe(
724 Effect.mapError(relayRequestError("link relay environment")),
725 timeoutRelayRequest("Relay environment linking"),
534726 );
535 return attempt(true);
536 };
537
538 const mobileRegistrationRequest = <A>(
539 input: {
540 readonly clerkToken: string;
541 readonly target: DpopProofTarget;
542 },
543 request: (
544 authorization: ManagedRelayAuthorization,
545 ) => Effect.Effect<A, ManagedRelayClientError>,
546 ) =>
547 runDpopRequest(
727 },
728 Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"),
729 withRelayClientTracing,
730 ),
731 unlinkEnvironment: Effect.fnUntraced(
732 function* (input) {
733 return yield* client.client
734 .unlinkEnvironment({
735 headers: bearerHeaders(input.clerkToken),
736 params: { environmentId: input.environmentId },
737 })
738 .pipe(
739 Effect.mapError(relayRequestError("unlink relay environment")),
740 timeoutRelayRequest("Relay environment unlinking"),
741 );
742 },
743 Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"),
744 withRelayClientTracing,
745 ),
746 getEnvironmentStatus: Effect.fnUntraced(
747 function* (input) {
748 yield* Effect.annotateCurrentSpan({
749 "environment.id": input.environmentId,
750 });
751 return yield* runDpopRequest(
548752 {
549 ...input,
550 scopes: [RelayMobileRegistrationScope],
753 clerkToken: input.clerkToken,
754 scopes: input.scopes,
755 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
551756 },
552 request,
553 );
554
555 return ManagedRelayClient.of({
556 relayUrl,
557 listEnvironments: Effect.fnUntraced(
558 function* (input) {
559 return yield* client.client
560 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
757 (authorization) =>
758 client.dpopClient
759 .getEnvironmentStatus({
760 headers: dpopHeaders(authorization),
761 params: { environmentId: input.environmentId },
762 })
561763 .pipe(
562 Effect.map((response) => response.environments),
563 Effect.mapError(relayRequestError("Could not list relay-managed environments.")),
564 timeoutRelayRequest("Relay environment listing timed out."),
565 );
764 Effect.mapError(relayRequestError("get relay environment status")),
765 timeoutRelayRequest("Relay environment status request"),
766 ),
767 );
768 },
769 Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"),
770 withRelayClientTracing,
771 ),
772 connectEnvironment: Effect.fnUntraced(
773 function* (input) {
774 yield* Effect.annotateCurrentSpan({
775 "environment.id": input.environmentId,
776 });
777 return yield* runDpopRequest(
778 {
779 clerkToken: input.clerkToken,
780 scopes: input.scopes,
781 target: dpopProofTargets.connectEnvironment(input.environmentId),
566782 },
567 Effect.withSpan("clientRuntime.managedRelay.listEnvironments"),
568 withRelayClientTracing,
569 ),
570 listDevices: Effect.fnUntraced(
571 function* (input) {
572 return yield* client.client
573 .listDevices({
574 headers: bearerHeaders(input.clerkToken),
783 (authorization) => {
784 const payload: RelayEnvironmentConnectRequest = {
785 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
786 clientKeyThumbprint: authorization.thumbprint,
787 };
788 return client.dpopClient
789 .connectEnvironment({
790 headers: dpopHeaders(authorization),
791 params: { environmentId: input.environmentId },
792 payload,
575793 })
576794 .pipe(
577 Effect.map((response) => response.devices),
578 Effect.mapError(relayRequestError("Could not list relay client devices.")),
579 timeoutRelayRequest("Relay client device listing timed out."),
795 Effect.mapError(relayRequestError("connect relay environment")),
796 timeoutRelayRequest("Relay environment connection"),
580797 );
581798 },
582 Effect.withSpan("clientRuntime.managedRelay.listDevices"),
583 withRelayClientTracing,
584 ),
585 createEnvironmentLinkChallenge: Effect.fnUntraced(
586 function* (input) {
587 return yield* client.client
588 .createEnvironmentLinkChallenge({
589 headers: bearerHeaders(input.clerkToken),
799 );
800 },
801 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),
802 withRelayClientTracing,
803 ),
804 registerDevice: Effect.fnUntraced(
805 function* (input) {
806 return yield* mobileRegistrationRequest(
807 {
808 clerkToken: input.clerkToken,
809 target: dpopProofTargets.registerDevice(),
810 },
811 (authorization) =>
812 client.mobile
813 .registerDevice({
814 headers: dpopHeaders(authorization),
590815 payload: input.payload,
591816 })
592817 .pipe(
593 Effect.mapError(
594 relayRequestError("Could not create relay environment link challenge."),
595 ),
596 timeoutRelayRequest("Relay environment link challenge timed out."),
597 );
818 Effect.mapError(relayRequestError("register relay mobile device")),
819 timeoutRelayRequest("Relay mobile device registration"),
820 ),
821 );
822 },
823 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),
824 withRelayClientTracing,
825 ),
826 unregisterDevice: Effect.fnUntraced(
827 function* (input) {
828 return yield* mobileRegistrationRequest(
829 {
830 clerkToken: input.clerkToken,
831 target: dpopProofTargets.unregisterDevice(input.deviceId),
598832 },
599 Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"),
600 withRelayClientTracing,
601 ),
602 linkEnvironment: Effect.fnUntraced(
603 function* (input) {
604 return yield* client.client
605 .linkEnvironment({
606 headers: bearerHeaders(input.clerkToken),
607 payload: input.payload,
833 (authorization) =>
834 client.mobile
835 .unregisterDevice({
836 headers: dpopHeaders(authorization),
837 params: { deviceId: input.deviceId },
608838 })
609839 .pipe(
610 Effect.mapError(relayRequestError("Could not link relay environment.")),
611 timeoutRelayRequest("Relay environment linking timed out."),
612 );
840 Effect.mapError(relayRequestError("unregister relay mobile device")),
841 timeoutRelayRequest("Relay mobile device unregistration"),
842 ),
843 );
844 },
845 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
846 withRelayClientTracing,
847 ),
848 registerLiveActivity: Effect.fnUntraced(
849 function* (input) {
850 return yield* mobileRegistrationRequest(
851 {
852 clerkToken: input.clerkToken,
853 target: dpopProofTargets.registerLiveActivity(),
613854 },
614 Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"),
615 withRelayClientTracing,
616 ),
617 unlinkEnvironment: Effect.fnUntraced(
618 function* (input) {
619 return yield* client.client
620 .unlinkEnvironment({
621 headers: bearerHeaders(input.clerkToken),
622 params: { environmentId: input.environmentId },
855 (authorization) =>
856 client.mobile
857 .registerLiveActivity({
858 headers: dpopHeaders(authorization),
859 payload: input.payload,
623860 })
624861 .pipe(
625 Effect.mapError(relayRequestError("Could not unlink relay environment.")),
626 timeoutRelayRequest("Relay environment unlinking timed out."),
627 );
628 },
629 Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"),
630 withRelayClientTracing,
631 ),
632 getEnvironmentStatus: Effect.fnUntraced(
633 function* (input) {
634 yield* Effect.annotateCurrentSpan({
635 "environment.id": input.environmentId,
636 });
637 return yield* runDpopRequest(
638 {
639 clerkToken: input.clerkToken,
640 scopes: input.scopes,
641 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
642 },
643 (authorization) =>
644 client.dpopClient
645 .getEnvironmentStatus({
646 headers: dpopHeaders(authorization),
647 params: { environmentId: input.environmentId },
648 })
649 .pipe(
650 Effect.mapError(relayRequestError("Could not get relay environment status.")),
651 timeoutRelayRequest("Relay environment status request timed out."),
652 ),
653 );
654 },
655 Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"),
656 withRelayClientTracing,
657 ),
658 connectEnvironment: Effect.fnUntraced(
659 function* (input) {
660 yield* Effect.annotateCurrentSpan({
661 "environment.id": input.environmentId,
662 });
663 return yield* runDpopRequest(
664 {
665 clerkToken: input.clerkToken,
666 scopes: input.scopes,
667 target: dpopProofTargets.connectEnvironment(input.environmentId),
668 },
669 (authorization) => {
670 const payload: RelayEnvironmentConnectRequest = {
671 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
672 clientKeyThumbprint: authorization.thumbprint,
673 };
674 return client.dpopClient
675 .connectEnvironment({
676 headers: dpopHeaders(authorization),
677 params: { environmentId: input.environmentId },
678 payload,
679 })
680 .pipe(
681 Effect.mapError(relayRequestError("Could not connect relay environment.")),
682 timeoutRelayRequest("Relay environment connection timed out."),
683 );
684 },
685 );
686 },
687 Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"),
688 withRelayClientTracing,
689 ),
690 registerDevice: Effect.fnUntraced(
691 function* (input) {
692 return yield* mobileRegistrationRequest(
693 {
694 clerkToken: input.clerkToken,
695 target: dpopProofTargets.registerDevice(),
696 },
697 (authorization) =>
698 client.mobile
699 .registerDevice({
700 headers: dpopHeaders(authorization),
701 payload: input.payload,
702 })
703 .pipe(
704 Effect.mapError(relayRequestError("Could not register relay mobile device.")),
705 timeoutRelayRequest("Relay mobile device registration timed out."),
706 ),
707 );
708 },
709 Effect.withSpan("clientRuntime.managedRelay.registerDevice"),
710 withRelayClientTracing,
711 ),
712 unregisterDevice: Effect.fnUntraced(
713 function* (input) {
714 return yield* mobileRegistrationRequest(
715 {
716 clerkToken: input.clerkToken,
717 target: dpopProofTargets.unregisterDevice(input.deviceId),
718 },
719 (authorization) =>
720 client.mobile
721 .unregisterDevice({
722 headers: dpopHeaders(authorization),
723 params: { deviceId: input.deviceId },
724 })
725 .pipe(
726 Effect.mapError(relayRequestError("Could not unregister relay mobile device.")),
727 timeoutRelayRequest("Relay mobile device unregistration timed out."),
728 ),
729 );
730 },
731 Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"),
732 withRelayClientTracing,
733 ),
734 registerLiveActivity: Effect.fnUntraced(
735 function* (input) {
736 return yield* mobileRegistrationRequest(
737 {
738 clerkToken: input.clerkToken,
739 target: dpopProofTargets.registerLiveActivity(),
740 },
741 (authorization) =>
742 client.mobile
743 .registerLiveActivity({
744 headers: dpopHeaders(authorization),
745 payload: input.payload,
746 })
747 .pipe(
748 Effect.mapError(relayRequestError("Could not register relay live activity.")),
749 timeoutRelayRequest("Relay Live Activity registration timed out."),
750 ),
751 );
752 },
753 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),
754 withRelayClientTracing,
755 ),
756 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
757 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
758 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
759 withRelayClientTracing,
760 ),
761 });
762 }),
763 );
764}
862 Effect.mapError(relayRequestError("register relay live activity")),
863 timeoutRelayRequest("Relay Live Activity registration"),
864 ),
865 );
866 },
867 Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"),
868 withRelayClientTracing,
869 ),
870 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
871 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
872 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
873 withRelayClientTracing,
874 ),
875 });
876});
877
878export const layer = (options: ManagedRelayClientLayerOptions) =>
879 Layer.effect(ManagedRelayClient, make(options));
packages/client-runtime/src/relay/managedRelayState.test.ts+7-10
......@@ -12,11 +12,7 @@ import * as Stream from "effect/Stream";
1212import { Atom, AtomRegistry } from "effect/unstable/reactivity";
1313import { afterEach, vi } from "vite-plus/test";
1414
15import {
16 ManagedRelayClient,
17 ManagedRelayClientError,
18 type ManagedRelayClientShape,
19} from "./managedRelay.ts";
15import * as ManagedRelay from "./managedRelay.ts";
2016import {
2117 createManagedRelayQueryManager,
2218 createManagedRelaySession,
......@@ -66,10 +62,10 @@ function resetRegistry() {
6662}
6763
6864function createManager(
69 overrides?: Partial<ManagedRelayClientShape>,
65 overrides?: Partial<ManagedRelay.ManagedRelayClient["Service"]>,
7066 onQueryEvent?: (event: ManagedRelayQueryEvent) => void,
7167) {
72 const client = ManagedRelayClient.of({
68 const client = ManagedRelay.ManagedRelayClient.of({
7369 relayUrl: "https://relay.example.test",
7470 listEnvironments: () => Effect.succeed([environment]),
7571 listDevices: () => Effect.succeed([device]),
......@@ -90,7 +86,7 @@ function createManager(
9086 resetTokenCache: Effect.void,
9187 ...overrides,
9288 });
93 const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client));
89 const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client));
9490 return createManagedRelayQueryManager(runtime, {
9591 staleTimeMs: 60_000,
9692 ...(onQueryEvent ? { onQueryEvent } : {}),
......@@ -363,8 +359,9 @@ describe("createManagedRelayQueryManager", () => {
363359 const manager = createManager({
364360 getEnvironmentStatus: () =>
365361 Effect.fail(
366 new ManagedRelayClientError({
367 message: "Could not get relay environment status.",
362 new ManagedRelay.ManagedRelayRequestFailedError({
363 action: "get relay environment status",
364 cause: new Error("Relay request failed."),
368365 traceId: "trace-status",
369366 }),
370367 ),
packages/client-runtime/src/relay/managedRelayState.ts+5-5
......@@ -16,7 +16,7 @@ import * as Stream from "effect/Stream";
1616import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1717
1818import { findErrorTraceId } from "../errors/errorTrace.ts";
19import { ManagedRelayClient } from "./managedRelay.ts";
19import * as ManagedRelay from "./managedRelay.ts";
2020
2121const DEFAULT_STALE_TIME_MS = 15_000;
2222const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
......@@ -308,7 +308,7 @@ export function readManagedRelaySnapshotState<A>(
308308}
309309
310310export function createManagedRelayQueryManager(
311 runtime: Atom.AtomRuntime<ManagedRelayClient>,
311 runtime: Atom.AtomRuntime<ManagedRelay.ManagedRelayClient>,
312312 options?: {
313313 readonly staleTimeMs?: number;
314314 readonly idleTtlMs?: number;
......@@ -351,7 +351,7 @@ export function createManagedRelayQueryManager(
351351 { ...base, stage: "clerk-token" },
352352 requireClerkToken(get, accountId),
353353 );
354 const relay = yield* ManagedRelayClient;
354 const relay = yield* ManagedRelay.ManagedRelayClient;
355355 return yield* observe(
356356 { ...base, stage: "relay-request" },
357357 relay.listEnvironments({ clerkToken }),
......@@ -374,7 +374,7 @@ export function createManagedRelayQueryManager(
374374 { ...base, stage: "clerk-token" },
375375 requireClerkToken(get, accountId),
376376 );
377 const relay = yield* ManagedRelayClient;
377 const relay = yield* ManagedRelay.ManagedRelayClient;
378378 return yield* observe(
379379 { ...base, stage: "relay-request" },
380380 relay.listDevices({ clerkToken }),
......@@ -402,7 +402,7 @@ export function createManagedRelayQueryManager(
402402 { ...base, stage: "clerk-token" },
403403 requireClerkToken(get, accountId),
404404 );
405 const relay = yield* ManagedRelayClient;
405 const relay = yield* ManagedRelay.ManagedRelayClient;
406406 const status = yield* observe(
407407 { ...base, stage: "relay-request" },
408408 relay.getEnvironmentStatus({
packages/client-runtime/src/state/relayDiscovery.ts+8-9
......@@ -4,34 +4,33 @@ import * as Stream from "effect/Stream";
44import * as SubscriptionRef from "effect/SubscriptionRef";
55import { AsyncResult, Atom } from "effect/unstable/reactivity";
66
7import {
8 EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
9 RelayEnvironmentDiscovery,
10} from "../relay/discovery.ts";
7import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
118import { createRuntimeCommand } from "./runtime.ts";
129
1310export function createRelayEnvironmentDiscoveryAtoms<R, E>(
14 runtime: Atom.AtomRuntime<RelayEnvironmentDiscovery | R, E>,
11 runtime: Atom.AtomRuntime<RelayEnvironmentDiscovery.RelayEnvironmentDiscovery | R, E>,
1512) {
1613 const stateAtom = runtime.atom(
1714 Stream.unwrap(
18 RelayEnvironmentDiscovery.pipe(
15 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe(
1916 Effect.map((discovery) => SubscriptionRef.changes(discovery.state)),
2017 ),
2118 ),
22 { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE },
19 { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE },
2320 );
2421 const stateValueAtom = Atom.make((get) =>
2522 Option.getOrElse(
2623 AsyncResult.value(get(stateAtom)),
27 () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
24 () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE,
2825 ),
2926 ).pipe(Atom.withLabel("relay-environment-discovery-value"));
3027 const refresh = createRuntimeCommand(runtime, {
3128 label: "relay-environment-discovery:refresh",
3229 concurrency: { mode: "singleFlight", key: () => "refresh" },
3330 execute: (_input: void) =>
34 RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)),
31 RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.pipe(
32 Effect.flatMap((discovery) => discovery.refresh),
33 ),
3534 });
3635
3736 return {