1import {
2 RelayAccessTokenType,
3 RelayApi,
4 type RelayClientEnvironmentRecord,
5 type RelayClientDeviceRecord,
6 RelayConnectEnvironmentEndpoint,
7 type RelayDeviceRegistrationRequest,
8 RelayDpopAccessTokenScope,
9 RelayDpopTokenExchangeGrantType,
10 type RelayEnvironmentConnectRequest,
11 type RelayEnvironmentConnectResponse,
12 type RelayEnvironmentLinkChallengeRequest,
13 type RelayEnvironmentLinkChallengeResponse,
14 type RelayEnvironmentLinkRequest,
15 type RelayEnvironmentLinkResponse,
16 type RelayEnvironmentStatusResponse,
17 RelayExchangeDpopAccessTokenEndpoint,
18 RelayGetEnvironmentStatusEndpoint,
19 RelayJwtSubjectTokenType,
20 type RelayAgentActivitySnapshotResponse,
21 type RelayLiveActivityRegistrationRequest,
22 RelayMobileRegistrationScope,
23 type RelayOkResponse,
24 type RelayPublicClientId,
25 RelayRegisterDeviceEndpoint,
26 RelayAgentActivitySnapshotEndpoint,
27 RelayRegisterLiveActivityEndpoint,
28 RelayProtectedError,
29 type RelayProtectedError as RelayProtectedErrorType,
30 RelayUnregisterDeviceEndpoint,
31} from "@t3tools/contracts/relay";
32import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
33import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
34import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
35import * as Clock from "effect/Clock";
36import * as Context from "effect/Context";
37import * as Duration from "effect/Duration";
38import * as Effect from "effect/Effect";
39import * as Layer from "effect/Layer";
40import * as Option from "effect/Option";
41import * as Schema from "effect/Schema";
42import * as SynchronizedRef from "effect/SynchronizedRef";
43import * as HttpClientError from "effect/http/HttpClientError";
44import type * as HttpMethod from "effect/http/HttpMethod";
45import * as HttpApiClient from "effect/http-api/HttpApiClient";
46import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
47
48export interface ManagedRelayDpopProofInput {
49 readonly method: HttpMethod.HttpMethod;
50 readonly url: string;
51 readonly accessToken?: string;
52}
53
54export class ManagedRelayDpopKeyLoadError extends Schema.TaggedError<ManagedRelayDpopKeyLoadError>()(
55 "ManagedRelayDpopKeyLoadError",
56 {
57 keyStore: Schema.Literals(["expo-secure-store", "indexed-db"]),
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 return "Could not load relay DPoP proof key.";
63 }
64}
65
66export class ManagedRelayDpopProofCreationError extends Schema.TaggedError<ManagedRelayDpopProofCreationError>()(
67 "ManagedRelayDpopProofCreationError",
68 {
69 method: Schema.String,
70 url: Schema.String,
71 cause: Schema.Defect(),
72 },
73) {
74 override get message(): string {
75 return `Could not create the relay DPoP proof for ${this.method} ${this.url}.`;
76 }
77}
78
79export const ManagedRelayDpopSignerError = Schema.Union([
80 ManagedRelayDpopKeyLoadError,
81 ManagedRelayDpopProofCreationError,
82]);
83export type ManagedRelayDpopSignerError = typeof ManagedRelayDpopSignerError.Type;
84
85export const ManagedRelayRequestAction = Schema.Literals([
86 "exchange relay DPoP access token",
87 "list relay-managed environments",
88 "list relay client devices",
89 "create relay environment link challenge",
90 "link relay environment",
91 "unlink relay environment",
92 "get relay environment status",
93 "connect relay environment",
94 "register relay mobile device",
95 "unregister relay mobile device",
96 "register relay live activity",
97 "read relay agent activity snapshot",
98]);
99export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type;
100
101export const ManagedRelayRequestActivity = Schema.Literals([
102 "Relay DPoP access token exchange",
103 "Relay environment listing",
104 "Relay client device listing",
105 "Relay environment link challenge",
106 "Relay environment linking",
107 "Relay environment unlinking",
108 "Relay environment status request",
109 "Relay environment connection",
110 "Relay mobile device registration",
111 "Relay mobile device unregistration",
112 "Relay Live Activity registration",
113 "Relay agent activity snapshot",
114]);
115export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type;
116
117export class ManagedRelayRequestTimeoutError extends Schema.TaggedError<ManagedRelayRequestTimeoutError>()(
118 "ManagedRelayRequestTimeoutError",
119 {
120 activity: ManagedRelayRequestActivity,
121 timeoutMs: Schema.Number,
122 // The CLIENT span's trace id. A timed-out request has no server response
123 // to take an id from, but the client span was exported, so carrying its id
124 // makes the failure searchable instead of logging `traceId: null`.
125 traceId: Schema.NullOr(Schema.String),
126 },
127) {
128 override get message(): string {
129 return `${this.activity} timed out. ${NETWORK_BLOCKING_HINT}`;
130 }
131}
132
133export class ManagedRelayUrlInvalidError extends Schema.TaggedError<ManagedRelayUrlInvalidError>()(
134 "ManagedRelayUrlInvalidError",
135 {
136 relayUrl: Schema.String,
137 },
138) {
139 override get message(): string {
140 return "Relay URL must be a secure absolute HTTPS origin.";
141 }
142}
143
144export class ManagedRelayRequestFailedError extends Schema.TaggedError<ManagedRelayRequestFailedError>()(
145 "ManagedRelayRequestFailedError",
146 {
147 action: ManagedRelayRequestAction,
148 transportFailed: Schema.optionalKey(Schema.Boolean),
149 cause: Schema.Defect(),
150 relayError: Schema.optional(RelayProtectedError),
151 // HTTP status of a relay response that did not decode as a typed relay error.
152 status: Schema.optional(Schema.Number),
153 traceId: Schema.optional(Schema.String),
154 },
155) {
156 override get message(): string {
157 const message = `Could not ${this.action}.`;
158 return this.transportFailed ? `${message} ${NETWORK_BLOCKING_HINT}` : message;
159 }
160}
161
162export class ManagedRelayAccessTokenScopesUnexpectedError extends Schema.TaggedError<ManagedRelayAccessTokenScopesUnexpectedError>()(
163 "ManagedRelayAccessTokenScopesUnexpectedError",
164 {
165 requestedScopes: Schema.Array(RelayDpopAccessTokenScope),
166 grantedScope: Schema.String,
167 },
168) {
169 override get message(): string {
170 return "Relay granted unexpected DPoP access token scopes.";
171 }
172}
173
174export class ManagedRelayTokenProofCreationError extends Schema.TaggedError<ManagedRelayTokenProofCreationError>()(
175 "ManagedRelayTokenProofCreationError",
176 {
177 method: Schema.String,
178 url: Schema.String,
179 cause: Schema.Defect(),
180 },
181) {
182 override get message(): string {
183 return "Could not create relay token DPoP proof.";
184 }
185}
186
187export class ManagedRelayRequestProofCreationError extends Schema.TaggedError<ManagedRelayRequestProofCreationError>()(
188 "ManagedRelayRequestProofCreationError",
189 {
190 method: Schema.String,
191 url: Schema.String,
192 cause: Schema.Defect(),
193 },
194) {
195 override get message(): string {
196 return "Could not create relay request DPoP proof.";
197 }
198}
199
200export const ManagedRelayClientError = Schema.Union([
201 ManagedRelayUrlInvalidError,
202 ManagedRelayRequestFailedError,
203 ManagedRelayRequestTimeoutError,
204 ManagedRelayAccessTokenScopesUnexpectedError,
205 ManagedRelayDpopKeyLoadError,
206 ManagedRelayTokenProofCreationError,
207 ManagedRelayRequestProofCreationError,
208]);
209export type ManagedRelayClientError = typeof ManagedRelayClientError.Type;
210
211type RelayHttpRequestError =
212 | RelayProtectedErrorType
213 | HttpClientError.HttpClientError
214 | Schema.SchemaError;
215
216export class ManagedRelayDpopSigner extends Context.Service<
217 ManagedRelayDpopSigner,
218 {
219 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopKeyLoadError>;
220 readonly createProof: (
221 input: ManagedRelayDpopProofInput,
222 ) => Effect.Effect<string, ManagedRelayDpopProofCreationError>;
223 }
224>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {}
225
226export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
227
228export interface ManagedRelayAccessTokenCacheEntry {
229 readonly accountId: string;
230 readonly clientId: RelayPublicClientId;
231 readonly relayUrl: string;
232 readonly thumbprint: string;
233 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
234 readonly accessToken: string;
235 readonly expiresAtMillis: number;
236}
237
238export interface ManagedRelayAccessTokenStore {
239 readonly load: Effect.Effect<ReadonlyArray<ManagedRelayAccessTokenCacheEntry>>;
240 readonly save: (entries: ReadonlyArray<ManagedRelayAccessTokenCacheEntry>) => Effect.Effect<void>;
241 readonly clear: Effect.Effect<void>;
242}
243
244export interface ManagedRelayAuthorization {
245 readonly accessToken: string;
246 readonly proof: string;
247 readonly thumbprint: string;
248}
249
250export interface ManagedRelayClientLayerOptions {
251 readonly relayUrl: string;
252 readonly clientId: RelayPublicClientId;
253 readonly accessTokenStore?: ManagedRelayAccessTokenStore;
254}
255
256export class ManagedRelayClient extends Context.Service<
257 ManagedRelayClient,
258 {
259 readonly relayUrl: string;
260 readonly listEnvironments: (input: {
261 readonly clerkToken: string;
262 }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>;
263 readonly listDevices: (input: {
264 readonly clerkToken: string;
265 }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>;
266 readonly createEnvironmentLinkChallenge: (input: {
267 readonly clerkToken: string;
268 readonly payload: RelayEnvironmentLinkChallengeRequest;
269 }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>;
270 readonly linkEnvironment: (input: {
271 readonly clerkToken: string;
272 readonly payload: RelayEnvironmentLinkRequest;
273 }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>;
274 readonly unlinkEnvironment: (input: {
275 readonly clerkToken: string;
276 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
277 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
278 readonly getEnvironmentStatus: (input: {
279 readonly clerkToken: string;
280 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
281 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
282 }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>;
283 readonly connectEnvironment: (input: {
284 readonly clerkToken: string;
285 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
286 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
287 readonly deviceId?: string;
288 }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>;
289 readonly registerDevice: (input: {
290 readonly clerkToken: string;
291 readonly payload: RelayDeviceRegistrationRequest;
292 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
293 readonly unregisterDevice: (input: {
294 readonly clerkToken: string;
295 readonly deviceId: string;
296 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
297 readonly registerLiveActivity: (input: {
298 readonly clerkToken: string;
299 readonly payload: RelayLiveActivityRegistrationRequest;
300 }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>;
301 readonly getAgentActivitySnapshot: (input: {
302 readonly clerkToken: string;
303 }) => Effect.Effect<RelayAgentActivitySnapshotResponse, ManagedRelayClientError>;
304 readonly resetTokenCache: Effect.Effect<void>;
305 }
306>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {}
307
308const isRelayProtectedError = Schema.is(RelayProtectedError);
309
310const TRACEPARENT_TRACE_ID = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/i;
311
312// Keeps the status and the relay's traceparent trace ID for a response that
313// carries no typed relay error, such as the relay's request-deadline 504.
314function untypedResponseFields(cause: RelayHttpRequestError) {
315 const response = HttpClientError.isHttpClientError(cause) ? cause.response : undefined;
316 if (!response) return {};
317 const traceId = TRACEPARENT_TRACE_ID.exec(response.headers.traceparent ?? "")?.[1];
318 return { status: response.status, ...(traceId ? { traceId } : {}) };
319}
320
321function relayRequestError(action: ManagedRelayRequestAction) {
322 return (cause: RelayHttpRequestError): ManagedRelayClientError =>
323 new ManagedRelayRequestFailedError({
324 action,
325 transportFailed:
326 HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError",
327 cause,
328 ...(isRelayProtectedError(cause)
329 ? { relayError: cause, traceId: cause.traceId }
330 : untypedResponseFields(cause)),
331 });
332}
333
334function proofCreationErrorFields(error: ManagedRelayDpopProofCreationError) {
335 return {
336 method: error.method,
337 url: error.url,
338 cause: error,
339 };
340}
341
342function isRejectedDpopAccessToken(error: ManagedRelayClientError): boolean {
343 return (
344 error._tag === "ManagedRelayRequestFailedError" &&
345 error.relayError?._tag === "RelayAuthInvalidError" &&
346 error.relayError.reason === "invalid_bearer"
347 );
348}
349
350function timeoutRelayRequest(activity: ManagedRelayRequestActivity) {
351 return <A, E, R>(
352 effect: Effect.Effect<A, E, R>,
353 ): Effect.Effect<A, E | ManagedRelayClientError, R> =>
354 effect.pipe(
355 Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)),
356 Effect.flatMap(
357 Option.match({
358 onNone: () =>
359 Effect.currentParentSpan.pipe(
360 Effect.map((span) => span.traceId),
361 Effect.orElseSucceed(() => null),
362 Effect.flatMap((traceId) =>
363 Effect.fail(
364 new ManagedRelayRequestTimeoutError({
365 activity,
366 timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS,
367 traceId,
368 }),
369 ),
370 ),
371 ),
372 onSome: Effect.succeed,
373 }),
374 ),
375 );
376}
377
378function tokenMatches(
379 token: ManagedRelayAccessTokenCacheEntry,
380 input: {
381 readonly accountId: string;
382 readonly clientId: RelayPublicClientId;
383 readonly relayUrl: string;
384 readonly thumbprint: string;
385 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
386 readonly nowMillis: number;
387 },
388): boolean {
389 return (
390 token.accountId === input.accountId &&
391 token.clientId === input.clientId &&
392 token.relayUrl === input.relayUrl &&
393 token.thumbprint === input.thumbprint &&
394 token.expiresAtMillis > input.nowMillis + 5_000 &&
395 input.scopes.every((scope) => token.scopes.includes(scope))
396 );
397}
398
399function relayAccountId(clerkToken: string): Option.Option<string> {
400 try {
401 return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe(
402 Option.filter((subject) => subject.length > 0),
403 );
404 } catch {
405 return Option.none();
406 }
407}
408
409function bearerHeaders(clerkToken: string) {
410 return { authorization: `Bearer ${clerkToken}` };
411}
412
413function dpopHeaders(authorization: ManagedRelayAuthorization) {
414 return {
415 authorization: `DPoP ${authorization.accessToken}`,
416 dpop: authorization.proof,
417 };
418}
419
420function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Service"] {
421 const unavailable = (spanName: string) =>
422 Effect.fn(spanName)(function* () {
423 return yield* new ManagedRelayUrlInvalidError({ relayUrl });
424 });
425 return ManagedRelayClient.of({
426 relayUrl,
427 listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"),
428 listDevices: unavailable("clientRuntime.managedRelay.listDevices"),
429 createEnvironmentLinkChallenge: unavailable(
430 "clientRuntime.managedRelay.createEnvironmentLinkChallenge",
431 ),
432 linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"),
433 unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"),
434 getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"),
435 connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"),
436 registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"),
437 unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"),
438 registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"),
439 getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"),
440 resetTokenCache: Effect.void.pipe(
441 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
442 ),
443 });
444}
445
446/** @public Service construction is part of the canonical Effect module API. */
447export const make = Effect.fn("ManagedRelayClient.make")(function* (
448 options: ManagedRelayClientLayerOptions,
449) {
450 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
451 if (relayUrl === null) {
452 return disabledManagedRelayClient(options.relayUrl);
453 }
454 const signer = yield* ManagedRelayDpopSigner;
455 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
456 const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : [];
457 const cachedTokens = yield* SynchronizedRef.make<
458 ReadonlyArray<ManagedRelayAccessTokenCacheEntry>
459 >(initialTokens.filter((token) => token.clientId === options.clientId));
460 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
461
462 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
463 const dpopProofTargets = {
464 exchangeAccessToken: (): DpopProofTarget => ({
465 method: RelayExchangeDpopAccessTokenEndpoint.method,
466 url: urlBuilder.token.exchangeDpopAccessToken(),
467 }),
468 getEnvironmentStatus: (
469 environmentId: RelayClientEnvironmentRecord["environmentId"],
470 ): DpopProofTarget => ({
471 method: RelayGetEnvironmentStatusEndpoint.method,
472 url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }),
473 }),
474 connectEnvironment: (
475 environmentId: RelayClientEnvironmentRecord["environmentId"],
476 ): DpopProofTarget => ({
477 method: RelayConnectEnvironmentEndpoint.method,
478 url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }),
479 }),
480 registerDevice: (): DpopProofTarget => ({
481 method: RelayRegisterDeviceEndpoint.method,
482 url: urlBuilder.mobile.registerDevice(),
483 }),
484 unregisterDevice: (deviceId: string): DpopProofTarget => ({
485 method: RelayUnregisterDeviceEndpoint.method,
486 url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }),
487 }),
488 getAgentActivitySnapshot: (): DpopProofTarget => ({
489 method: RelayAgentActivitySnapshotEndpoint.method,
490 url: urlBuilder.mobile.getAgentActivitySnapshot(),
491 }),
492 registerLiveActivity: (): DpopProofTarget => ({
493 method: RelayRegisterLiveActivityEndpoint.method,
494 url: urlBuilder.mobile.registerLiveActivity(),
495 }),
496 };
497
498 const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")(
499 function* (input: {
500 readonly clerkToken: string;
501 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
502 }) {
503 yield* Effect.annotateCurrentSpan({
504 "relay.client_id": options.clientId,
505 "relay.scopes": input.scopes.join(" "),
506 });
507 const proof = yield* signer
508 .createProof(dpopProofTargets.exchangeAccessToken())
509 .pipe(
510 Effect.mapError(
511 (error) => new ManagedRelayTokenProofCreationError(proofCreationErrorFields(error)),
512 ),
513 );
514 const response = yield* client.token
515 .exchangeDpopAccessToken({
516 headers: { dpop: proof },
517 payload: {
518 grant_type: RelayDpopTokenExchangeGrantType,
519 subject_token: input.clerkToken,
520 subject_token_type: RelayJwtSubjectTokenType,
521 requested_token_type: RelayAccessTokenType,
522 resource: relayUrl,
523 scope: encodeOAuthScope(input.scopes),
524 client_id: options.clientId,
525 },
526 })
527 .pipe(
528 Effect.mapError(relayRequestError("exchange relay DPoP access token")),
529 timeoutRelayRequest("Relay DPoP access token exchange"),
530 );
531 if (!oauthScopeSetEquals(response.scope, input.scopes)) {
532 return yield* new ManagedRelayAccessTokenScopesUnexpectedError({
533 requestedScopes: input.scopes,
534 grantedScope: response.scope,
535 });
536 }
537 return response;
538 },
539 );
540
541 const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")(
542 function* (input: {
543 readonly clerkToken: string;
544 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
545 readonly thumbprint: string;
546 }) {
547 yield* Effect.annotateCurrentSpan({
548 "relay.client_id": options.clientId,
549 "relay.scopes": input.scopes.join(" "),
550 });
551 const nowMillis = yield* Clock.currentTimeMillis;
552 const accountId = relayAccountId(input.clerkToken);
553 if (Option.isNone(accountId)) {
554 yield* Effect.annotateCurrentSpan({
555 "relay.token_cache.result": "bypass",
556 "relay.token_cache.bypass_reason": "invalid_subject_token",
557 });
558 const response = yield* exchangeAccessToken(input);
559 return {
560 accountId: "",
561 clientId: options.clientId,
562 relayUrl,
563 thumbprint: input.thumbprint,
564 scopes: input.scopes,
565 accessToken: response.access_token,
566 expiresAtMillis: nowMillis + response.expires_in * 1_000,
567 } satisfies ManagedRelayAccessTokenCacheEntry;
568 }
569 const match = {
570 accountId: accountId.value,
571 clientId: options.clientId,
572 relayUrl,
573 thumbprint: input.thumbprint,
574 scopes: input.scopes,
575 nowMillis,
576 };
577 // Cache hits do not need to wait for an unrelated exchange or store write.
578 const cached = (yield* SynchronizedRef.get(cachedTokens)).find((token) =>
579 tokenMatches(token, match),
580 );
581 if (cached) {
582 yield* Effect.annotateCurrentSpan({
583 "relay.token_cache.result": "hit",
584 });
585 return cached;
586 }
587 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) =>
588 Effect.gen(function* () {
589 const lookupMillis = yield* Clock.currentTimeMillis;
590 const activeTokens = tokens.filter(
591 (token) => token.expiresAtMillis > lookupMillis + 5_000,
592 );
593 const cached = activeTokens.find((token) =>
594 tokenMatches(token, { ...match, nowMillis: lookupMillis }),
595 );
596 if (cached) {
597 yield* Effect.annotateCurrentSpan({
598 "relay.token_cache.result": "hit",
599 });
600 return [cached, activeTokens] as const;
601 }
602 yield* Effect.annotateCurrentSpan({
603 "relay.token_cache.result": "miss",
604 });
605 const response = yield* exchangeAccessToken(input);
606 const next: ManagedRelayAccessTokenCacheEntry = {
607 accountId: accountId.value,
608 clientId: options.clientId,
609 relayUrl,
610 thumbprint: input.thumbprint,
611 scopes: input.scopes,
612 accessToken: response.access_token,
613 expiresAtMillis: lookupMillis + response.expires_in * 1_000,
614 };
615 const nextTokens = [...activeTokens, next];
616 if (options.accessTokenStore) {
617 yield* options.accessTokenStore.save(nextTokens);
618 }
619 return [next, nextTokens] as const;
620 }),
621 ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection"));
622 },
623 );
624
625 const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: {
626 readonly clerkToken: string;
627 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
628 readonly target: DpopProofTarget;
629 }) {
630 yield* Effect.annotateCurrentSpan({
631 "relay.client_id": options.clientId,
632 "relay.scopes": input.scopes.join(" "),
633 "http.request.method": input.target.method,
634 "url.full": input.target.url,
635 });
636 const thumbprint = yield* signer.thumbprint;
637 const token = yield* obtainAccessToken({
638 clerkToken: input.clerkToken,
639 scopes: input.scopes,
640 thumbprint,
641 });
642 const proof = yield* signer
643 .createProof({
644 ...input.target,
645 accessToken: token.accessToken,
646 })
647 .pipe(
648 Effect.mapError(
649 (error) => new ManagedRelayRequestProofCreationError(proofCreationErrorFields(error)),
650 ),
651 );
652 return { accessToken: token.accessToken, proof, thumbprint };
653 });
654
655 const invalidateAccessToken = Effect.fn("clientRuntime.managedRelay.invalidateAccessToken")(
656 function* (accessToken: string) {
657 return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => {
658 const nextTokens = tokens.filter((token) => token.accessToken !== accessToken);
659 if (nextTokens.length === tokens.length) {
660 return Effect.succeed([false, tokens] as const);
661 }
662 return (
663 options.accessTokenStore ? options.accessTokenStore.save(nextTokens) : Effect.void
664 ).pipe(Effect.as([true, nextTokens] as const));
665 });
666 },
667 );
668
669 const runDpopRequest = <A>(
670 input: {
671 readonly clerkToken: string;
672 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
673 readonly target: DpopProofTarget;
674 },
675 request: (
676 authorization: ManagedRelayAuthorization,
677 ) => Effect.Effect<A, ManagedRelayClientError>,
678 ): Effect.Effect<A, ManagedRelayClientError> => {
679 const attempt = (refreshRejectedToken: boolean): Effect.Effect<A, ManagedRelayClientError> =>
680 authorize(input).pipe(
681 Effect.flatMap((authorization) =>
682 request(authorization).pipe(
683 Effect.catchIf(isRejectedDpopAccessToken, (error) =>
684 invalidateAccessToken(authorization.accessToken).pipe(
685 Effect.tap((invalidated) =>
686 Effect.annotateCurrentSpan({
687 "relay.token_cache.invalidated": invalidated,
688 "relay.token_cache.invalidation_reason": "invalid_bearer",
689 "relay.token_cache.retry_after_invalidation": refreshRejectedToken,
690 }),
691 ),
692 Effect.tap((invalidated) =>
693 invalidated && refreshRejectedToken
694 ? Effect.logWarning(
695 "Relay rejected a cached DPoP access token; refreshing it once.",
696 )
697 : Effect.void,
698 ),
699 Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)),
700 ),
701 ),
702 ),
703 ),
704 );
705 return attempt(true);
706 };
707
708 const mobileRegistrationRequest = <A>(
709 input: {
710 readonly clerkToken: string;
711 readonly target: DpopProofTarget;
712 },
713 request: (
714 authorization: ManagedRelayAuthorization,
715 ) => Effect.Effect<A, ManagedRelayClientError>,
716 ) =>
717 runDpopRequest(
718 {
719 ...input,
720 scopes: [RelayMobileRegistrationScope],
721 },
722 request,
723 );
724
725 return ManagedRelayClient.of({
726 relayUrl,
727 listEnvironments: Effect.fnUntraced(function* (input) {
728 return yield* client.client
729 .listEnvironments({ headers: bearerHeaders(input.clerkToken) })
730 .pipe(
731 Effect.map((response) => response.environments),
732 Effect.mapError(relayRequestError("list relay-managed environments")),
733 timeoutRelayRequest("Relay environment listing"),
734 );
735 }, Effect.withSpan("clientRuntime.managedRelay.listEnvironments")),
736 listDevices: Effect.fnUntraced(function* (input) {
737 return yield* client.client
738 .listDevicesV2({
739 headers: bearerHeaders(input.clerkToken),
740 })
741 .pipe(
742 Effect.map((response) => response.devices),
743 Effect.mapError(relayRequestError("list relay client devices")),
744 timeoutRelayRequest("Relay client device listing"),
745 );
746 }, Effect.withSpan("clientRuntime.managedRelay.listDevices")),
747 createEnvironmentLinkChallenge: Effect.fnUntraced(function* (input) {
748 return yield* client.client
749 .createEnvironmentLinkChallenge({
750 headers: bearerHeaders(input.clerkToken),
751 payload: input.payload,
752 })
753 .pipe(
754 Effect.mapError(relayRequestError("create relay environment link challenge")),
755 timeoutRelayRequest("Relay environment link challenge"),
756 );
757 }, Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge")),
758 linkEnvironment: Effect.fnUntraced(function* (input) {
759 return yield* client.client
760 .linkEnvironment({
761 headers: bearerHeaders(input.clerkToken),
762 payload: input.payload,
763 })
764 .pipe(
765 Effect.mapError(relayRequestError("link relay environment")),
766 timeoutRelayRequest("Relay environment linking"),
767 );
768 }, Effect.withSpan("clientRuntime.managedRelay.linkEnvironment")),
769 unlinkEnvironment: Effect.fnUntraced(function* (input) {
770 return yield* client.client
771 .unlinkEnvironment({
772 headers: bearerHeaders(input.clerkToken),
773 params: { environmentId: input.environmentId },
774 })
775 .pipe(
776 Effect.mapError(relayRequestError("unlink relay environment")),
777 timeoutRelayRequest("Relay environment unlinking"),
778 );
779 }, Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment")),
780 getEnvironmentStatus: Effect.fnUntraced(function* (input) {
781 yield* Effect.annotateCurrentSpan({
782 "environment.id": input.environmentId,
783 });
784 return yield* runDpopRequest(
785 {
786 clerkToken: input.clerkToken,
787 scopes: input.scopes,
788 target: dpopProofTargets.getEnvironmentStatus(input.environmentId),
789 },
790 (authorization) =>
791 client.dpopClient
792 .getEnvironmentStatus({
793 headers: dpopHeaders(authorization),
794 params: { environmentId: input.environmentId },
795 })
796 .pipe(
797 Effect.mapError(relayRequestError("get relay environment status")),
798 timeoutRelayRequest("Relay environment status request"),
799 ),
800 );
801 }, Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus")),
802 connectEnvironment: Effect.fnUntraced(function* (input) {
803 yield* Effect.annotateCurrentSpan({
804 "environment.id": input.environmentId,
805 });
806 return yield* runDpopRequest(
807 {
808 clerkToken: input.clerkToken,
809 scopes: input.scopes,
810 target: dpopProofTargets.connectEnvironment(input.environmentId),
811 },
812 (authorization) => {
813 const payload: RelayEnvironmentConnectRequest = {
814 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
815 clientKeyThumbprint: authorization.thumbprint,
816 };
817 return client.dpopClient
818 .connectEnvironment({
819 headers: dpopHeaders(authorization),
820 params: { environmentId: input.environmentId },
821 payload,
822 })
823 .pipe(
824 Effect.mapError(relayRequestError("connect relay environment")),
825 timeoutRelayRequest("Relay environment connection"),
826 );
827 },
828 );
829 }, Effect.withSpan("clientRuntime.managedRelay.connectEnvironment")),
830 registerDevice: Effect.fnUntraced(function* (input) {
831 return yield* mobileRegistrationRequest(
832 {
833 clerkToken: input.clerkToken,
834 target: dpopProofTargets.registerDevice(),
835 },
836 (authorization) =>
837 client.mobile
838 .registerDevice({
839 headers: dpopHeaders(authorization),
840 payload: input.payload,
841 })
842 .pipe(
843 Effect.mapError(relayRequestError("register relay mobile device")),
844 timeoutRelayRequest("Relay mobile device registration"),
845 ),
846 );
847 }, Effect.withSpan("clientRuntime.managedRelay.registerDevice")),
848 unregisterDevice: Effect.fnUntraced(function* (input) {
849 return yield* mobileRegistrationRequest(
850 {
851 clerkToken: input.clerkToken,
852 target: dpopProofTargets.unregisterDevice(input.deviceId),
853 },
854 (authorization) =>
855 client.mobile
856 .unregisterDevice({
857 headers: dpopHeaders(authorization),
858 params: { deviceId: input.deviceId },
859 })
860 .pipe(
861 Effect.mapError(relayRequestError("unregister relay mobile device")),
862 timeoutRelayRequest("Relay mobile device unregistration"),
863 ),
864 );
865 }, Effect.withSpan("clientRuntime.managedRelay.unregisterDevice")),
866 getAgentActivitySnapshot: Effect.fnUntraced(function* (input) {
867 return yield* mobileRegistrationRequest(
868 {
869 clerkToken: input.clerkToken,
870 target: dpopProofTargets.getAgentActivitySnapshot(),
871 },
872 (authorization) =>
873 client.mobile
874 .getAgentActivitySnapshot({
875 headers: dpopHeaders(authorization),
876 })
877 .pipe(
878 Effect.mapError(relayRequestError("read relay agent activity snapshot")),
879 timeoutRelayRequest("Relay agent activity snapshot"),
880 ),
881 );
882 }, Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot")),
883 registerLiveActivity: Effect.fnUntraced(function* (input) {
884 return yield* mobileRegistrationRequest(
885 {
886 clerkToken: input.clerkToken,
887 target: dpopProofTargets.registerLiveActivity(),
888 },
889 (authorization) =>
890 client.mobile
891 .registerLiveActivity({
892 headers: dpopHeaders(authorization),
893 payload: input.payload,
894 })
895 .pipe(
896 Effect.mapError(relayRequestError("register relay live activity")),
897 timeoutRelayRequest("Relay Live Activity registration"),
898 ),
899 );
900 }, Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity")),
901 resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe(
902 Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void),
903 Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"),
904 ),
905 });
906});
907
908export const layer = (options: ManagedRelayClientLayerOptions) =>
909 Layer.effect(ManagedRelayClient, make(options));