Skip to content
Source
Sign inSign up
Open History to see the last commit

managedRelay.ts

1,004 lines36.4 KB

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