authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 20:12:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:12:04-07:00
log363cde4114c2186c496ed97cafba4a8737ea5585
tree7bb4f9e39a2226e0a89b53c34d75bbf68893f8bf
parentac90950f1ac315bcef5f92b53c5e0d6579daeefd
signature Signed by PGP key B5690EEEBB952194

fix(connect): refresh HTTP credentials without reconnecting (#9594)


30 files changed, 2086 insertions(+), 825 deletions(-)

apps/mobile/src/connection/platform.ts+3
......@@ -118,6 +118,9 @@ const capabilitiesLayer = Layer.effectContext(
118118 return Context.make(
119119 CloudSession,
120120 CloudSession.of({
121 identity: Effect.sync(() =>
122 Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)),
123 ),
121124 clerkToken: Effect.gen(function* () {
122125 const session = appAtomRegistry.get(managedRelaySessionAtom);
123126 if (session === null) {
apps/mobile/src/connection/runtime.ts+2-4
......@@ -30,10 +30,8 @@ type ConnectionLayerSource =
3030 | typeof mobileBackgroundActivityObserverLayer
3131 | typeof mobileBackgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = Layer.merge(
34 Connection.layerWithOptions({ usageLimitSources: true }),
35 snapshotLoaderLayer,
36).pipe(
33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })),
3735 Layer.provideMerge(
3836 Layer.mergeAll(
3937 runtimeContextLayer,
apps/mobile/src/connection/storage.ts+2-9
......@@ -2,6 +2,7 @@ import {
22 ConnectionPersistenceError,
33 ConnectionRegistrationStore,
44 ConnectionTargetStore,
5 putRemoteDpopTokenInCatalog,
56 registerConnectionInCatalog,
67 removeConnectionFromCatalog,
78 removeCatalogValue,
......@@ -109,15 +110,7 @@ export const connectionStorageLayer = Layer.effectContext(
109110 ),
110111 ),
111112 ),
112 put: (token) =>
113 catalog.update((document) => ({
114 ...document,
115 remoteDpopTokens: replaceCatalogValue(
116 document.remoteDpopTokens,
117 (value) => value.environmentId,
118 token,
119 ),
120 })),
113 put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)),
121114 remove: (environmentId) =>
122115 catalog.update((document) => ({
123116 ...document,
apps/server/src/auth/SessionStore.test.ts+128
......@@ -5,6 +5,8 @@ import * as Duration from "effect/Duration";
55import * as Effect from "effect/Effect";
66import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
8import * as Queue from "effect/Queue";
9import * as Stream from "effect/Stream";
810import * as TestClock from "effect/testing/TestClock";
911import * as SqlClient from "effect/unstable/sql/SqlClient";
1012
......@@ -44,6 +46,14 @@ const makeSessionStoreLayer = (
4446 Layer.provide(makeServerConfigLayer(overrides)),
4547 );
4648
49const relaySessionInput = {
50 subject: "managed-relay-bootstrap",
51 method: "dpop-access-token",
52 proofKeyThumbprint: "relay-proof-key",
53 ttl: Duration.hours(1),
54 client: { label: "Relay desktop", deviceType: "desktop" },
55} as const;
56
4757const repositoryFailure = new PersistenceSqlError({
4858 operation: "AuthSessionRepository.getById:query",
4959 detail: "sqlite is unavailable",
......@@ -420,6 +430,124 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
420430 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
421431 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
422432 );
433
434 it.effect("keeps connected relay sessions visible through expiry and HTTP renewal", () =>
435 Effect.gen(function* () {
436 const sessions = yield* SessionStore.SessionStore;
437 const original = yield* sessions.issue(relaySessionInput);
438 const websocket = yield* sessions.issueWebSocketToken(original.sessionId, {
439 ttl: Duration.hours(2),
440 });
441 yield* sessions.verifyWebSocketToken(websocket.token);
442 yield* sessions.markConnected(original.sessionId);
443 const beforeExpiry = yield* sessions.listActive();
444 expect(beforeExpiry).toHaveLength(1);
445 expect(beforeExpiry[0]?.connected).toBe(true);
446
447 yield* TestClock.adjust(Duration.minutes(61));
448
449 expect(yield* sessions.listActive()).toEqual(beforeExpiry);
450 expect(yield* Effect.flip(sessions.verify(original.token))).toMatchObject({
451 _tag: "SessionTokenExpiredError",
452 });
453 expect(yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token))).toMatchObject({
454 _tag: "WebSocketSessionExpiredError",
455 });
456
457 const renewed = yield* sessions.issue(relaySessionInput);
458 const afterRenewal = yield* sessions.listActive();
459 expect(renewed.sessionId).not.toBe(original.sessionId);
460 expect(afterRenewal).toHaveLength(2);
461 expect(afterRenewal).toEqual(
462 expect.arrayContaining([
463 beforeExpiry[0],
464 expect.objectContaining({
465 sessionId: renewed.sessionId,
466 connected: false,
467 lastConnectedAt: null,
468 }),
469 ]),
470 );
471 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
472 );
473
474 it.effect.each([1, 2])(
475 "removes an expired session from listings and updates after its last of %s sockets closes",
476 (socketCount) =>
477 Effect.gen(function* () {
478 const sessions = yield* SessionStore.SessionStore;
479 const issued = yield* sessions.issue(relaySessionInput);
480 const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();
481 yield* sessions.streamChanges.pipe(
482 Stream.runForEach((change) => Queue.offer(changes, change)),
483 Effect.forkScoped({ startImmediately: true }),
484 );
485 for (let index = 0; index < socketCount; index += 1) {
486 yield* sessions.markConnected(issued.sessionId);
487 expect(yield* Queue.take(changes)).toMatchObject({
488 type: "clientUpserted",
489 clientSession: { sessionId: issued.sessionId, connected: true },
490 });
491 }
492
493 yield* TestClock.adjust(Duration.minutes(61));
494
495 for (let remaining = socketCount - 1; remaining >= 0; remaining -= 1) {
496 yield* sessions.markDisconnected(issued.sessionId);
497 const change = yield* Queue.take(changes);
498 const listed = yield* sessions.listActive();
499 if (remaining > 0) {
500 expect(change).toMatchObject({
501 type: "clientUpserted",
502 clientSession: { sessionId: issued.sessionId, connected: true },
503 });
504 expect(listed).toHaveLength(1);
505 expect(listed[0]?.connected).toBe(true);
506 } else {
507 expect(change).toEqual({ type: "clientRemoved", sessionId: issued.sessionId });
508 expect(listed).toEqual([]);
509 }
510 }
511 }).pipe(
512 Effect.scoped,
513 Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer())),
514 ),
515 );
516
517 it.effect.each(["revoke", "revokeAllExcept"] as const)(
518 "removes expired connected sessions with %s",
519 (operation) =>
520 Effect.gen(function* () {
521 const sessions = yield* SessionStore.SessionStore;
522 const administrative = yield* sessions.issue({ subject: "desktop-bootstrap" });
523 const client = yield* sessions.issue(relaySessionInput);
524 yield* sessions.markConnected(client.sessionId);
525 yield* TestClock.adjust(Duration.minutes(61));
526 const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();
527 yield* sessions.streamChanges.pipe(
528 Stream.runForEach((change) => Queue.offer(changes, change)),
529 Effect.forkScoped({ startImmediately: true }),
530 );
531
532 if (operation === "revoke") {
533 expect(yield* sessions.revoke(client.sessionId)).toBe(true);
534 } else {
535 expect(yield* sessions.revokeAllExcept(administrative.sessionId)).toBe(1);
536 }
537
538 expect(yield* Queue.take(changes)).toEqual({
539 type: "clientRemoved",
540 sessionId: client.sessionId,
541 });
542 const listed = yield* sessions.listActive();
543 expect(listed).toHaveLength(1);
544 expect(listed[0]?.sessionId).toBe(administrative.sessionId);
545 }).pipe(
546 Effect.scoped,
547 Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer())),
548 ),
549 );
550
423551 it.effect("records client connection metadata without clearing prior values", () =>
424552 Effect.gen(function* () {
425553 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+12-4
......@@ -482,7 +482,7 @@ export const make = Effect.gen(function* () {
482482 const secretStore = yield* ServerSecretStore.ServerSecretStore;
483483 const authSessions = yield* AuthSessions.AuthSessionRepository;
484484 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
485 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
485 const connectedSessionsRef = yield* Ref.make(new Map<AuthSessionId, number>());
486486 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
487487 const cookieInput = {
488488 mode: serverConfig.mode,
......@@ -515,6 +515,11 @@ export const make = Effect.gen(function* () {
515515 }
516516
517517 const connectedSessions = yield* Ref.get(connectedSessionsRef);
518 const connected = connectedSessions.has(row.value.sessionId);
519 const now = yield* DateTime.now;
520 if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
521 return Option.none<AuthClientSession>();
522 }
518523 return Option.some(
519524 toAuthClientSession({
520525 sessionId: row.value.sessionId,
......@@ -525,7 +530,7 @@ export const make = Effect.gen(function* () {
525530 issuedAt: row.value.issuedAt,
526531 expiresAt: row.value.expiresAt,
527532 lastConnectedAt: row.value.lastConnectedAt,
528 connected: connectedSessions.has(row.value.sessionId),
533 connected,
529534 }),
530535 );
531536 });
......@@ -599,7 +604,7 @@ export const make = Effect.gen(function* () {
599604 }).pipe(
600605 Effect.flatMap(() => loadActiveSession(sessionId)),
601606 Effect.flatMap((session) =>
602 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
607 Option.isSome(session) ? emitUpsert(session.value) : emitRemoved(sessionId),
603608 ),
604609 Effect.catchCause((cause) =>
605610 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
......@@ -882,7 +887,10 @@ export const make = Effect.gen(function* () {
882887 function* () {
883888 const now = yield* DateTime.now;
884889 const connectedSessions = yield* Ref.get(connectedSessionsRef);
885 const rows = yield* authSessions.listActive({ now });
890 const rows = yield* authSessions.listActive({
891 now,
892 connectedSessionIds: Array.from(connectedSessions.keys()),
893 });
886894
887895 return rows.map((row) =>
888896 toAuthClientSession({
apps/server/src/persistence/AuthSessions.ts+3-2
......@@ -69,6 +69,7 @@ export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;
6969
7070export const ListActiveAuthSessionsInput = Schema.Struct({
7171 now: Schema.DateTimeUtcFromString,
72 connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)),
7273});
7374export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;
7475
......@@ -282,7 +283,7 @@ export const make = Effect.gen(function* () {
282283 const listActiveSessionRows = SqlSchema.findAll({
283284 Request: ListActiveAuthSessionsInput,
284285 Result: AuthSessionRawDbRow,
285 execute: ({ now }) =>
286 execute: ({ now, connectedSessionIds = [] }) =>
286287 sql`
287288 SELECT
288289 session_id AS "sessionId",
......@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {
301302 revoked_at AS "revokedAt"
302303 FROM auth_sessions
303304 WHERE revoked_at IS NULL
304 AND expires_at > ${now}
305 AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)})
305306 ORDER BY issued_at DESC, session_id DESC
306307 `,
307308 });
apps/web/src/connection/platform.ts+3
......@@ -182,6 +182,9 @@ const capabilitiesLayer = Layer.effectContext(
182182 scopes: AuthStandardClientScopes,
183183 });
184184 const cloudSession = CloudSession.of({
185 identity: Effect.sync(() =>
186 Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)),
187 ),
185188 clerkToken: Effect.gen(function* () {
186189 const session = appAtomRegistry.get(managedRelaySessionAtom);
187190 if (session === null) {
apps/web/src/connection/runtime.ts+4-4
......@@ -30,10 +30,10 @@ type ConnectionLayerSource =
3030 | typeof backgroundActivityObserverLayer
3131 | typeof backgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = Layer.merge(
34 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
35 snapshotLoaderLayer,
36).pipe(
33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Layer.provideMerge(
35 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
36 ),
3737 Layer.provideMerge(
3838 Layer.mergeAll(
3939 runtimeContextLayer,
apps/web/src/connection/storage.ts+2-9
......@@ -6,6 +6,7 @@ import {
66 ConnectionTargetStore,
77 EMPTY_CONNECTION_CATALOG_DOCUMENT,
88 EnvironmentCacheStore,
9 putRemoteDpopTokenInCatalog,
910 registerConnectionInCatalog,
1011 removeCatalogValue,
1112 removeConnectionFromCatalog,
......@@ -446,15 +447,7 @@ export const connectionStorageLayer = Layer.effectContext(
446447 ),
447448 ),
448449 ),
449 put: (token) =>
450 catalog.update((document) => ({
451 ...document,
452 remoteDpopTokens: replaceCatalogValue(
453 document.remoteDpopTokens,
454 (value) => value.environmentId,
455 token,
456 ),
457 })),
450 put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)),
458451 remove: (environmentId) =>
459452 catalog.update((document) => ({
460453 ...document,
docs/internals/connection-runtime.md+16-2
......@@ -6,10 +6,10 @@ signals, and application lifecycle events. React views consume the runtime.
66Keeping retries and session lifetime here prevents competing reconnect loops when
77several views need the same environment.
88
9## One retry owner
9## One transport retry owner
1010
1111The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns
12retry policy; resolving an endpoint and opening an RPC session are single
12transport retry policy; resolving an endpoint and opening an RPC session are single
1313attempts. Transient failures retry with capped backoff. Offline states and
1414authentication failures wait for a wakeup instead of spending attempts on
1515unchanged conditions.
......@@ -27,6 +27,20 @@ and cached data. Explicit removal closes the scope and clears credentials,
2727projections, and platform-owned state such as drafts. Cloud-account changes apply
2828to relay registrations; they must not discard directly paired environments.
2929
30## HTTP authorization
31
32RPC sessions authenticate at socket upgrade, while HTTP requests need current
33credentials from the
34[authorization service](../../packages/client-runtime/src/authorization/service.ts).
35Replacing a healthy socket for HTTP renewal would interrupt conversations and
36change the transport generation without a transport failure. Credential expiry
37does not close the socket, and refresh failure belongs to the HTTP operation.
38
39Session listings must retain unrevoked connected sessions after credential expiry
40so an open connection does not disappear from connection management. This does
41not extend the credential's lifetime. New HTTP requests and socket upgrades still
42require valid credentials.
43
3044## Transport health and data freshness are separate
3145
3246A socket opening is insufficient evidence that the environment is usable. The
docs/user/remote-access.md+8
......@@ -24,6 +24,11 @@ On your other device, sign in to the same T3 Connect account and choose the
2424environment. Over SSH, the CLI prints a browser link and accepts the returned
2525authorization code, so you do not need to forward an OAuth callback port.
2626
27T3 Connect renews access credentials when needed without disconnecting a healthy
28connection. Pull request diffs and provider settings keep working after the
29previous credential expires. A failed renewal affects that request; it does not
30disconnect an otherwise healthy conversation.
31
2732## Pair over a LAN or private network
2833
2934Use direct pairing when the other device can reach the host's network address.
......@@ -127,6 +132,9 @@ pairing links and revoke client sessions. Revoking an unused link prevents new
127132pairings; revoke a device's session to remove its existing access. Command-line
128133management is available through `npx t3 auth --help`.
129134
135A session with an open connection stays listed after its access credential
136expires.
137
130138To remove an environment from T3 Connect, open your account menu's **T3 Connect**
131139page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
132140revokes its cloud access and frees its host space even when the environment is
packages/client-runtime/src/authorization/index.ts+1-1
......@@ -1,7 +1,7 @@
11export * from "./remote.ts";
22export {
33 type AuthorizedRemoteEnvironment,
4 type RelayEnvironmentAuthorization,
4 type AuthorizedRemoteHttpEnvironment,
55 RemoteEnvironmentAuthorization,
66} from "./service.ts";
77export * as TokenStore from "./tokenStore.ts";
packages/client-runtime/src/authorization/layer.test.ts+524-20
......@@ -1,8 +1,16 @@
11import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
2import {
3 RelayEnvironmentConnectScope,
4 type RelayEnvironmentConnectResponse,
5} from "@t3tools/contracts/relay";
26import { describe, expect, it } from "@effect/vitest";
7import * as Clock from "effect/Clock";
8import * as Deferred from "effect/Deferred";
39import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
411import * as Layer from "effect/Layer";
512import * as Option from "effect/Option";
13import * as Queue from "effect/Queue";
614import * as Ref from "effect/Ref";
715import * as TestClock from "effect/testing/TestClock";
816
......@@ -31,10 +39,11 @@ const DESCRIPTOR = {
3139 repositoryIdentity: true,
3240 },
3341};
34const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = {
42const BOOTSTRAP: RelayEnvironmentConnectResponse = {
3543 environmentId: ENVIRONMENT_ID,
3644 endpoint: ENDPOINT,
3745 credential: "relay-bootstrap",
46 expiresAt: "2026-06-06T01:00:00.000Z",
3847};
3948
4049function recordedFetch(responses: ReadonlyArray<Response>) {
......@@ -76,9 +85,31 @@ const authInvalid = () =>
7685 { status: 401 },
7786 );
7887
88const persistedToken = (
89 input: {
90 readonly environmentId?: EnvironmentId;
91 readonly accessToken?: string;
92 readonly expiresAtEpochMs?: number;
93 readonly dpopThumbprint?: string;
94 } = {},
95) =>
96 new TokenStore.RemoteDpopAccessToken({
97 environmentId: input.environmentId ?? ENVIRONMENT_ID,
98 accountId: "account-1",
99 label: DESCRIPTOR.label,
100 endpoint: ENDPOINT,
101 accessToken: input.accessToken ?? "cached-access-token",
102 expiresAtEpochMs: input.expiresAtEpochMs ?? Number.MAX_SAFE_INTEGER,
103 dpopThumbprint: input.dpopThumbprint ?? "thumbprint-1",
104 });
105
79106const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {
80107 readonly initialToken?: TokenStore.RemoteDpopAccessToken;
81108 readonly responses: ReadonlyArray<Response>;
109 readonly bootstrap?: RelayEnvironmentConnectResponse;
110 readonly beforeBootstrap?: Effect.Effect<void, ManagedRelay.ManagedRelayClientError>;
111 readonly beforePut?: Effect.Effect<void>;
112 readonly clerkToken?: ClientCapabilities.CloudSession["Service"]["clerkToken"];
82113}) {
83114 const tokens = yield* Ref.make(
84115 new Map(
......@@ -88,6 +119,14 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
88119 ),
89120 );
90121 const bootstrapCalls = yield* Ref.make(0);
122 const relayInputs = yield* Ref.make<
123 ReadonlyArray<Parameters<ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]>[0]>
124 >([]);
125 const session = yield* Ref.make<Option.Option<ClientCapabilities.CloudSessionIdentity>>(
126 Option.some({ accountId: "account-1" }),
127 );
128 const thumbprint = yield* Ref.make("thumbprint-1");
129 const tokenReads = yield* Queue.unbounded<EnvironmentId>();
91130 const proofInputs = yield* Ref.make<
92131 ReadonlyArray<{
93132 readonly method: string;
......@@ -101,13 +140,18 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
101140 get: (environmentId) =>
102141 Ref.get(tokens).pipe(
103142 Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))),
143 Effect.tap(() => Queue.offer(tokenReads, environmentId)),
104144 ),
105145 put: (token) =>
106 Ref.update(tokens, (current) => {
107 const next = new Map(current);
108 next.set(token.environmentId, token);
109 return next;
110 }),
146 (input.beforePut ?? Effect.void).pipe(
147 Effect.andThen(
148 Ref.update(tokens, (current) => {
149 const next = new Map(current);
150 next.set(token.environmentId, token);
151 return next;
152 }),
153 ),
154 ),
111155 remove: (environmentId) =>
112156 Ref.update(tokens, (current) => {
113157 const next = new Map(current);
......@@ -116,17 +160,47 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
116160 }),
117161 });
118162 const signer = ManagedRelay.ManagedRelayDpopSigner.of({
119 thumbprint: Effect.succeed("thumbprint-1"),
163 thumbprint: Ref.get(thumbprint),
120164 createProof: (proofInput) =>
121165 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(
122166 Effect.as(`proof:${proofInput.url}`),
123167 ),
124168 });
169 const unexpected = () => Effect.die("Unexpected relay request");
170 const relay = ManagedRelay.ManagedRelayClient.of({
171 relayUrl: "https://relay.example.test",
172 listEnvironments: unexpected,
173 listDevices: unexpected,
174 createEnvironmentLinkChallenge: unexpected,
175 linkEnvironment: unexpected,
176 unlinkEnvironment: unexpected,
177 getEnvironmentStatus: unexpected,
178 connectEnvironment: (request) =>
179 Effect.gen(function* () {
180 yield* Ref.update(bootstrapCalls, (count) => count + 1);
181 yield* Ref.update(relayInputs, (current) => [...current, request]);
182 yield* input.beforeBootstrap ?? Effect.void;
183 return input.bootstrap ?? BOOTSTRAP;
184 }),
185 registerDevice: unexpected,
186 unregisterDevice: unexpected,
187 registerLiveActivity: unexpected,
188 getAgentActivitySnapshot: unexpected,
189 resetTokenCache: Effect.void,
190 });
125191 const layer = RemoteEnvironmentAuthorization.layer.pipe(
126192 Layer.provide(
127193 Layer.mergeAll(
128194 remoteHttpClientLayer(fetch.fetchFn),
129195 Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer),
196 Layer.succeed(ManagedRelay.ManagedRelayClient, relay),
197 Layer.succeed(ClientCapabilities.CloudSession, {
198 identity: Ref.get(session),
199 clerkToken: input.clerkToken ?? Effect.succeed("clerk-session"),
200 }),
201 Layer.succeed(ClientCapabilities.RelayDeviceIdentity, {
202 deviceId: Effect.succeed(Option.some("device-1")),
203 }),
130204 Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore),
131205 Layer.succeed(
132206 ClientCapabilities.ClientPresentation,
......@@ -142,17 +216,16 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
142216 ),
143217 ),
144218 );
145 const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe(
146 Effect.as(BOOTSTRAP),
147 );
148
149219 return {
150220 layer,
151221 tokens,
152222 bootstrapCalls,
153223 proofInputs,
154224 fetch,
155 obtainBootstrap,
225 relayInputs,
226 session,
227 thumbprint,
228 tokenReads,
156229 };
157230});
158231
......@@ -234,10 +307,11 @@ describe("RemoteEnvironmentAuthorization", () => {
234307 }),
235308 );
236309
237 it.effect("reuses a valid persisted environment token without contacting the relay", () =>
310 it.effect("reuses a same-account persisted environment token without contacting the relay", () =>
238311 Effect.gen(function* () {
239312 const cached = new TokenStore.RemoteDpopAccessToken({
240313 environmentId: ENVIRONMENT_ID,
314 accountId: "account-1",
241315 label: DESCRIPTOR.label,
242316 endpoint: ENDPOINT,
243317 accessToken: "cached-access-token",
......@@ -253,7 +327,6 @@ describe("RemoteEnvironmentAuthorization", () => {
253327 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
254328 return yield* remote.authorizeDpop({
255329 expectedEnvironmentId: ENVIRONMENT_ID,
256 obtainBootstrap: harness.obtainBootstrap,
257330 });
258331 }).pipe(Effect.provide(harness.layer));
259332
......@@ -271,6 +344,7 @@ describe("RemoteEnvironmentAuthorization", () => {
271344 Effect.gen(function* () {
272345 const expired = new TokenStore.RemoteDpopAccessToken({
273346 environmentId: ENVIRONMENT_ID,
347 accountId: "account-1",
274348 label: DESCRIPTOR.label,
275349 endpoint: ENDPOINT,
276350 accessToken: "expired-access-token",
......@@ -290,7 +364,6 @@ describe("RemoteEnvironmentAuthorization", () => {
290364 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
291365 return yield* remote.authorizeDpop({
292366 expectedEnvironmentId: ENVIRONMENT_ID,
293 obtainBootstrap: harness.obtainBootstrap,
294367 });
295368 }).pipe(Effect.provide(harness.layer));
296369
......@@ -310,6 +383,7 @@ describe("RemoteEnvironmentAuthorization", () => {
310383 Effect.gen(function* () {
311384 const cached = new TokenStore.RemoteDpopAccessToken({
312385 environmentId: ENVIRONMENT_ID,
386 accountId: "account-1",
313387 label: DESCRIPTOR.label,
314388 endpoint: ENDPOINT,
315389 accessToken: "invalid-access-token",
......@@ -330,7 +404,6 @@ describe("RemoteEnvironmentAuthorization", () => {
330404 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
331405 return yield* remote.authorizeDpop({
332406 expectedEnvironmentId: ENVIRONMENT_ID,
333 obtainBootstrap: harness.obtainBootstrap,
334407 });
335408 }).pipe(Effect.provide(harness.layer));
336409
......@@ -355,7 +428,6 @@ describe("RemoteEnvironmentAuthorization", () => {
355428 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
356429 return yield* remote.authorizeDpop({
357430 expectedEnvironmentId: ENVIRONMENT_ID,
358 obtainBootstrap: harness.obtainBootstrap,
359431 });
360432 }).pipe(Effect.provide(harness.layer), Effect.flip);
361433
......@@ -372,6 +444,7 @@ describe("RemoteEnvironmentAuthorization", () => {
372444 Effect.gen(function* () {
373445 const cached = new TokenStore.RemoteDpopAccessToken({
374446 environmentId: ENVIRONMENT_ID,
447 accountId: "account-1",
375448 label: DESCRIPTOR.label,
376449 endpoint: ENDPOINT,
377450 accessToken: "cached-access-token",
......@@ -392,7 +465,6 @@ describe("RemoteEnvironmentAuthorization", () => {
392465 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
393466 return yield* remote.authorizeDpop({
394467 expectedEnvironmentId: ENVIRONMENT_ID,
395 obtainBootstrap: harness.obtainBootstrap,
396468 });
397469 }).pipe(Effect.provide(harness.layer));
398470
......@@ -407,7 +479,7 @@ describe("RemoteEnvironmentAuthorization", () => {
407479 }),
408480 );
409481
410 it.effect("does not persist a refreshed token until its websocket ticket succeeds", () =>
482 it.effect("removes a newly refreshed token when websocket ticket authorization fails", () =>
411483 Effect.gen(function* () {
412484 const harness = yield* makeHarness({
413485 responses: [
......@@ -421,7 +493,6 @@ describe("RemoteEnvironmentAuthorization", () => {
421493 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
422494 return yield* remote.authorizeDpop({
423495 expectedEnvironmentId: ENVIRONMENT_ID,
424 obtainBootstrap: harness.obtainBootstrap,
425496 });
426497 }).pipe(Effect.provide(harness.layer), Effect.flip);
427498
......@@ -430,4 +501,437 @@ describe("RemoteEnvironmentAuthorization", () => {
430501 expect(harness.fetch.calls).toHaveLength(3);
431502 }),
432503 );
504
505 it.effect("uses the current cached HTTP token without a relay request or websocket ticket", () =>
506 Effect.gen(function* () {
507 const harness = yield* makeHarness({ initialToken: persistedToken(), responses: [] });
508 const authorized = yield* Effect.gen(function* () {
509 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
510 return yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
511 }).pipe(Effect.provide(harness.layer));
512
513 expect(authorized).toEqual({
514 environmentId: ENVIRONMENT_ID,
515 label: DESCRIPTOR.label,
516 httpBaseUrl: ENDPOINT.httpBaseUrl,
517 httpAuthorization: {
518 _tag: "Dpop",
519 accessToken: "cached-access-token",
520 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
521 },
522 });
523 expect(harness.fetch.calls).toHaveLength(0);
524 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0);
525 expect(yield* Ref.get(harness.proofInputs)).toEqual([]);
526 }),
527 );
528
529 it.effect(
530 "renews HTTP credentials near expiry through the current cloud account and device",
531 () =>
532 Effect.gen(function* () {
533 const now = yield* Clock.currentTimeMillis;
534 const harness = yield* makeHarness({
535 initialToken: persistedToken({ expiresAtEpochMs: now + 61_000 }),
536 responses: [Response.json(DESCRIPTOR), accessToken("fresh-access-token")],
537 });
538 yield* Effect.gen(function* () {
539 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
540 const before = yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
541 expect(before.httpAuthorization.accessToken).toBe("cached-access-token");
542 yield* TestClock.adjust("1 second");
543 const after = yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
544 expect(after.httpAuthorization.accessToken).toBe("fresh-access-token");
545 expect(after.httpAuthorization.expiresAtEpochMs).toBe(now + 3_601_000);
546 }).pipe(Effect.provide(harness.layer));
547
548 expect(yield* Ref.get(harness.relayInputs)).toEqual([
549 {
550 environmentId: ENVIRONMENT_ID,
551 clerkToken: "clerk-session",
552 scopes: [RelayEnvironmentConnectScope],
553 deviceId: "device-1",
554 },
555 ]);
556 expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([
557 `${ENDPOINT.httpBaseUrl}/.well-known/t3/environment`,
558 `${ENDPOINT.httpBaseUrl}/oauth/token`,
559 ]);
560 expect(yield* Ref.get(harness.proofInputs)).toEqual([
561 {
562 method: "POST",
563 url: `${ENDPOINT.httpBaseUrl}/oauth/token`,
564 },
565 ]);
566 }),
567 );
568
569 it.effect("does not evict a replacement token when a late request rejects the old one", () =>
570 Effect.gen(function* () {
571 const harness = yield* makeHarness({
572 initialToken: persistedToken(),
573 responses: [Response.json(DESCRIPTOR), accessToken("replacement-token")],
574 });
575 yield* Effect.gen(function* () {
576 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
577 const initial = yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
578 const retry = {
579 expectedEnvironmentId: ENVIRONMENT_ID,
580 rejectedAccessToken: initial.httpAuthorization.accessToken,
581 };
582 const first = yield* remote.authorizeDpopHttp(retry);
583 const late = yield* remote.authorizeDpopHttp(retry);
584 expect(first.httpAuthorization.accessToken).toBe("replacement-token");
585 expect(late).toEqual(first);
586 }).pipe(Effect.provide(harness.layer));
587
588 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
589 expect(harness.fetch.calls).toHaveLength(2);
590 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe(
591 "replacement-token",
592 );
593 }),
594 );
595
596 it.effect("shares one in-flight renewal between concurrent HTTP requests", () =>
597 Effect.gen(function* () {
598 const started = yield* Deferred.make<void>();
599 const release = yield* Deferred.make<void>();
600 const harness = yield* makeHarness({
601 initialToken: persistedToken({ expiresAtEpochMs: 0 }),
602 beforeBootstrap: Deferred.succeed(started, undefined).pipe(
603 Effect.andThen(Deferred.await(release)),
604 ),
605 responses: [Response.json(DESCRIPTOR), accessToken("shared-token")],
606 });
607 yield* Effect.gen(function* () {
608 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
609 const requests = yield* Effect.all(
610 Array.from({ length: 6 }, () =>
611 remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID }),
612 ),
613 { concurrency: "unbounded" },
614 ).pipe(Effect.forkChild);
615 yield* Queue.takeN(harness.tokenReads, 6);
616 yield* Deferred.await(started);
617 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
618 yield* Deferred.succeed(release, undefined);
619 const results = yield* Fiber.join(requests);
620 expect(results.map((result) => result.httpAuthorization.accessToken)).toEqual(
621 Array(6).fill("shared-token"),
622 );
623 }).pipe(Effect.provide(harness.layer));
624
625 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
626 expect(harness.fetch.calls).toHaveLength(2);
627 }),
628 );
629
630 it.effect(
631 "shares a failed renewal and allows a later attempt instead of caching its failure",
632 () =>
633 Effect.gen(function* () {
634 const release = yield* Deferred.make<void, ManagedRelay.ManagedRelayClientError>();
635 const failure = new ManagedRelay.ManagedRelayRequestTimeoutError({
636 activity: "Relay environment connection",
637 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
638 traceId: null,
639 });
640 const harness = yield* makeHarness({
641 responses: [],
642 beforeBootstrap: Deferred.await(release),
643 });
644 yield* Effect.gen(function* () {
645 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
646 const requests = yield* Effect.all(
647 Array.from({ length: 6 }, () =>
648 remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID }).pipe(Effect.flip),
649 ),
650 { concurrency: "unbounded" },
651 ).pipe(Effect.forkChild);
652 yield* Queue.takeN(harness.tokenReads, 6);
653 yield* Deferred.fail(release, failure);
654 const failures = yield* Fiber.join(requests);
655 expect(failures).toEqual(
656 Array(6).fill(
657 expect.objectContaining({
658 _tag: "ConnectionTransientError",
659 reason: "timeout",
660 }),
661 ),
662 );
663 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
664 yield* remote
665 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
666 .pipe(Effect.flip);
667 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(2);
668 }).pipe(Effect.provide(harness.layer));
669 expect(harness.fetch.calls).toHaveLength(0);
670 }),
671 );
672
673 it.effect("does not block another environment behind a pending renewal", () =>
674 Effect.gen(function* () {
675 const started = yield* Deferred.make<void>();
676 const release = yield* Deferred.make<void>();
677 const otherEnvironmentId = EnvironmentId.make("environment-2");
678 const harness = yield* makeHarness({
679 initialToken: persistedToken({
680 environmentId: otherEnvironmentId,
681 accessToken: "other-token",
682 }),
683 beforeBootstrap: Deferred.succeed(started, undefined).pipe(
684 Effect.andThen(Deferred.await(release)),
685 ),
686 responses: [Response.json(DESCRIPTOR), accessToken("fresh-token")],
687 });
688 yield* Effect.gen(function* () {
689 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
690 const pending = yield* remote
691 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
692 .pipe(Effect.forkChild);
693 yield* Deferred.await(started);
694 const other = yield* remote.authorizeDpopHttp({
695 expectedEnvironmentId: otherEnvironmentId,
696 });
697 expect(other.httpAuthorization.accessToken).toBe("other-token");
698 yield* Deferred.succeed(release, undefined);
699 yield* Fiber.join(pending);
700 }).pipe(Effect.provide(harness.layer));
701 }),
702 );
703
704 it.effect("does not return or persist credentials after logout during renewal", () =>
705 Effect.gen(function* () {
706 const started = yield* Deferred.make<void>();
707 const release = yield* Deferred.make<void>();
708 const harness = yield* makeHarness({
709 beforeBootstrap: Deferred.succeed(started, undefined).pipe(
710 Effect.andThen(Deferred.await(release)),
711 ),
712 responses: [],
713 });
714 yield* Effect.gen(function* () {
715 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
716 const pending = yield* remote
717 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
718 .pipe(Effect.flip, Effect.forkChild);
719 yield* Deferred.await(started);
720 yield* Ref.set(harness.session, Option.none());
721 yield* Deferred.succeed(release, undefined);
722 expect(yield* Fiber.join(pending)).toMatchObject({
723 _tag: "ConnectionBlockedError",
724 reason: "authentication",
725 });
726 }).pipe(Effect.provide(harness.layer));
727 expect((yield* Ref.get(harness.tokens)).size).toBe(0);
728 expect(harness.fetch.calls).toHaveLength(0);
729 }),
730 );
731
732 it.effect("removes an obsolete credential if logout overlaps the persistent write", () =>
733 Effect.gen(function* () {
734 const started = yield* Deferred.make<void>();
735 const release = yield* Deferred.make<void>();
736 const harness = yield* makeHarness({
737 beforePut: Deferred.succeed(started, undefined).pipe(
738 Effect.andThen(Deferred.await(release)),
739 ),
740 responses: [Response.json(DESCRIPTOR), accessToken("obsolete-token")],
741 });
742 yield* Effect.gen(function* () {
743 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
744 const pending = yield* remote
745 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
746 .pipe(Effect.flip, Effect.forkChild);
747 yield* Deferred.await(started);
748 yield* Ref.set(harness.session, Option.none());
749 yield* Deferred.succeed(release, undefined);
750 expect(yield* Fiber.join(pending)).toMatchObject({
751 _tag: "ConnectionBlockedError",
752 reason: "authentication",
753 });
754 }).pipe(Effect.provide(harness.layer));
755 expect((yield* Ref.get(harness.tokens)).size).toBe(0);
756 }),
757 );
758
759 it.effect(
760 "renews another account's persisted token after the authorization service restarts",
761 () =>
762 Effect.gen(function* () {
763 const harness = yield* makeHarness({
764 initialToken: persistedToken(),
765 responses: [Response.json(DESCRIPTOR), accessToken("account-2-token")],
766 });
767 const authorize = Effect.gen(function* () {
768 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
769 return yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
770 }).pipe(Effect.provide(harness.layer));
771
772 expect((yield* authorize).httpAuthorization.accessToken).toBe("cached-access-token");
773 yield* Ref.set(harness.session, Option.some({ accountId: "account-2" }));
774 expect((yield* authorize).httpAuthorization.accessToken).toBe("account-2-token");
775 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accountId).toBe("account-2");
776
777 yield* Ref.set(harness.session, Option.some({ accountId: "account-2" }));
778 expect((yield* authorize).httpAuthorization.accessToken).toBe("account-2-token");
779 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
780 expect(harness.fetch.calls).toHaveLength(2);
781 }),
782 );
783
784 it.effect("renews a legacy unowned token once and reuses its account-bound replacement", () =>
785 Effect.gen(function* () {
786 const legacy = new TokenStore.RemoteDpopAccessToken({
787 environmentId: ENVIRONMENT_ID,
788 label: DESCRIPTOR.label,
789 endpoint: ENDPOINT,
790 accessToken: "legacy-access-token",
791 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
792 dpopThumbprint: "thumbprint-1",
793 });
794 const harness = yield* makeHarness({
795 initialToken: legacy,
796 responses: [Response.json(DESCRIPTOR), accessToken("account-bound-token")],
797 });
798 const authorize = Effect.gen(function* () {
799 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
800 return yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
801 }).pipe(Effect.provide(harness.layer));
802
803 expect((yield* authorize).httpAuthorization.accessToken).toBe("account-bound-token");
804 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accountId).toBe("account-1");
805 expect((yield* authorize).httpAuthorization.accessToken).toBe("account-bound-token");
806 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
807 expect(harness.fetch.calls).toHaveLength(2);
808 }),
809 );
810
811 it.effect("requires fresh credentials after signing back into the same account", () =>
812 Effect.gen(function* () {
813 const harness = yield* makeHarness({
814 initialToken: persistedToken(),
815 responses: [Response.json(DESCRIPTOR), accessToken("new-session-token")],
816 });
817 yield* Effect.gen(function* () {
818 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
819 yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
820 yield* Ref.set(harness.session, Option.none());
821 const signedOut = yield* remote
822 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
823 .pipe(Effect.flip);
824 expect(signedOut).toMatchObject({
825 _tag: "ConnectionBlockedError",
826 reason: "authentication",
827 });
828 yield* Ref.set(harness.session, Option.some({ accountId: "account-1" }));
829 const signedIn = yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
830 expect(signedIn.httpAuthorization.accessToken).toBe("new-session-token");
831 }).pipe(Effect.provide(harness.layer));
832 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
833 }),
834 );
835
836 it.effect("renews a cached credential bound to a different signing key", () =>
837 Effect.gen(function* () {
838 const harness = yield* makeHarness({
839 initialToken: persistedToken(),
840 responses: [Response.json(DESCRIPTOR), accessToken("new-key-token")],
841 });
842 yield* Ref.set(harness.thumbprint, "thumbprint-2");
843 yield* Effect.gen(function* () {
844 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
845 const authorized = yield* remote.authorizeDpopHttp({
846 expectedEnvironmentId: ENVIRONMENT_ID,
847 });
848 expect(authorized.httpAuthorization.accessToken).toBe("new-key-token");
849 }).pipe(Effect.provide(harness.layer));
850 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.dpopThumbprint).toBe(
851 "thumbprint-2",
852 );
853 }),
854 );
855
856 it.effect("rejects a relay bootstrap for a different environment before contacting it", () =>
857 Effect.gen(function* () {
858 const harness = yield* makeHarness({
859 responses: [],
860 bootstrap: { ...BOOTSTRAP, environmentId: EnvironmentId.make("wrong-environment") },
861 });
862 const failure = yield* Effect.gen(function* () {
863 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
864 return yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
865 }).pipe(Effect.provide(harness.layer), Effect.flip);
866 expect(failure).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" });
867 expect(harness.fetch.calls).toHaveLength(0);
868 }),
869 );
870
871 it.effect("keeps a shared renewal alive when one waiting request is interrupted", () =>
872 Effect.gen(function* () {
873 const started = yield* Deferred.make<void>();
874 const release = yield* Deferred.make<void>();
875 const harness = yield* makeHarness({
876 beforeBootstrap: Deferred.succeed(started, undefined).pipe(
877 Effect.andThen(Deferred.await(release)),
878 ),
879 responses: [Response.json(DESCRIPTOR), accessToken("fresh-token")],
880 });
881 yield* Effect.gen(function* () {
882 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
883 const pending = yield* remote
884 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
885 .pipe(Effect.forkChild);
886 yield* Deferred.await(started);
887 const other = yield* remote
888 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
889 .pipe(Effect.forkChild);
890 yield* Queue.takeN(harness.tokenReads, 2);
891 yield* Fiber.interrupt(pending);
892 yield* Deferred.succeed(release, undefined);
893 const authorized = yield* Fiber.join(other);
894 expect(authorized.httpAuthorization.accessToken).toBe("fresh-token");
895 const cached = yield* remote.authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID });
896 expect(cached).toEqual(authorized);
897 }).pipe(Effect.provide(harness.layer));
898 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
899 }),
900 );
901
902 it.effect("times out a stalled cloud token read so later requests can renew again", () =>
903 Effect.gen(function* () {
904 const started = yield* Deferred.make<void>();
905 const reads = yield* Ref.make(0);
906 const harness = yield* makeHarness({
907 clerkToken: Effect.gen(function* () {
908 const read = yield* Ref.updateAndGet(reads, (value) => value + 1);
909 if (read === 1) {
910 yield* Deferred.succeed(started, undefined);
911 return yield* Effect.never;
912 }
913 return "clerk-session";
914 }),
915 responses: [Response.json(DESCRIPTOR), accessToken("fresh-token")],
916 });
917 yield* Effect.gen(function* () {
918 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
919 const pending = yield* remote
920 .authorizeDpopHttp({ expectedEnvironmentId: ENVIRONMENT_ID })
921 .pipe(Effect.flip, Effect.forkChild);
922 yield* Deferred.await(started);
923 yield* TestClock.adjust("30 seconds");
924 expect(yield* Fiber.join(pending)).toMatchObject({
925 _tag: "ConnectionTransientError",
926 reason: "timeout",
927 });
928 const authorized = yield* remote.authorizeDpopHttp({
929 expectedEnvironmentId: ENVIRONMENT_ID,
930 });
931 expect(authorized.httpAuthorization.accessToken).toBe("fresh-token");
932 }).pipe(Effect.provide(harness.layer));
933 expect(yield* Ref.get(reads)).toBe(2);
934 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1);
935 }),
936 );
433937});
packages/client-runtime/src/authorization/service.ts+272-89
......@@ -3,7 +3,8 @@ import {
33 EnvironmentId,
44 type ExecutionEnvironmentDescriptor,
55} from "@t3tools/contracts";
6import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
78import {
89 exchangeRemoteDpopAccessToken,
910 type RemoteEnvironmentAuthError,
......@@ -12,10 +13,15 @@ import {
1213} from "./remote.ts";
1314import {
1415 environmentMismatchError,
16 mapManagedRelayError,
1517 mapRemoteDpopEnvironmentError,
1618 mapRemoteEnvironmentError,
1719} from "../connection/errors.ts";
18import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
20import {
21 ConnectionBlockedError,
22 ConnectionTransientError,
23 type ConnectionAttemptError,
24} from "../connection/model.ts";
1925import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
2026import { environmentEndpointUrl } from "../environment/endpoint.ts";
2127import * as ClientCapabilities from "../platform/capabilities.ts";
......@@ -24,10 +30,13 @@ import * as TokenStore from "./tokenStore.ts";
2430import * as Clock from "effect/Clock";
2531import * as Context from "effect/Context";
2632import * as Effect from "effect/Effect";
33import * as Fiber from "effect/Fiber";
2734import * as Layer from "effect/Layer";
2835import * as Option from "effect/Option";
2936import * as Ref from "effect/Ref";
3037import * as Result from "effect/Result";
38import * as Scope from "effect/Scope";
39import * as Semaphore from "effect/Semaphore";
3140import * as HttpClient from "effect/unstable/http/HttpClient";
3241
3342import {
......@@ -35,12 +44,6 @@ import {
3544 type PreparedHttpAuthorization,
3645} from "../connection/model.ts";
3746
38export interface RelayEnvironmentAuthorization {
39 readonly environmentId: EnvironmentId;
40 readonly endpoint: RelayManagedEndpoint;
41 readonly credential: string;
42}
43
4447export interface AuthorizedRemoteEnvironment {
4548 readonly environmentId: EnvironmentId;
4649 readonly label: string;
......@@ -49,6 +52,13 @@ export interface AuthorizedRemoteEnvironment {
4952 readonly httpAuthorization: PreparedHttpAuthorization;
5053}
5154
55export interface AuthorizedRemoteHttpEnvironment {
56 readonly environmentId: EnvironmentId;
57 readonly label: string;
58 readonly httpBaseUrl: string;
59 readonly httpAuthorization: Extract<PreparedHttpAuthorization, { readonly _tag: "Dpop" }>;
60}
61
5262export class RemoteEnvironmentAuthorization extends Context.Service<
5363 RemoteEnvironmentAuthorization,
5464 {
......@@ -61,16 +71,17 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
6171 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
6272 readonly authorizeDpop: (input: {
6373 readonly expectedEnvironmentId: EnvironmentId;
64 readonly obtainBootstrap: Effect.Effect<
65 RelayEnvironmentAuthorization,
66 ConnectionAttemptError
67 >;
6874 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
75 readonly authorizeDpopHttp: (input: {
76 readonly expectedEnvironmentId: EnvironmentId;
77 readonly rejectedAccessToken?: string;
78 }) => Effect.Effect<AuthorizedRemoteHttpEnvironment, ConnectionAttemptError>;
6979 }
7080>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
7181
7282const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
7383const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
84const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000;
7485
7586function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
7687 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
......@@ -88,10 +99,28 @@ const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescript
8899});
89100
90101export const make = Effect.gen(function* () {
102 const serviceScope = yield* Scope.Scope;
91103 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
104 const relay = yield* ManagedRelay.ManagedRelayClient;
105 const cloudSession = yield* ClientCapabilities.CloudSession;
106 const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity;
92107 const presentation = yield* ClientCapabilities.ClientPresentation;
93108 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
94109 const httpClient = yield* HttpClient.HttpClient;
110 const tokenLock = yield* Semaphore.make(1);
111 const tokenOwners = new Map<
112 EnvironmentId,
113 { readonly accessToken: string; readonly identity: ClientCapabilities.CloudSessionIdentity }
114 >();
115 const pendingTokens = new Map<
116 EnvironmentId,
117 {
118 readonly id: object;
119 readonly identity: ClientCapabilities.CloudSessionIdentity;
120 readonly thumbprint: string;
121 readonly token: Effect.Effect<TokenStore.RemoteDpopAccessToken, ConnectionAttemptError>;
122 }
123 >();
95124 const bearerDescriptors = yield* Ref.make<
96125 ReadonlyMap<
97126 EnvironmentId,
......@@ -192,74 +221,77 @@ export const make = Effect.gen(function* () {
192221 },
193222 );
194223
195 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(
196 function* (input: {
197 readonly expectedEnvironmentId: Parameters<
198 RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]
199 >[0]["expectedEnvironmentId"];
200 readonly obtainBootstrap: Parameters<
201 RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]
202 >[0]["obtainBootstrap"];
203 }) {
204 const thumbprint = yield* signer.thumbprint.pipe(
205 Effect.mapError(
206 () =>
207 new ConnectionBlockedError({
208 reason: "configuration",
209 detail: "Could not load the environment authorization key.",
210 }),
211 ),
212 Effect.withSpan("environment.authorization.dpopKey.resolve"),
213 );
214 const now = yield* Clock.currentTimeMillis;
215 const cached = yield* tokenStore
216 .get(input.expectedEnvironmentId)
217 .pipe(Effect.withSpan("environment.authorization.accessToken.cache"));
218 if (
219 Option.isSome(cached) &&
220 cached.value.environmentId === input.expectedEnvironmentId &&
221 cached.value.dpopThumbprint === thumbprint &&
222 cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS
223 ) {
224 yield* Effect.annotateCurrentSpan({
225 "connection.remote_token_cache": "hit",
226 });
227 const cachedSocket = yield* createDpopSocketUrl(
228 cached.value,
229 CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
230 ).pipe(Effect.result);
231 if (Result.isSuccess(cachedSocket)) {
232 return {
233 environmentId: cached.value.environmentId,
234 label: cached.value.label,
235 httpBaseUrl: cached.value.endpoint.httpBaseUrl,
236 socketUrl: cachedSocket.success,
237 httpAuthorization: {
238 _tag: "Dpop" as const,
239 accessToken: cached.value.accessToken,
240 expiresAtEpochMs: cached.value.expiresAtEpochMs,
241 },
242 };
243 }
244 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
245 return yield* mapDpopSocketError(cachedSocket.failure);
246 }
247 yield* tokenStore
248 .remove(input.expectedEnvironmentId)
249 .pipe(Effect.withSpan("environment.authorization.accessToken.remove"));
250 }
224 const sessionChanged = () =>
225 new ConnectionBlockedError({
226 reason: "authentication",
227 detail: "Your cloud sign-in changed. Sign in again to authorize the environment.",
228 });
229
230 const assertSession = Effect.fnUntraced(function* (
231 identity: ClientCapabilities.CloudSessionIdentity,
232 ) {
233 const current = yield* cloudSession.identity;
234 if (Option.isNone(current) || current.value !== identity) {
235 return yield* sessionChanged();
236 }
237 });
251238
252 yield* Effect.annotateCurrentSpan({
253 "connection.remote_token_cache": "miss",
239 // Called under tokenLock so a late rejection cannot remove a newer credential.
240 const removeRejectedToken = Effect.fnUntraced(function* (
241 environmentId: EnvironmentId,
242 accessToken: string,
243 ) {
244 const cached = yield* tokenStore.get(environmentId);
245 if (Option.isSome(cached) && cached.value.accessToken === accessToken) {
246 yield* tokenStore.remove(environmentId);
247 tokenOwners.delete(environmentId);
248 }
249 });
250
251 const obtainBootstrap = Effect.fn("relay.connection.bootstrap.obtain")(function* (
252 environmentId: EnvironmentId,
253 identity: ClientCapabilities.CloudSessionIdentity,
254 ) {
255 yield* assertSession(identity);
256 const clerkToken = yield* cloudSession.clerkToken.pipe(
257 Effect.withSpan("relay.connection.cloudSessionToken.resolve"),
258 );
259 const deviceId = yield* deviceIdentity.deviceId.pipe(
260 Effect.withSpan("relay.connection.deviceIdentity.resolve"),
261 );
262 yield* assertSession(identity);
263 const connected = yield* relay
264 .connectEnvironment({
265 clerkToken,
266 scopes: [RelayEnvironmentConnectScope],
267 environmentId,
268 ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}),
269 })
270 .pipe(Effect.mapError(mapManagedRelayError));
271 if (connected.environmentId !== environmentId) {
272 return yield* environmentMismatchError({
273 expected: environmentId,
274 actual: connected.environmentId,
254275 });
255 const bootstrap = yield* input.obtainBootstrap;
276 }
277 yield* assertSession(identity);
278 return connected;
279 });
280
281 const exchangeDpopToken = Effect.fn("clientRuntime.connection.remote.exchangeDpopToken")(
282 function* (
283 environmentId: EnvironmentId,
284 thumbprint: string,
285 identity: ClientCapabilities.CloudSessionIdentity,
286 ) {
287 const bootstrap = yield* obtainBootstrap(environmentId, identity);
256288 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
257289 Effect.provideService(HttpClient.HttpClient, httpClient),
258290 Effect.withSpan("environment.authorization.descriptor"),
259291 );
260 if (descriptor.environmentId !== input.expectedEnvironmentId) {
292 if (descriptor.environmentId !== environmentId) {
261293 return yield* environmentMismatchError({
262 expected: input.expectedEnvironmentId,
294 expected: environmentId,
263295 actual: descriptor.environmentId,
264296 });
265297 }
......@@ -277,6 +309,7 @@ export const make = Effect.gen(function* () {
277309 }),
278310 ),
279311 );
312 yield* assertSession(identity);
280313 const access = yield* exchangeRemoteDpopAccessToken({
281314 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
282315 credential: bootstrap.credential,
......@@ -289,36 +322,186 @@ export const make = Effect.gen(function* () {
289322 Effect.withSpan("environment.authorization.accessToken.exchange"),
290323 );
291324 const issuedAt = yield* Clock.currentTimeMillis;
292 const token = new TokenStore.RemoteDpopAccessToken({
325 return new TokenStore.RemoteDpopAccessToken({
293326 environmentId: descriptor.environmentId,
327 accountId: identity.accountId,
294328 label: descriptor.label,
295329 endpoint: bootstrap.endpoint,
296330 accessToken: access.access_token,
297331 expiresAtEpochMs: issuedAt + access.expires_in * 1_000,
298332 dpopThumbprint: thumbprint,
299333 });
300 const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError));
301 yield* tokenStore
302 .put(token)
303 .pipe(Effect.withSpan("environment.authorization.accessToken.persist"));
304 return {
305 environmentId: descriptor.environmentId,
306 label: descriptor.label,
307 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
308 socketUrl,
309 httpAuthorization: {
310 _tag: "Dpop" as const,
311 accessToken: token.accessToken,
312 expiresAtEpochMs: token.expiresAtEpochMs,
313 },
314 };
315334 },
316335 );
317336
337 const getDpopToken = Effect.fn("clientRuntime.connection.remote.getDpopToken")(function* (
338 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]>[0],
339 ) {
340 const session = yield* cloudSession.identity;
341 if (Option.isNone(session)) {
342 return yield* sessionChanged();
343 }
344 const identity = session.value;
345 const thumbprint = yield* signer.thumbprint.pipe(
346 Effect.mapError(
347 () =>
348 new ConnectionBlockedError({
349 reason: "configuration",
350 detail: "Could not load the environment authorization key.",
351 }),
352 ),
353 Effect.withSpan("environment.authorization.dpopKey.resolve"),
354 );
355 const selected = yield* tokenLock.withPermits(1)(
356 Effect.gen(function* () {
357 yield* assertSession(identity);
358 const now = yield* Clock.currentTimeMillis;
359 const cached = yield* tokenStore
360 .get(input.expectedEnvironmentId)
361 .pipe(Effect.withSpan("environment.authorization.accessToken.cache"));
362 const owner = tokenOwners.get(input.expectedEnvironmentId);
363 if (
364 Option.isSome(cached) &&
365 cached.value.environmentId === input.expectedEnvironmentId &&
366 cached.value.accountId === identity.accountId &&
367 cached.value.dpopThumbprint === thumbprint &&
368 cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS &&
369 cached.value.accessToken !== input.rejectedAccessToken &&
370 (owner?.accessToken !== cached.value.accessToken || owner.identity === identity)
371 ) {
372 tokenOwners.set(input.expectedEnvironmentId, {
373 accessToken: cached.value.accessToken,
374 identity,
375 });
376 return { token: Effect.succeed(cached.value), fromCache: true };
377 }
378 if (input.rejectedAccessToken !== undefined) {
379 yield* removeRejectedToken(input.expectedEnvironmentId, input.rejectedAccessToken);
380 }
381 const pending = pendingTokens.get(input.expectedEnvironmentId);
382 if (pending?.identity === identity && pending.thumbprint === thumbprint) {
383 return { token: pending.token, fromCache: false };
384 }
385
386 const id = {};
387 const fiber = yield* exchangeDpopToken(
388 input.expectedEnvironmentId,
389 thumbprint,
390 identity,
391 ).pipe(
392 Effect.flatMap((token) =>
393 tokenLock.withPermits(1)(
394 Effect.gen(function* () {
395 yield* assertSession(identity);
396 yield* tokenStore
397 .put(token)
398 .pipe(Effect.withSpan("environment.authorization.accessToken.persist"));
399 const current = yield* cloudSession.identity;
400 if (Option.isNone(current) || current.value !== identity) {
401 yield* removeRejectedToken(input.expectedEnvironmentId, token.accessToken);
402 return yield* sessionChanged();
403 }
404 tokenOwners.set(input.expectedEnvironmentId, {
405 accessToken: token.accessToken,
406 identity,
407 });
408 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
409 pendingTokens.delete(input.expectedEnvironmentId);
410 }
411 return token;
412 }),
413 ),
414 ),
415 Effect.timeoutOrElse({
416 duration: DPOP_AUTHORIZATION_TIMEOUT_MS,
417 orElse: () =>
418 Effect.fail(
419 new ConnectionTransientError({
420 reason: "timeout",
421 detail: "Timed out renewing the environment credential.",
422 }),
423 ),
424 }),
425 Effect.ensuring(
426 tokenLock.withPermits(1)(
427 Effect.sync(() => {
428 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
429 pendingTokens.delete(input.expectedEnvironmentId);
430 }
431 }),
432 ),
433 ),
434 // One caller's timeout must not cancel renewal for other HTTP requests.
435 Effect.forkIn(serviceScope),
436 );
437 const token = Fiber.join(fiber);
438 pendingTokens.set(input.expectedEnvironmentId, { id, identity, thumbprint, token });
439 return { token, fromCache: false };
440 }),
441 );
442 yield* Effect.annotateCurrentSpan({
443 "connection.remote_token_cache": selected.fromCache ? "hit" : "miss",
444 });
445 const token = yield* selected.token;
446 yield* assertSession(identity);
447 return { token, fromCache: selected.fromCache, identity };
448 });
449
450 const httpAuthorization = (
451 token: TokenStore.RemoteDpopAccessToken,
452 ): AuthorizedRemoteHttpEnvironment => ({
453 environmentId: token.environmentId,
454 label: token.label,
455 httpBaseUrl: token.endpoint.httpBaseUrl,
456 httpAuthorization: {
457 _tag: "Dpop",
458 accessToken: token.accessToken,
459 expiresAtEpochMs: token.expiresAtEpochMs,
460 },
461 });
462
463 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(function* (
464 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]>[0],
465 ) {
466 let selected = yield* getDpopToken(input);
467 if (selected.fromCache) {
468 const cachedSocket = yield* createDpopSocketUrl(
469 selected.token,
470 CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
471 ).pipe(Effect.result);
472 if (Result.isSuccess(cachedSocket)) {
473 yield* assertSession(selected.identity);
474 return { ...httpAuthorization(selected.token), socketUrl: cachedSocket.success };
475 }
476 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
477 return yield* mapDpopSocketError(cachedSocket.failure);
478 }
479 selected = yield* getDpopToken({
480 ...input,
481 rejectedAccessToken: selected.token.accessToken,
482 });
483 }
484 const socket = yield* createDpopSocketUrl(selected.token).pipe(Effect.result);
485 if (Result.isFailure(socket)) {
486 yield* tokenLock.withPermits(1)(
487 removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken),
488 );
489 return yield* mapDpopSocketError(socket.failure);
490 }
491 yield* assertSession(selected.identity);
492 return { ...httpAuthorization(selected.token), socketUrl: socket.success };
493 });
494
318495 return RemoteEnvironmentAuthorization.of({
319496 authorizeBearer,
320497 authorizeDpop: (input) =>
321498 authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")),
499 authorizeDpopHttp: (input) =>
500 getDpopToken(input).pipe(
501 Effect.map(({ token }) => httpAuthorization(token)),
502 Effect.withSpan("environment.authorization.http"),
503 withRelayClientTracing,
504 ),
322505 });
323506});
324507
packages/client-runtime/src/authorization/tokenStore.ts+1
......@@ -12,6 +12,7 @@ export class RemoteDpopAccessToken extends Schema.Class<RemoteDpopAccessToken>(
1212 "@t3tools/client-runtime/authorization/RemoteDpopAccessToken",
1313)({
1414 environmentId: EnvironmentId,
15 accountId: Schema.optionalKey(Schema.String),
1516 label: Schema.String,
1617 endpoint: RelayManagedEndpoint,
1718 accessToken: Schema.String,
packages/client-runtime/src/connection/layer.ts+5-6
......@@ -11,13 +11,9 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
1111import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1212import * as RpcSession from "../rpc/session.ts";
1313
14const resolverLayer = ConnectionResolver.layer.pipe(
15 Layer.provide(RemoteEnvironmentAuthorization.layer),
16);
17
1814export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
1915 const driverLayer = ConnectionDriver.layer.pipe(
20 Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))),
16 Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))),
2117 );
2218 const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer));
2319 const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer));
......@@ -37,7 +33,10 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
3733 );
3834 }).pipe(Effect.withSpan("clientRuntime.connection.application.start")),
3935 );
40 return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer));
36 return connectionStartupLayer.pipe(
37 Layer.provideMerge(connectionServicesLayer),
38 Layer.provideMerge(RemoteEnvironmentAuthorization.layer),
39 );
4140}
4241
4342export const layer = layerWithOptions({});
packages/client-runtime/src/connection/resolver.test.ts+44-141
......@@ -1,5 +1,4 @@
11import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts";
2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
32import { RelayClientTracer } from "@t3tools/shared/relayTracing";
43import { describe, expect, it } from "@effect/vitest";
54import * as Effect from "effect/Effect";
......@@ -8,7 +7,6 @@ import * as Option from "effect/Option";
87import * as Ref from "effect/Ref";
98import * as Tracer from "effect/Tracer";
109
11import * as ManagedRelay from "../relay/managedRelay.ts";
1210import * as ConnectionResolver from "./resolver.ts";
1311import * as ClientCapabilities from "../platform/capabilities.ts";
1412import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
......@@ -35,7 +33,6 @@ const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
3533const ENDPOINT = {
3634 httpBaseUrl: "https://environment.example.test",
3735 wsBaseUrl: "wss://environment.example.test",
38 providerKind: "cloudflare_tunnel" as const,
3936};
4037const SSH_TARGET: DesktopSshEnvironmentTarget = {
4138 alias: "development",
......@@ -51,10 +48,6 @@ function catalogEntry(
5148 return { target, profile };
5249}
5350
54function unsupported<A>(name: string): Effect.Effect<A> {
55 return Effect.die(new Error(`Unexpected relay call: ${name}`));
56}
57
5851function collectingTracer(spans: Array<string>): Tracer.Tracer {
5952 return Tracer.make({
6053 span: (options) => {
......@@ -69,30 +62,9 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {
6962 });
7063}
7164
72function relayClient(
73 connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"],
74) {
75 return ManagedRelay.ManagedRelayClient.of({
76 relayUrl: "https://relay.example.test",
77 listEnvironments: () => unsupported("listEnvironments"),
78 listDevices: () => unsupported("listDevices"),
79 createEnvironmentLinkChallenge: () => unsupported("createEnvironmentLinkChallenge"),
80 linkEnvironment: () => unsupported("linkEnvironment"),
81 unlinkEnvironment: () => unsupported("unlinkEnvironment"),
82 getEnvironmentStatus: () => unsupported("getEnvironmentStatus"),
83 connectEnvironment,
84 registerDevice: () => unsupported("registerDevice"),
85 unregisterDevice: () => unsupported("unregisterDevice"),
86 registerLiveActivity: () => unsupported("registerLiveActivity"),
87 getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"),
88 resetTokenCache: Effect.void,
89 });
90}
91
9265const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
9366 readonly profiles?: ReadonlyArray<ConnectionProfile>;
9467 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
95 readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"];
9668 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
9769 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
9870 readonly primaryBearerToken?: string;
......@@ -131,19 +103,18 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
131103 authorizeDpop:
132104 options?.authorizeDpop ??
133105 ((input) =>
134 input.obtainBootstrap.pipe(
135 Effect.as({
136 environmentId: input.expectedEnvironmentId,
137 label: "Authorized relay environment",
138 httpBaseUrl: ENDPOINT.httpBaseUrl,
139 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
140 httpAuthorization: {
141 _tag: "Dpop" as const,
142 accessToken: "dpop-access-token",
143 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
144 },
145 }),
146 )),
106 Effect.succeed({
107 environmentId: input.expectedEnvironmentId,
108 label: "Authorized relay environment",
109 httpBaseUrl: ENDPOINT.httpBaseUrl,
110 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
111 httpAuthorization: {
112 _tag: "Dpop" as const,
113 accessToken: "dpop-access-token",
114 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
115 },
116 })),
117 authorizeDpopHttp: () => Effect.die("unused"),
147118 });
148119 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
149120 provision: () => Effect.die("unused"),
......@@ -165,10 +136,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
165136 const dependencies = Layer.mergeAll(
166137 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),
167138 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
168 Layer.succeed(
169 ClientCapabilities.CloudSession,
170 ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }),
171 ),
172139 Layer.succeed(
173140 ClientCapabilities.PrimaryEnvironmentAuth,
174141 ClientCapabilities.PrimaryEnvironmentAuth.of({
......@@ -182,27 +149,8 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
182149 scopes: [],
183150 }),
184151 ),
185 Layer.succeed(
186 ClientCapabilities.RelayDeviceIdentity,
187 ClientCapabilities.RelayDeviceIdentity.of({
188 deviceId: Effect.succeed(Option.some("device-1")),
189 }),
190 ),
191152 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),
192153 Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh),
193 Layer.succeed(
194 ManagedRelay.ManagedRelayClient,
195 relayClient(
196 options?.connectEnvironment ??
197 ((input) =>
198 Effect.succeed({
199 environmentId: input.environmentId,
200 endpoint: ENDPOINT,
201 credential: "relay-bootstrap",
202 expiresAt: "2026-06-06T00:00:00.000Z",
203 })),
204 ),
205 ),
206154 );
207155
208156 return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies)));
......@@ -314,66 +262,27 @@ describe("ConnectionResolver", () => {
314262 }),
315263 );
316264
317 it.effect("brokers relay credentials with the current cloud session and device identity", () =>
265 it.effect("prepares relay connections with the authorized endpoint and credentials", () =>
318266 Effect.gen(function* () {
319 const relayInputs = yield* Ref.make<
320 ReadonlyArray<{
321 readonly clerkToken: string;
322 readonly scopes: ReadonlyArray<string>;
323 readonly deviceId?: string;
324 }>
325 >([]);
326 const bootstrapCredentials = yield* Ref.make<ReadonlyArray<string>>([]);
327267 const target = new RelayConnectionTarget({
328268 environmentId: ENVIRONMENT_ID,
329269 label: "Cloud",
330270 });
331 const brokerLayer = yield* makeDependencies({
332 connectEnvironment: (input) =>
333 Ref.update(relayInputs, (values) => [
334 ...values,
335 {
336 clerkToken: input.clerkToken,
337 scopes: input.scopes,
338 ...(input.deviceId ? { deviceId: input.deviceId } : {}),
339 },
340 ]).pipe(
341 Effect.as({
342 environmentId: input.environmentId,
343 endpoint: ENDPOINT,
344 credential: "relay-bootstrap",
345 expiresAt: "2026-06-06T00:00:00.000Z",
346 }),
347 ),
348 authorizeDpop: (input) =>
349 input.obtainBootstrap.pipe(
350 Effect.tap((bootstrap) =>
351 Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]),
352 ),
353 Effect.as({
354 environmentId: input.expectedEnvironmentId,
355 label: "Cloud",
356 httpBaseUrl: ENDPOINT.httpBaseUrl,
357 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",
358 httpAuthorization: {
359 _tag: "Dpop" as const,
360 accessToken: "dpop-access-token",
361 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
362 },
363 }),
364 ),
365 });
271 const brokerLayer = yield* makeDependencies();
366272 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
367273
368 expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop");
369 expect(yield* Ref.get(relayInputs)).toEqual([
370 {
371 clerkToken: "clerk-session",
372 scopes: [RelayEnvironmentConnectScope],
373 deviceId: "device-1",
274 expect(yield* broker.prepare(catalogEntry(target))).toEqual({
275 environmentId: ENVIRONMENT_ID,
276 label: "Authorized relay environment",
277 httpBaseUrl: ENDPOINT.httpBaseUrl,
278 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
279 httpAuthorization: {
280 _tag: "Dpop",
281 accessToken: "dpop-access-token",
282 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
374283 },
375 ]);
376 expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]);
284 target,
285 });
377286 }),
378287 );
379288
......@@ -387,20 +296,17 @@ describe("ConnectionResolver", () => {
387296 });
388297 const brokerLayer = yield* makeDependencies({
389298 authorizeDpop: (input) =>
390 input.obtainBootstrap.pipe(
391 Effect.as({
392 environmentId: input.expectedEnvironmentId,
393 label: "Cloud",
394 httpBaseUrl: ENDPOINT.httpBaseUrl,
395 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",
396 httpAuthorization: {
397 _tag: "Dpop" as const,
398 accessToken: "dpop-access-token",
399 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
400 },
401 }),
402 Effect.withSpan("test.remote.authorizeDpop"),
403 ),
299 Effect.succeed({
300 environmentId: input.expectedEnvironmentId,
301 label: "Cloud",
302 httpBaseUrl: ENDPOINT.httpBaseUrl,
303 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",
304 httpAuthorization: {
305 _tag: "Dpop" as const,
306 accessToken: "dpop-access-token",
307 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
308 },
309 }).pipe(Effect.withSpan("test.remote.authorizeDpop")),
404310 });
405311 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
406312
......@@ -470,27 +376,24 @@ describe("ConnectionResolver", () => {
470376 }),
471377 );
472378
473 it.effect("classifies relay request timeouts as retryable connection failures", () =>
379 it.effect("preserves relay authorization failure classification and trace details", () =>
474380 Effect.gen(function* () {
475381 const target = new RelayConnectionTarget({
476382 environmentId: ENVIRONMENT_ID,
477383 label: "Cloud",
478384 });
385 const authorizationError = new ConnectionTransientError({
386 reason: "timeout",
387 detail: "Relay environment connection timed out.",
388 traceId: "relay-trace",
389 });
479390 const brokerLayer = yield* makeDependencies({
480 connectEnvironment: () =>
481 Effect.fail(
482 new ManagedRelay.ManagedRelayRequestTimeoutError({
483 activity: "Relay environment connection",
484 timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS,
485 traceId: null,
486 }),
487 ),
391 authorizeDpop: () => Effect.fail(authorizationError),
488392 });
489393 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
490394 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
491395
492 expect(error).toBeInstanceOf(ConnectionTransientError);
493 expect(error).toMatchObject({ reason: "timeout" });
396 expect(error).toBe(authorizationError);
494397 }),
495398 );
496399});
packages/client-runtime/src/connection/resolver.ts+1-34
......@@ -1,5 +1,4 @@
11import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
43import * as Context from "effect/Context";
54import * as Effect from "effect/Effect";
......@@ -9,7 +8,6 @@ import * as Schema from "effect/Schema";
98
109import { appendClientConnectionParams } from "../authorization/remote.ts";
1110import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
12import * as ManagedRelay from "../relay/managedRelay.ts";
1311import * as ClientCapabilities from "../platform/capabilities.ts";
1412import {
1513 BearerConnectionCredential,
......@@ -18,12 +16,7 @@ import {
1816 SshConnectionProfile,
1917} from "./catalog.ts";
2018import * as ConnectionCredentialStore from "./credentialStore.ts";
21import {
22 credentialMissingError,
23 environmentMismatchError,
24 mapManagedRelayError,
25 profileMissingError,
26} from "./errors.ts";
19import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";
2720import type {
2821 BearerConnectionTarget,
2922 ConnectionTarget,
......@@ -148,38 +141,12 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
148141});
149142
150143const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {
151 const relay = yield* ManagedRelay.ManagedRelayClient;
152 const session = yield* ClientCapabilities.CloudSession;
153 const identity = yield* ClientCapabilities.RelayDeviceIdentity;
154144 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
155145
156146 return Effect.fnUntraced(
157147 function* (target: RelayConnectionTarget) {
158148 const authorized = yield* remote.authorizeDpop({
159149 expectedEnvironmentId: target.environmentId,
160 obtainBootstrap: Effect.gen(function* () {
161 const clerkToken = yield* session.clerkToken.pipe(
162 Effect.withSpan("relay.connection.cloudSessionToken.resolve"),
163 );
164 const deviceId = yield* identity.deviceId.pipe(
165 Effect.withSpan("relay.connection.deviceIdentity.resolve"),
166 );
167 const connected = yield* relay
168 .connectEnvironment({
169 clerkToken,
170 scopes: [RelayEnvironmentConnectScope],
171 environmentId: target.environmentId,
172 ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}),
173 })
174 .pipe(Effect.mapError(mapManagedRelayError));
175 if (connected.environmentId !== target.environmentId) {
176 return yield* environmentMismatchError({
177 expected: target.environmentId,
178 actual: connected.environmentId,
179 });
180 }
181 return connected;
182 }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")),
183150 });
184151 return {
185152 environmentId: authorized.environmentId,
packages/client-runtime/src/connection/supervisor.test.ts+186-140
......@@ -1,4 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
22import { RelayClientTracer } from "@t3tools/shared/relayTracing";
33import { describe, expect, it } from "@effect/vitest";
44import * as Deferred from "effect/Deferred";
......@@ -11,12 +11,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
1111import * as TestClock from "effect/testing/TestClock";
1212import * as Tracer from "effect/Tracer";
1313
14import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
15import * as TokenStore from "../authorization/tokenStore.ts";
16import * as ClientCapabilities from "../platform/capabilities.ts";
17import {
18 ManagedRelayClient,
19 ManagedRelayDpopSigner,
20 ManagedRelayRequestTimeoutError,
21} from "../relay/managedRelay.ts";
22import { remoteHttpClientLayer } from "../rpc/http.ts";
1423import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import { fetchEnvironmentSessionState } from "../state/session.ts";
1525import type { ConnectionCatalogEntry } from "./catalog.ts";
1626import * as Connectivity from "./connectivity.ts";
1727import * as ConnectionDriver from "./driver.ts";
1828import {
19 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
2029 ConnectionBlockedError,
2130 ConnectionTransientError,
2231 PrimaryConnectionTarget,
......@@ -1127,183 +1136,220 @@ describe("EnvironmentSupervisor", () => {
11271136 }),
11281137 );
11291138
1130 it.effect("hands off relay authorization after the replacement session is ready", () =>
1139 it.effect("keeps a healthy relay session when its HTTP access token expires", () =>
11311140 Effect.gen(function* () {
1132 const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2;
1133 const replacementStarted = yield* Deferred.make<void>();
1134 const releaseReplacement = yield* Deferred.make<void>();
11351141 const harness = yield* makeHarness({
11361142 prepare: (attempt) =>
1137 attempt === 2
1138 ? Effect.fail(transient("Authorization refresh failed."))
1139 : Effect.succeed({
1140 ...PREPARED_CONNECTION,
1141 target: RELAY_TARGET,
1142 httpAuthorization: {
1143 _tag: "Dpop",
1144 accessToken: `access-token-${attempt}`,
1145 expiresAtEpochMs: tokenLifetimeMs * attempt,
1146 },
1147 }),
1148 ready: (attempt) =>
1149 attempt === 2
1150 ? Deferred.succeed(replacementStarted, undefined).pipe(
1151 Effect.andThen(Deferred.await(releaseReplacement)),
1152 )
1153 : Effect.void,
1143 Effect.succeed({
1144 ...PREPARED_CONNECTION,
1145 target: RELAY_TARGET,
1146 httpAuthorization: {
1147 _tag: "Dpop",
1148 accessToken: `access-token-${attempt}`,
1149 expiresAtEpochMs: 3_600_000 * attempt,
1150 },
1151 }),
11541152 });
11551153 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
11561154 initiallyDesired: true,
11571155 }).pipe(Effect.provide(harness.dependencies));
11581156
11591157 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1160 const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
1161 const firstPrepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1162 yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1);
1163 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
1158 const session = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
11641159
1165 yield* TestClock.adjust(1);
1166 expect(yield* Ref.get(harness.prepareCount)).toBe(2);
1167 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
1168 expect(yield* Ref.get(harness.releaseCount)).toBe(0);
1169 yield* Effect.yieldNow;
1160 yield* TestClock.adjust("2 hours");
11701161
1171 yield* TestClock.adjust("2999 millis");
1172 expect(yield* Ref.get(harness.prepareCount)).toBe(2);
1173 yield* TestClock.adjust("1 milli");
1174 yield* Deferred.await(replacementStarted);
1175
1176 expect(yield* Ref.get(harness.prepareCount)).toBe(3);
1177 expect(yield* Ref.get(harness.sessionCount)).toBe(2);
1162 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
11781163 expect(yield* Ref.get(harness.releaseCount)).toBe(0);
1164 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session);
11791165 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
11801166 phase: "connected",
11811167 generation: 1,
11821168 });
1183 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);
1184 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared))).toBe(
1185 firstPrepared,
1186 );
1187
1188 yield* Deferred.succeed(releaseReplacement, undefined);
1189 yield* awaitState(
1190 supervisor.state,
1191 (state) => state.phase === "connected" && state.generation === 2,
1192 );
1193
1194 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
1195 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).not.toBe(
1196 firstSession,
1197 );
1198 expect(
1199 Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).httpAuthorization,
1200 ).toMatchObject({ accessToken: "access-token-3" });
12011169 }).pipe(Effect.provide(TestClock.layer())),
12021170 );
12031171
1204 it.effect("cleans up timed-out replacements and an interrupted retry", () =>
1172 it.effect("refreshes HTTP authorization without replacing the active relay session", () =>
12051173 Effect.gen(function* () {
1206 const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2;
1207 const replacementStarted = yield* Deferred.make<void>();
1208 const retryStarted = yield* Deferred.make<void>();
1209 const harness = yield* makeHarness({
1210 prepare: (attempt) =>
1211 Effect.succeed({
1212 ...PREPARED_CONNECTION,
1213 target: RELAY_TARGET,
1214 httpAuthorization: {
1215 _tag: "Dpop",
1216 accessToken: `access-token-${attempt}`,
1217 expiresAtEpochMs: tokenLifetimeMs * attempt,
1218 },
1174 const endpoint = {
1175 httpBaseUrl: TARGET.httpBaseUrl,
1176 wsBaseUrl: TARGET.wsBaseUrl,
1177 providerKind: "cloudflare_tunnel" as const,
1178 };
1179 const token = yield* Ref.make(
1180 Option.some(
1181 new TokenStore.RemoteDpopAccessToken({
1182 environmentId: TARGET.environmentId,
1183 accountId: "test-account",
1184 label: TARGET.label,
1185 endpoint,
1186 accessToken: "access-token-1",
1187 expiresAtEpochMs: 3_600_000,
1188 dpopThumbprint: "test-thumbprint",
12191189 }),
1220 ready: (attempt) => {
1221 if (attempt === 2) {
1222 return Deferred.succeed(replacementStarted, undefined).pipe(
1223 Effect.andThen(Effect.never),
1190 ),
1191 );
1192 const bootstrapFails = yield* Ref.make(false);
1193 const bootstrapCalls = yield* Ref.make(0);
1194 const httpPaths: Array<string> = [];
1195 const sessionAuthorizations: Array<string | null> = [];
1196 const fetchFn = ((input, init) => {
1197 const request = new Request(input, init);
1198 const pathname = new URL(request.url).pathname;
1199 httpPaths.push(pathname);
1200 switch (pathname) {
1201 case "/.well-known/t3/environment":
1202 return Promise.resolve(
1203 Response.json({
1204 environmentId: TARGET.environmentId,
1205 label: TARGET.label,
1206 platform: { os: "linux", arch: "x64" },
1207 serverVersion: "0.0.0-test",
1208 capabilities: { repositoryIdentity: true },
1209 }),
1210 );
1211 case "/oauth/token":
1212 return Promise.resolve(
1213 Response.json({
1214 access_token: "access-token-2",
1215 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
1216 token_type: "DPoP",
1217 expires_in: 3_600,
1218 scope: AuthStandardClientScopes.join(" "),
1219 }),
1220 );
1221 case "/api/auth/websocket-ticket":
1222 return Promise.resolve(
1223 Response.json({
1224 ticket: "ws-ticket",
1225 expiresAt: "2026-09-04T01:00:00.000Z",
1226 }),
1227 );
1228 case "/api/auth/session": {
1229 const authorization = request.headers.get("authorization");
1230 sessionAuthorizations.push(authorization);
1231 return Promise.resolve(
1232 Response.json({
1233 authenticated: authorization === "DPoP access-token-2",
1234 auth: {
1235 policy: "loopback-browser",
1236 bootstrapMethods: ["one-time-token"],
1237 sessionMethods: ["dpop-access-token"],
1238 sessionCookieName: "t3_session_test",
1239 },
1240 scopes: AuthStandardClientScopes,
1241 }),
12241242 );
12251243 }
1226 if (attempt === 3) {
1227 return Deferred.succeed(retryStarted, undefined).pipe(Effect.andThen(Effect.never));
1244 default:
1245 return Promise.reject(new Error(`Unexpected HTTP request to ${request.url}`));
1246 }
1247 }) satisfies typeof fetch;
1248 const signer = ManagedRelayDpopSigner.of({
1249 thumbprint: Effect.succeed("test-thumbprint"),
1250 createProof: () => Effect.succeed("test-proof"),
1251 });
1252 const unused = () => Effect.die("Unexpected relay operation.");
1253 const relay = ManagedRelayClient.of({
1254 relayUrl: "https://relay.example.test",
1255 listEnvironments: unused,
1256 listDevices: unused,
1257 createEnvironmentLinkChallenge: unused,
1258 linkEnvironment: unused,
1259 unlinkEnvironment: unused,
1260 getEnvironmentStatus: unused,
1261 connectEnvironment: Effect.fn("TestConnectionHttp.connectEnvironment")(function* () {
1262 yield* Ref.update(bootstrapCalls, (count) => count + 1);
1263 if (yield* Ref.get(bootstrapFails)) {
1264 return yield* new ManagedRelayRequestTimeoutError({
1265 activity: "Relay environment connection",
1266 timeoutMs: 6_000,
1267 traceId: null,
1268 });
12281269 }
1229 return Effect.void;
1230 },
1270 return {
1271 environmentId: TARGET.environmentId,
1272 endpoint,
1273 credential: "relay-bootstrap",
1274 expiresAt: "2026-09-04T01:00:00.000Z",
1275 };
1276 }),
1277 registerDevice: unused,
1278 unregisterDevice: unused,
1279 registerLiveActivity: unused,
1280 getAgentActivitySnapshot: unused,
1281 resetTokenCache: Effect.void,
1282 });
1283 const httpLayer = remoteHttpClientLayer(fetchFn);
1284 const remoteAuthorization = yield* RemoteEnvironmentAuthorization.make.pipe(
1285 Effect.provide(
1286 Layer.mergeAll(
1287 httpLayer,
1288 Layer.succeed(ManagedRelayDpopSigner, signer),
1289 Layer.succeed(ManagedRelayClient, relay),
1290 Layer.succeed(ClientCapabilities.CloudSession, {
1291 identity: Effect.succeed(Option.some({ accountId: "test-account" })),
1292 clerkToken: Effect.succeed("clerk-token"),
1293 }),
1294 Layer.succeed(ClientCapabilities.RelayDeviceIdentity, {
1295 deviceId: Effect.succeed(Option.none()),
1296 }),
1297 TokenStore.layer({
1298 get: () => Ref.get(token),
1299 put: (value) => Ref.set(token, Option.some(value)),
1300 remove: () => Ref.set(token, Option.none()),
1301 }),
1302 Layer.succeed(ClientCapabilities.ClientPresentation, {
1303 metadata: { label: "Test client", deviceType: "desktop" },
1304 scopes: AuthStandardClientScopes,
1305 }),
1306 ),
1307 ),
1308 );
1309 const harness = yield* makeHarness({
1310 prepare: () =>
1311 remoteAuthorization
1312 .authorizeDpop({ expectedEnvironmentId: TARGET.environmentId })
1313 .pipe(Effect.map((prepared) => ({ ...prepared, target: RELAY_TARGET }))),
12311314 });
12321315 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
12331316 initiallyDesired: true,
12341317 }).pipe(Effect.provide(harness.dependencies));
1235
12361318 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1237 const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
1238 yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS);
1239 yield* Deferred.await(replacementStarted);
1240
1241 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);
1319 const session = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
1320 const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1321 const readSession = fetchEnvironmentSessionState({
1322 prepared,
1323 signer: Option.some(signer),
1324 remoteAuthorization: Option.some(remoteAuthorization),
1325 }).pipe(Effect.provide(httpLayer));
1326
1327 yield* TestClock.adjust("2 hours");
1328 expect((yield* readSession).authenticated).toBe(true);
1329 expect(sessionAuthorizations).toEqual(["DPoP access-token-2"]);
1330 expect(yield* Ref.get(bootstrapCalls)).toBe(1);
1331 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session);
12421332 expect(yield* Ref.get(harness.releaseCount)).toBe(0);
1243
1244 yield* TestClock.adjust("15 seconds");
1245 yield* Effect.yieldNow;
1246 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
1247 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);
12481333 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
12491334 phase: "connected",
12501335 generation: 1,
12511336 });
12521337
1253 yield* TestClock.adjust("3 seconds");
1254 yield* Deferred.await(retryStarted);
1255 expect(yield* Ref.get(harness.sessionCount)).toBe(3);
1256 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
1257 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);
1258
1259 yield* supervisor.disconnect;
1260 yield* awaitState(supervisor.state, (state) => state.phase === "available");
1261
1262 expect(yield* Ref.get(harness.releaseCount)).toBe(3);
1263 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true);
1264 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);
1265 }).pipe(Effect.provide(TestClock.layer())),
1266 );
1267
1268 it.effect("does not keep a relay session past its authorization expiry", () =>
1269 Effect.gen(function* () {
1270 const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2;
1271 const harness = yield* makeHarness({
1272 prepare: (attempt) =>
1273 attempt === 1
1274 ? Effect.succeed({
1275 ...PREPARED_CONNECTION,
1276 target: RELAY_TARGET,
1277 httpAuthorization: {
1278 _tag: "Dpop",
1279 accessToken: "access-token-1",
1280 expiresAtEpochMs: tokenLifetimeMs,
1281 },
1282 })
1283 : Effect.fail(transient("Authorization refresh failed.")),
1284 });
1285 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
1286 initiallyDesired: true,
1287 }).pipe(Effect.provide(harness.dependencies));
1288
1289 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1290 yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS);
1291 yield* Effect.yieldNow;
1292 for (const delay of [3_000, 4_000, 8_000, 16_000, 16_000, 13_000]) {
1293 yield* TestClock.adjust(delay);
1294 yield* Effect.yieldNow;
1295 }
1296
1297 yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
1298
1338 yield* TestClock.adjust("2 hours");
1339 yield* Ref.set(bootstrapFails, true);
1340 const failure = yield* readSession.pipe(Effect.flip);
1341 expect(failure._tag).toBe("RemoteEnvironmentAuthFetchError");
1342 expect(yield* Ref.get(bootstrapCalls)).toBe(2);
1343 expect(sessionAuthorizations).toEqual(["DPoP access-token-2"]);
1344 expect(httpPaths.filter((path) => path === "/api/auth/websocket-ticket")).toHaveLength(1);
1345 expect(httpPaths.filter((path) => path === "/oauth/token")).toHaveLength(1);
12991346 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
1300 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
1347 expect(yield* Ref.get(harness.releaseCount)).toBe(0);
1348 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session);
13011349 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1302 phase: "backoff",
1350 phase: "connected",
13031351 generation: 1,
13041352 });
1305 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true);
1306 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);
13071353 }).pipe(Effect.provide(TestClock.layer())),
13081354 );
13091355
packages/client-runtime/src/connection/supervisor.ts+44-270
......@@ -18,7 +18,6 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";
1818import * as Connectivity from "./connectivity.ts";
1919import * as ConnectionDriver from "./driver.ts";
2020import {
21 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
2221 type ConnectionAttemptError,
2322 type ConnectionTarget,
2423 ConnectionTransientError,
......@@ -61,24 +60,16 @@ interface TracedAttemptFailure {
6160 readonly attemptSpan: Option.Option<Tracer.Span>;
6261}
6362
64interface ScopedConnection {
65 readonly attemptSpan: Option.Option<Tracer.Span>;
66 readonly lease: ConnectionDriver.EnvironmentConnectionLease;
67 readonly scope: Scope.Closeable;
68}
69
7063type AttemptOutcome =
7164 | {
7265 readonly _tag: "Interrupted";
7366 readonly established: boolean;
74 readonly generation: number;
7567 readonly stable: boolean;
7668 readonly resetRetry: boolean;
7769 }
7870 | {
7971 readonly _tag: "Failure";
8072 readonly established: boolean;
81 readonly generation: number;
8273 readonly stable: boolean;
8374 readonly failure: TracedAttemptFailure;
8475 };
......@@ -86,26 +77,17 @@ type AttemptOutcome =
8677type EstablishmentEvent =
8778 | {
8879 readonly _tag: "Completed";
89 readonly exit: Exit.Exit<ScopedConnection, TracedAttemptFailure>;
80 readonly exit: Exit.Exit<
81 {
82 readonly attemptSpan: Option.Option<Tracer.Span>;
83 readonly lease: ConnectionDriver.EnvironmentConnectionLease;
84 },
85 TracedAttemptFailure
86 >;
9087 }
9188 | { readonly _tag: "Interrupted"; readonly resetRetry: boolean }
9289 | { readonly _tag: "TimedOut" };
9390
94type ReplacementPreparationEvent =
95 | { readonly _tag: "Completed"; readonly exit: Exit.Exit<ScopedConnection, TracedAttemptFailure> }
96 | { readonly _tag: "TimedOut" }
97 | { readonly _tag: "AuthorizationExpired" };
98
99type ConnectedLeaseEvent =
100 | {
101 readonly _tag: "ActiveCompleted";
102 readonly exit: Exit.Exit<boolean, TracedAttemptFailure>;
103 }
104 | {
105 readonly _tag: "ReplacementCompleted";
106 readonly exit: Exit.Exit<Option.Option<ScopedConnection>, TracedAttemptFailure>;
107 };
108
10991function exitUnlessInterrupted<A, E, R>(
11092 effect: Effect.Effect<A, E, R>,
11193): Effect.Effect<Exit.Exit<A, E>, never, R> {
......@@ -186,18 +168,16 @@ function failureFromExit<A>(
186168 target: ConnectionTarget,
187169 exit: Exit.Exit<A, TracedAttemptFailure>,
188170 established: boolean,
189 generation: number,
190171 stable: boolean,
191172): AttemptOutcome {
192173 if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
193 return { _tag: "Interrupted", established, generation, stable, resetRetry: false };
174 return { _tag: "Interrupted", established, stable, resetRetry: false };
194175 }
195176 const typedFailure = exit.cause.reasons.find(Cause.isFailReason);
196177 if (typedFailure) {
197178 return {
198179 _tag: "Failure",
199180 established,
200 generation,
201181 stable,
202182 failure: typedFailure.error,
203183 };
......@@ -205,7 +185,6 @@ function failureFromExit<A>(
205185 return {
206186 _tag: "Failure",
207187 established,
208 generation,
209188 stable,
210189 failure: {
211190 error: new ConnectionTransientError({
......@@ -313,10 +292,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
313292 attempt: number,
314293 generation: number,
315294 lastFailure: ConnectionAttemptError | null,
316 publishProgress: boolean,
317295 ) {
318296 return yield* driver.connect(entry, (progress) =>
319 publishProgress ? reportProgress(attempt, generation, lastFailure, progress) : Effect.void,
297 reportProgress(attempt, generation, lastFailure, progress),
320298 );
321299 });
322300
......@@ -367,17 +345,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
367345 generation: number,
368346 lastFailure: ConnectionAttemptError | null,
369347 pendingRetry: Option.Option<PendingRetryTrace>,
370 publishProgress: boolean,
371348 ) {
372349 if (target._tag === "RelayConnectionTarget") {
373350 return yield* traceRelayEstablishment(
374 establishConnection(attempt, generation, lastFailure, publishProgress),
351 establishConnection(attempt, generation, lastFailure),
375352 attempt,
376353 generation,
377354 pendingRetry,
378355 );
379356 }
380 return yield* establishConnection(attempt, generation, lastFailure, publishProgress).pipe(
357 return yield* establishConnection(attempt, generation, lastFailure).pipe(
381358 Effect.map((lease) => ({
382359 attemptSpan: Option.none<Tracer.Span>(),
383360 lease,
......@@ -389,37 +366,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
389366 );
390367 });
391368
392 const forkScopedTracedConnection = Effect.fnUntraced(function* (
393 attempt: number,
394 generation: number,
395 lastFailure: ConnectionAttemptError | null,
396 pendingRetry: Option.Option<PendingRetryTrace>,
397 publishProgress: boolean,
398 ) {
399 const parentScope = yield* Scope.Scope;
400 return yield* Effect.uninterruptibleMask((restore) =>
401 Effect.gen(function* () {
402 const connectionScope = yield* Scope.fork(parentScope, "sequential");
403 const fiber = yield* restore(
404 establishTracedConnection(
405 attempt,
406 generation,
407 lastFailure,
408 pendingRetry,
409 publishProgress,
410 ).pipe(
411 Scope.provide(connectionScope),
412 Effect.map(
413 (established) =>
414 ({ ...established, scope: connectionScope }) satisfies ScopedConnection,
415 ),
416 ),
417 ).pipe(Effect.forkChild);
418 return { fiber, scope: connectionScope };
419 }),
420 );
421 });
422
423369 const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () {
424370 for (;;) {
425371 const next = yield* Queue.take(signals);
......@@ -541,136 +487,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
541487 }
542488 });
543489
544 const waitForAuthorizationDeadline = Effect.fnUntraced(function* (
545 preparedConnection: PreparedConnection,
546 skewMs: number,
547 ) {
548 const authorization = preparedConnection.httpAuthorization;
549 if (authorization?._tag !== "Dpop") {
550 return yield* Effect.never;
551 }
552 const now = yield* Clock.currentTimeMillis;
553 yield* Effect.sleep(Math.max(0, authorization.expiresAtEpochMs - now - skewMs));
554 });
555
556 const waitForActiveCompletion = Effect.fnUntraced(function* (active: ScopedConnection) {
557 const exit = yield* exitUnlessInterrupted(
558 Effect.raceAllFirst([
559 active.lease.session.closed.pipe(
560 Effect.mapError((error): TracedAttemptFailure => ({
561 error,
562 attemptSpan: active.attemptSpan,
563 })),
564 ),
565 monitorConnectedLease(active.lease).pipe(
566 Effect.mapError((error): TracedAttemptFailure => ({
567 error,
568 attemptSpan: active.attemptSpan,
569 })),
570 ),
571 ]),
572 );
573 return { _tag: "ActiveCompleted", exit } satisfies ConnectedLeaseEvent;
574 });
575
576 const prepareReplacement = Effect.fnUntraced(function* (
577 active: ScopedConnection,
578 generation: number,
579 ) {
580 yield* waitForAuthorizationDeadline(active.lease.prepared, DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS);
581 yield* Effect.logDebug(
582 "Preparing a replacement environment connection before its DPoP token expires.",
583 );
584
585 let failureCount = 0;
586 for (;;) {
587 const candidate = yield* forkScopedTracedConnection(
588 failureCount + 1,
589 generation,
590 null,
591 Option.none(),
592 false,
593 );
594 const replacement = yield* Effect.raceAllFirst([
595 Fiber.await(candidate.fiber).pipe(
596 Effect.map((exit): ReplacementPreparationEvent => ({ _tag: "Completed", exit })),
597 ),
598 waitForAuthorizationDeadline(active.lease.prepared, 0).pipe(
599 Effect.as<ReplacementPreparationEvent>({ _tag: "AuthorizationExpired" }),
600 ),
601 Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe(
602 Effect.as<ReplacementPreparationEvent>({ _tag: "TimedOut" }),
603 ),
604 ]);
605
606 if (replacement._tag !== "Completed") {
607 yield* Fiber.interrupt(candidate.fiber);
608 yield* Fiber.await(candidate.fiber);
609 yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore);
610 } else if (Exit.isFailure(replacement.exit)) {
611 yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore);
612 }
613 if (replacement._tag === "AuthorizationExpired") {
614 return Option.none<ScopedConnection>();
615 }
616 if (replacement._tag === "Completed" && Exit.isSuccess(replacement.exit)) {
617 return Option.some(replacement.exit.value);
618 }
619
620 let replacementError: ConnectionTransientError;
621 if (replacement._tag === "Completed") {
622 if (Exit.isSuccess(replacement.exit)) {
623 return yield* Effect.die("A successful replacement was not installed.");
624 }
625 const failure = Cause.findErrorOption(replacement.exit.cause);
626 if (Option.isNone(failure) || failure.value.error._tag === "ConnectionBlockedError") {
627 return yield* Effect.failCause(replacement.exit.cause);
628 }
629 replacementError = failure.value.error;
630 } else {
631 replacementError = new ConnectionTransientError({
632 reason: "timeout",
633 detail: setupTimeoutDetail,
634 });
635 }
636
637 const retryDelay = retryDelayMs(failureCount);
638 failureCount += 1;
639 yield* Effect.logWarning(
640 "Could not prepare a replacement environment connection; keeping the active connection.",
641 ).pipe(
642 Effect.annotateLogs({
643 "authorization.refresh.retry_delay_ms": retryDelay,
644 ...safeErrorLogAttributes(replacementError),
645 }),
646 );
647 const retryBeforeExpiry = yield* Effect.raceFirst(
648 Effect.sleep(retryDelay).pipe(Effect.as(true)),
649 waitForAuthorizationDeadline(active.lease.prepared, 0).pipe(Effect.as(false)),
650 );
651 if (!retryBeforeExpiry) {
652 return Option.none<ScopedConnection>();
653 }
654 }
655 });
656
657490 const runAttempt = Effect.fnUntraced(function* (
658491 attempt: number,
659 previousGeneration: number,
492 generation: number,
660493 lastFailure: ConnectionAttemptError | null,
661494 pendingRetry: Option.Option<PendingRetryTrace>,
662495 ) {
663 const initialGeneration = previousGeneration + 1;
664496 yield* SubscriptionRef.set(prepared, Option.none());
665 const initial = yield* forkScopedTracedConnection(
666 attempt,
667 initialGeneration,
668 lastFailure,
669 pendingRetry,
670 true,
671 );
672497 const establishment = yield* Effect.raceAllFirst([
673 Fiber.await(initial.fiber).pipe(
498 exitUnlessInterrupted(
499 establishTracedConnection(attempt, generation, lastFailure, pendingRetry),
500 ).pipe(
674501 Effect.map((exit): EstablishmentEvent => ({
675502 _tag: "Completed",
676503 exit,
......@@ -687,18 +514,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
687514 ),
688515 ]);
689516
690 if (establishment._tag !== "Completed") {
691 yield* Fiber.interrupt(initial.fiber);
692 yield* Fiber.await(initial.fiber);
693 yield* Scope.close(initial.scope, Exit.void).pipe(Effect.ignore);
694 } else if (Exit.isFailure(establishment.exit)) {
695 yield* Scope.close(initial.scope, Exit.void).pipe(Effect.ignore);
696 }
697517 if (establishment._tag === "Interrupted") {
698518 return {
699519 _tag: "Interrupted",
700520 established: false,
701 generation: previousGeneration,
702521 stable: false,
703522 resetRetry: establishment.resetRetry,
704523 } satisfies AttemptOutcome;
......@@ -707,7 +526,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
707526 return {
708527 _tag: "Failure",
709528 established: false,
710 generation: previousGeneration,
711529 stable: false,
712530 failure: {
713531 error: new ConnectionTransientError({
......@@ -722,7 +540,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
722540 const isUnexpectedDefect =
723541 !Cause.hasInterruptsOnly(establishment.exit.cause) &&
724542 !establishment.exit.cause.reasons.some(Cause.isFailReason);
725 const outcome = failureFromExit(target, establishment.exit, false, previousGeneration, false);
543 const outcome = failureFromExit(target, establishment.exit, false, false);
726544 if (isUnexpectedDefect) {
727545 const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect;
728546 yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe(
......@@ -737,20 +555,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
737555 return outcome;
738556 }
739557
558 const active = establishment.exit.value;
740559 const currentIntent = yield* Ref.get(intent);
741560 if (!currentIntent.desired || currentIntent.network === "offline") {
742561 return {
743562 _tag: "Interrupted",
744563 established: false,
745 generation: previousGeneration,
746564 stable: false,
747565 resetRetry: false,
748566 } satisfies AttemptOutcome;
749567 }
750568
751569 const connectedAt = yield* Clock.currentTimeMillis;
752 let active = establishment.exit.value;
753 let activeGeneration = initialGeneration;
754570 yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared));
755571 yield* SubscriptionRef.set(session, Option.some(active.lease.session));
756572 yield* setState({
......@@ -759,78 +575,35 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
759575 phase: "connected",
760576 stage: null,
761577 attempt,
762 generation: activeGeneration,
578 generation,
763579 lastFailure: null,
764580 retryAt: null,
765581 });
766582
767 for (;;) {
768 const connectedEvent = yield* Effect.raceAllFirst([
769 waitForActiveCompletion(active),
770 exitUnlessInterrupted(prepareReplacement(active, activeGeneration + 1)).pipe(
771 Effect.map((exit): ConnectedLeaseEvent => ({ _tag: "ReplacementCompleted", exit })),
772 ),
773 ]);
774 const stable = (yield* Clock.currentTimeMillis) - connectedAt >= BACKOFF_RESET_AFTER_MS;
775 if (connectedEvent._tag === "ActiveCompleted") {
776 if (Exit.isSuccess(connectedEvent.exit)) {
777 return {
778 _tag: "Interrupted",
779 established: true,
780 generation: activeGeneration,
781 stable,
782 resetRetry: connectedEvent.exit.value,
783 } satisfies AttemptOutcome;
784 }
785 return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable);
786 }
787 if (Exit.isFailure(connectedEvent.exit)) {
788 return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable);
789 }
790 if (Option.isNone(connectedEvent.exit.value)) {
791 return {
792 _tag: "Interrupted",
793 established: true,
794 generation: activeGeneration,
795 stable,
796 resetRetry: true,
797 } satisfies AttemptOutcome;
798 }
799
800 const candidate = connectedEvent.exit.value.value;
801 const replacementIntent = yield* Ref.get(intent);
802 if (!replacementIntent.desired || replacementIntent.network === "offline") {
803 yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore);
804 return {
805 _tag: "Interrupted",
806 established: true,
807 generation: activeGeneration,
808 stable,
809 resetRetry: false,
810 } satisfies AttemptOutcome;
811 }
812
813 const previous = active;
814 yield* Effect.uninterruptible(
815 Effect.gen(function* () {
816 active = candidate;
817 activeGeneration += 1;
818 yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared));
819 yield* SubscriptionRef.set(session, Option.some(active.lease.session));
820 yield* setState({
821 desired: true,
822 network: replacementIntent.network,
823 phase: "connected",
824 stage: null,
825 attempt: 1,
826 generation: activeGeneration,
827 lastFailure: null,
828 retryAt: null,
829 });
830 yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore);
831 }),
832 );
583 const connectedExit = yield* Effect.raceFirst(
584 active.lease.session.closed.pipe(
585 Effect.mapError((error): TracedAttemptFailure => ({
586 error,
587 attemptSpan: active.attemptSpan,
588 })),
589 ),
590 monitorConnectedLease(active.lease).pipe(
591 Effect.mapError((error): TracedAttemptFailure => ({
592 error,
593 attemptSpan: active.attemptSpan,
594 })),
595 ),
596 ).pipe(exitUnlessInterrupted);
597 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;
598 if (Exit.isSuccess(connectedExit)) {
599 return {
600 _tag: "Interrupted",
601 established: true,
602 stable: connectedForMs >= BACKOFF_RESET_AFTER_MS,
603 resetRetry: connectedExit.value,
604 } satisfies AttemptOutcome;
833605 }
606 return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS);
834607 }, Effect.ensuring(clearLease));
835608
836609 const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) {
......@@ -895,14 +668,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
895668 }
896669
897670 const attempt = failureCount + 1;
671 const nextGeneration = generation + 1;
898672 const outcome: AttemptOutcome = yield* Effect.scoped(
899 runAttempt(attempt, generation, latestFailure, pendingRetry),
673 runAttempt(attempt, nextGeneration, latestFailure, pendingRetry),
900674 );
901675 // Consumed on every iteration so a stale marker can never leak into a
902676 // later, unrelated failure.
903677 const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false);
904 generation = outcome.generation;
905678 if (outcome.established) {
679 generation = nextGeneration;
906680 if (outcome.stable) {
907681 resetRetryLadder();
908682 latestFailure = null;
packages/client-runtime/src/platform/capabilities.ts+6
......@@ -21,9 +21,15 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment {
2121 readonly label: string;
2222}
2323
24/** Stable for one signed-in session, including same-account token refreshes. */
25export interface CloudSessionIdentity {
26 readonly accountId: string;
27}
28
2429export class CloudSession extends Context.Service<
2530 CloudSession,
2631 {
32 readonly identity: Effect.Effect<Option.Option<CloudSessionIdentity>>;
2733 readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>;
2834 }
2935>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {}
packages/client-runtime/src/platform/storageDocument.test.ts+86
......@@ -1,5 +1,6 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
3import * as Schema from "effect/Schema";
34
45import * as TokenStore from "../authorization/tokenStore.ts";
56import {
......@@ -16,13 +17,19 @@ import {
1617 SshConnectionTarget,
1718} from "../connection/model.ts";
1819import {
20 ConnectionCatalogDocument,
1921 EMPTY_CONNECTION_CATALOG_DOCUMENT,
22 putRemoteDpopTokenInCatalog,
2023 registerConnectionInCatalog,
2124 removeConnectionFromCatalog,
2225} from "./storageDocument.ts";
2326
2427const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
2528
29const RELAY_TARGET = new RelayConnectionTarget({
30 environmentId: ENVIRONMENT_ID,
31 label: "Remote",
32});
2633const BEARER_TARGET = new BearerConnectionTarget({
2734 environmentId: ENVIRONMENT_ID,
2835 label: "Remote",
......@@ -52,6 +59,26 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({
5259});
5360
5461describe("ConnectionCatalogDocument", () => {
62 it.each([
63 { name: "legacy", accountId: undefined },
64 { name: "account-bound", accountId: "account-1" },
65 ])("round-trips a catalog containing a $name DPoP token", ({ accountId }) => {
66 const token = new TokenStore.RemoteDpopAccessToken({
67 ...REMOTE_TOKEN,
68 ...(accountId === undefined ? {} : { accountId }),
69 });
70 const document = {
71 ...EMPTY_CONNECTION_CATALOG_DOCUMENT,
72 targets: [RELAY_TARGET],
73 remoteDpopTokens: [token],
74 };
75 const schema = Schema.fromJsonString(ConnectionCatalogDocument);
76 const restored = Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(document));
77
78 expect(restored).toEqual(document);
79 expect(restored.remoteDpopTokens[0]?.accountId).toBe(accountId);
80 });
81
5582 it("registers a bearer connection as one catalog mutation", () => {
5683 const document = registerConnectionInCatalog(
5784 EMPTY_CONNECTION_CATALOG_DOCUMENT,
......@@ -117,6 +144,65 @@ describe("ConnectionCatalogDocument", () => {
117144 );
118145 });
119146
147 it("stores a DPoP token for a registered relay environment", () => {
148 const registered = registerConnectionInCatalog(
149 EMPTY_CONNECTION_CATALOG_DOCUMENT,
150 new RelayConnectionRegistration({ target: RELAY_TARGET }),
151 );
152
153 expect(putRemoteDpopTokenInCatalog(registered, REMOTE_TOKEN)).toEqual({
154 ...registered,
155 remoteDpopTokens: [REMOTE_TOKEN],
156 });
157 });
158
159 it("ignores a late DPoP token after the environment is removed", () => {
160 const registered = registerConnectionInCatalog(
161 EMPTY_CONNECTION_CATALOG_DOCUMENT,
162 new RelayConnectionRegistration({ target: RELAY_TARGET }),
163 );
164 const removed = removeConnectionFromCatalog(registered, RELAY_TARGET);
165
166 expect(putRemoteDpopTokenInCatalog(removed, REMOTE_TOKEN)).toEqual(
167 EMPTY_CONNECTION_CATALOG_DOCUMENT,
168 );
169 });
170
171 it("removes a DPoP token stored before the environment is removed", () => {
172 const registered = registerConnectionInCatalog(
173 EMPTY_CONNECTION_CATALOG_DOCUMENT,
174 new RelayConnectionRegistration({ target: RELAY_TARGET }),
175 );
176 const refreshed = putRemoteDpopTokenInCatalog(registered, REMOTE_TOKEN);
177
178 expect(removeConnectionFromCatalog(refreshed, RELAY_TARGET)).toEqual(
179 EMPTY_CONNECTION_CATALOG_DOCUMENT,
180 );
181 });
182
183 it("does not store DPoP tokens for a different environment or connection kind", () => {
184 const bearer = registerConnectionInCatalog(
185 EMPTY_CONNECTION_CATALOG_DOCUMENT,
186 new BearerConnectionRegistration({
187 target: BEARER_TARGET,
188 profile: BEARER_PROFILE,
189 credential: BEARER_CREDENTIAL,
190 }),
191 );
192 const otherRelay = registerConnectionInCatalog(
193 EMPTY_CONNECTION_CATALOG_DOCUMENT,
194 new RelayConnectionRegistration({
195 target: new RelayConnectionTarget({
196 environmentId: EnvironmentId.make("environment-2"),
197 label: "Other relay",
198 }),
199 }),
200 );
201
202 expect(putRemoteDpopTokenInCatalog(bearer, REMOTE_TOKEN)).toBe(bearer);
203 expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay);
204 });
205
120206 it("persists the normalized SSH profile beside its target", () => {
121207 const target = new SshConnectionTarget({
122208 environmentId: ENVIRONMENT_ID,
packages/client-runtime/src/platform/storageDocument.ts+21
......@@ -139,3 +139,24 @@ export function removeConnectionFromCatalog(
139139): ConnectionCatalogDocument {
140140 return removeConnectionMetadata(document, target, true);
141141}
142
143export function putRemoteDpopTokenInCatalog(
144 document: ConnectionCatalogDocument,
145 token: TokenStore.RemoteDpopAccessToken,
146): ConnectionCatalogDocument {
147 const registered = document.targets.some(
148 (target) =>
149 target._tag === "RelayConnectionTarget" && target.environmentId === token.environmentId,
150 );
151 if (!registered) {
152 return document;
153 }
154 return {
155 ...document,
156 remoteDpopTokens: replaceCatalogValue(
157 document.remoteDpopTokens,
158 (value) => value.environmentId,
159 token,
160 ),
161 };
162}
packages/client-runtime/src/relay/discovery.test.ts+7
......@@ -62,6 +62,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
6262 const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null);
6363 const secondListCall = yield* Deferred.make<void>();
6464 const clerkToken = yield* Ref.make<string | null>("clerk-token");
65 const sessionIdentity = { accountId: "account-1" };
6566 const wakeups = yield* SubscriptionRef.make<{
6667 readonly sequence: number;
6768 readonly reason: "application-active" | "credentials-changed";
......@@ -127,6 +128,11 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
127128 Layer.succeed(
128129 ClientCapabilities.CloudSession,
129130 ClientCapabilities.CloudSession.of({
131 identity: Ref.get(clerkToken).pipe(
132 Effect.map((token) =>
133 token === null ? Option.none() : Option.some(sessionIdentity),
134 ),
135 ),
130136 clerkToken: Ref.get(clerkToken).pipe(
131137 Effect.flatMap((token) =>
132138 token === null
......@@ -283,6 +289,7 @@ describe("RelayEnvironmentDiscovery", () => {
283289 Layer.mergeAll(
284290 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
285291 Layer.succeed(ClientCapabilities.CloudSession, {
292 identity: Effect.succeed(Option.some({ accountId: "account-1" })),
286293 clerkToken: Effect.succeed("clerk-token"),
287294 }),
288295 Layer.succeed(Connectivity.Connectivity, {
packages/client-runtime/src/state/environmentHttpAuth.test.ts created+531
......@@ -0,0 +1,531 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type AuthSessionState,
8 type OrchestrationShellSnapshot,
9 type OrchestrationThreadDetailSnapshot,
10} from "@t3tools/contracts";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import { TestClock } from "effect/testing";
17import type { HttpClient } from "effect/unstable/http";
18
19import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
20import {
21 ConnectionTransientError,
22 RelayConnectionTarget,
23 type PreparedConnection,
24 type PreparedHttpAuthorization,
25} from "../connection/model.ts";
26import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "../relay/managedRelay.ts";
27import { remoteHttpClientLayer, type RemoteEnvironmentRequestError } from "../rpc/http.ts";
28import {
29 fetchEnvironmentPullRequestDiff,
30 type PullRequestDiffCredentialRejectedError,
31 PullRequestDiffLoader,
32 pullRequestDiffLoaderLayer,
33} from "./pullRequestDiffHttp.ts";
34import { fetchEnvironmentSessionState } from "./session.ts";
35import { fetchEnvironmentShellSnapshot } from "./shellSnapshotHttp.ts";
36import { fetchEnvironmentThreadSnapshot } from "./threadSnapshotHttp.ts";
37
38const TARGET = new RelayConnectionTarget({
39 environmentId: EnvironmentId.make("environment-1"),
40 label: "Remote environment",
41});
42const PREPARED: PreparedConnection = {
43 environmentId: TARGET.environmentId,
44 label: TARGET.label,
45 httpBaseUrl: "https://previous.example.test",
46 socketUrl: "wss://previous.example.test/ws",
47 httpAuthorization: { _tag: "Dpop", accessToken: "expired-token", expiresAtEpochMs: 0 },
48 target: TARGET,
49};
50const CURRENT_ORIGIN = "https://current.example.test";
51const RENEWED_ORIGIN = "https://renewed.example.test";
52const DIFF = {
53 projectId: ProjectId.make("project-1"),
54 repository: "owner/repository",
55 number: 42,
56};
57const DIFF_RESULT = { patch: "diff --git a/file.ts b/file.ts", truncated: false, nextCursor: null };
58const AUTH = {
59 policy: "remote-reachable",
60 bootstrapMethods: ["one-time-token"],
61 sessionMethods: ["dpop-access-token"],
62 sessionCookieName: "t3_session",
63} satisfies AuthSessionState["auth"];
64const SESSION = {
65 authenticated: true,
66 auth: AUTH,
67 scopes: ["orchestration:read", "orchestration:operate"],
68 sessionMethod: "dpop-access-token",
69} satisfies AuthSessionState;
70const UNAUTHENTICATED_SESSION = { authenticated: false, auth: AUTH } satisfies AuthSessionState;
71const SHELL = {
72 snapshotSequence: 1,
73 projects: [],
74 threads: [],
75 updatedAt: "2026-09-04T00:00:00.000Z",
76} satisfies OrchestrationShellSnapshot;
77const THREAD = {
78 snapshotSequence: 2,
79 thread: {
80 id: ThreadId.make("thread-1"),
81 projectId: ProjectId.make("project-1"),
82 title: "Thread",
83 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
84 runtimeMode: "full-access",
85 interactionMode: "default",
86 branch: null,
87 worktreePath: null,
88 latestTurn: null,
89 createdAt: "2026-09-04T00:00:00.000Z",
90 updatedAt: "2026-09-04T00:00:00.000Z",
91 archivedAt: null,
92 settledOverride: null,
93 settledAt: null,
94 deletedAt: null,
95 messages: [],
96 proposedPlans: [],
97 activities: [],
98 checkpoints: [],
99 session: null,
100 },
101 page: { beforeCursor: null, hasMore: false, snapshotSequence: 2 },
102} satisfies OrchestrationThreadDetailSnapshot;
103
104function credentialRejectedResponse(reason = "invalid_credential") {
105 return Response.json(
106 {
107 _tag: "EnvironmentAuthInvalidError",
108 code: "auth_invalid",
109 reason,
110 traceId: "trace-rejected",
111 },
112 { status: 401 },
113 );
114}
115
116function makeHarness(reply: (requestNumber: number) => Response | Promise<Response>) {
117 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
118 const authorizations: Array<
119 Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]>[0]
120 > = [];
121 const proofs: Array<ManagedRelayDpopProofInput> = [];
122 const remoteAuthorization = RemoteEnvironmentAuthorization.of({
123 authorizeBearer: () => Effect.die("Unexpected bearer connection preparation."),
124 authorizeDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."),
125 authorizeDpopHttp: (input) =>
126 Effect.sync(() => {
127 authorizations.push(input);
128 const rejected = input.rejectedAccessToken !== undefined;
129 return {
130 environmentId: TARGET.environmentId,
131 label: TARGET.label,
132 httpBaseUrl: rejected ? RENEWED_ORIGIN : CURRENT_ORIGIN,
133 httpAuthorization: {
134 _tag: "Dpop" as const,
135 accessToken: rejected ? "renewed-token" : "current-token",
136 expiresAtEpochMs: 3_600_000,
137 },
138 };
139 }),
140 });
141 const signer = ManagedRelayDpopSigner.of({
142 thumbprint: Effect.succeed("test-thumbprint"),
143 createProof: (input) =>
144 Effect.sync(() => {
145 proofs.push(input);
146 return `proof-${proofs.length}`;
147 }),
148 });
149 const fetchFn: typeof fetch = async (request, init) => {
150 calls.push({ url: String(request), init: init ?? {} });
151 return reply(calls.length);
152 };
153 return {
154 calls,
155 authorizations,
156 proofs,
157 remoteAuthorization,
158 input: {
159 prepared: PREPARED,
160 signer: Option.some(signer),
161 remoteAuthorization: Option.some(remoteAuthorization),
162 },
163 httpLayer: remoteHttpClientLayer(fetchFn),
164 };
165}
166
167type HttpInput = ReturnType<typeof makeHarness>["input"];
168const LOADERS: ReadonlyArray<{
169 readonly name: string;
170 readonly method: string;
171 readonly path: string;
172 readonly response: unknown;
173 readonly load: (
174 input: HttpInput,
175 ) => Effect.Effect<
176 unknown,
177 RemoteEnvironmentRequestError | PullRequestDiffCredentialRejectedError,
178 HttpClient.HttpClient
179 >;
180}> = [
181 {
182 name: "PR diff",
183 method: "POST",
184 path: "/api/pull-requests/diff",
185 response: DIFF_RESULT,
186 load: (input: HttpInput) => fetchEnvironmentPullRequestDiff({ ...input, diff: DIFF }),
187 },
188 {
189 name: "session permissions",
190 method: "GET",
191 path: "/api/auth/session",
192 response: SESSION,
193 load: fetchEnvironmentSessionState,
194 },
195 {
196 name: "shell snapshot",
197 method: "GET",
198 path: "/api/orchestration/shell",
199 response: SHELL,
200 load: fetchEnvironmentShellSnapshot,
201 },
202 {
203 name: "older thread history",
204 method: "GET",
205 path: "/api/orchestration/threads/thread-1",
206 response: THREAD,
207 load: (input: HttpInput) =>
208 fetchEnvironmentThreadSnapshot({
209 ...input,
210 threadId: THREAD.thread.id,
211 window: { turnLimit: 20, beforeCursor: "older-page" },
212 }),
213 },
214];
215
216describe("authenticated environment HTTP requests", () => {
217 it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) =>
218 Effect.gen(function* () {
219 const harness = makeHarness(() => Response.json(loader.response));
220 const result = yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
221
222 expect(result).toEqual(loader.response);
223 expect(harness.calls).toHaveLength(1);
224 const call = harness.calls[0]!;
225 const url = new URL(call.url);
226 expect(url.origin).toBe(CURRENT_ORIGIN);
227 expect(url.pathname).toBe(loader.path);
228 expect(call.init.method).toBe(loader.method);
229 expect(new Headers(call.init.headers).get("authorization")).toBe("DPoP current-token");
230 expect(new Headers(call.init.headers).get("dpop")).toBe("proof-1");
231 expect(call.init.credentials).toBeUndefined();
232 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
233 expect(harness.proofs).toEqual([
234 {
235 method: loader.method,
236 url: `${CURRENT_ORIGIN}${loader.path}`,
237 accessToken: "current-token",
238 },
239 ]);
240 if (loader.name === "older thread history") {
241 expect(url.searchParams.get("turnLimit")).toBe("20");
242 expect(url.searchParams.get("beforeCursor")).toBe("older-page");
243 }
244 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });
245 }),
246 );
247
248 it.effect("retries a rejected diff once with a new token, endpoint, and proof", () =>
249 Effect.gen(function* () {
250 const harness = makeHarness((requestNumber) =>
251 requestNumber === 1 ? credentialRejectedResponse() : Response.json(DIFF_RESULT),
252 );
253 const result = yield* fetchEnvironmentPullRequestDiff({ ...harness.input, diff: DIFF }).pipe(
254 Effect.provide(harness.httpLayer),
255 );
256
257 expect(result).toEqual(DIFF_RESULT);
258 expect(harness.authorizations).toEqual([
259 { expectedEnvironmentId: TARGET.environmentId },
260 { expectedEnvironmentId: TARGET.environmentId, rejectedAccessToken: "current-token" },
261 ]);
262 expect(harness.calls.map((call) => call.url)).toEqual([
263 `${CURRENT_ORIGIN}/api/pull-requests/diff`,
264 `${RENEWED_ORIGIN}/api/pull-requests/diff`,
265 ]);
266 expect(
267 harness.calls.map((call) => new Headers(call.init.headers).get("authorization")),
268 ).toEqual(["DPoP current-token", "DPoP renewed-token"]);
269 expect(harness.calls.map((call) => new Headers(call.init.headers).get("dpop"))).toEqual([
270 "proof-1",
271 "proof-2",
272 ]);
273 expect(harness.proofs[1]).toEqual({
274 method: "POST",
275 url: `${RENEWED_ORIGIN}/api/pull-requests/diff`,
276 accessToken: "renewed-token",
277 });
278 expect(harness.calls[1]!.init.body).toEqual(harness.calls[0]!.init.body);
279 }),
280 );
281
282 it.effect("uses the authorization service captured by the diff loader layer", () =>
283 Effect.gen(function* () {
284 const harness = makeHarness(() => Response.json(DIFF_RESULT));
285 const loaderLayer = pullRequestDiffLoaderLayer.pipe(
286 Layer.provide(
287 Layer.mergeAll(
288 harness.httpLayer,
289 Layer.succeed(ManagedRelayDpopSigner, Option.getOrThrow(harness.input.signer)),
290 Layer.succeed(RemoteEnvironmentAuthorization, harness.remoteAuthorization),
291 ),
292 ),
293 );
294 const loader = yield* PullRequestDiffLoader.pipe(Effect.provide(loaderLayer));
295 const result = yield* loader.load(PREPARED, DIFF);
296
297 expect(result).toEqual(DIFF_RESULT);
298 expect(new Headers(harness.calls[0]!.init.headers).get("authorization")).toBe(
299 "DPoP current-token",
300 );
301 }),
302 );
303
304 it.effect("preserves the credential rejection after the one recovery attempt fails", () =>
305 Effect.gen(function* () {
306 const harness = makeHarness(() => credentialRejectedResponse());
307 const error = yield* fetchEnvironmentPullRequestDiff({ ...harness.input, diff: DIFF }).pipe(
308 Effect.provide(harness.httpLayer),
309 Effect.flip,
310 );
311
312 expect(error).toMatchObject({
313 _tag: "PullRequestDiffCredentialRejectedError",
314 traceId: "trace-rejected",
315 });
316 expect(harness.calls).toHaveLength(2);
317 expect(harness.authorizations).toHaveLength(2);
318 }),
319 );
320
321 it.effect.each([
322 {
323 name: "insufficient scope",
324 reply: () =>
325 Response.json(
326 {
327 _tag: "EnvironmentScopeRequiredError",
328 code: "insufficient_scope",
329 requiredScope: "orchestration:read",
330 traceId: "trace-scope",
331 },
332 { status: 403 },
333 ),
334 errorTag: "EnvironmentScopeRequiredError",
335 },
336 {
337 name: "missing credential",
338 reply: () => credentialRejectedResponse("missing_credential"),
339 errorTag: "EnvironmentAuthInvalidError",
340 },
341 {
342 name: "Cloudflare 530",
343 reply: () => new Response("error code: 1033", { status: 530 }),
344 errorTag: "RemoteEnvironmentAuthUndeclaredStatusError",
345 },
346 {
347 name: "network failure",
348 reply: () => Promise.reject(new Error("Network unreachable")),
349 errorTag: "RemoteEnvironmentAuthFetchError",
350 },
351 ])("does not renew or retry on $name", ({ reply, errorTag }) =>
352 Effect.gen(function* () {
353 const harness = makeHarness(reply);
354 const error = yield* fetchEnvironmentPullRequestDiff({ ...harness.input, diff: DIFF }).pipe(
355 Effect.provide(harness.httpLayer),
356 Effect.flip,
357 );
358
359 expect(error._tag).toBe(errorTag);
360 expect(harness.calls).toHaveLength(1);
361 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
362 }),
363 );
364
365 it.effect("recovers a session's unauthenticated 200 response before checking permissions", () =>
366 Effect.gen(function* () {
367 const harness = makeHarness((requestNumber) =>
368 Response.json(requestNumber === 1 ? UNAUTHENTICATED_SESSION : SESSION),
369 );
370 const result = yield* fetchEnvironmentSessionState(harness.input).pipe(
371 Effect.provide(harness.httpLayer),
372 );
373
374 expect(result).toEqual(SESSION);
375 expect(harness.authorizations[1]).toEqual({
376 expectedEnvironmentId: TARGET.environmentId,
377 rejectedAccessToken: "current-token",
378 });
379 expect(harness.calls).toHaveLength(2);
380 }),
381 );
382
383 it.effect("reports persistent session rejection instead of showing missing permissions", () =>
384 Effect.gen(function* () {
385 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
386 const error = yield* fetchEnvironmentSessionState(harness.input).pipe(
387 Effect.provide(harness.httpLayer),
388 Effect.flip,
389 );
390
391 expect(error).toMatchObject({
392 _tag: "RemoteEnvironmentAuthFetchError",
393 message: "The environment rejected the renewed session authorization.",
394 });
395 expect(harness.calls).toHaveLength(2);
396 }),
397 );
398
399 it.effect.each([
400 { name: "cookie", authorization: null },
401 { name: "bearer", authorization: { _tag: "Bearer", token: "bearer-token" } },
402 ] satisfies ReadonlyArray<{ name: string; authorization: PreparedHttpAuthorization | null }>)(
403 "leaves $name sessions unchanged without relay services",
404 ({ authorization }) =>
405 Effect.gen(function* () {
406 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
407 const result = yield* fetchEnvironmentSessionState({
408 prepared: { ...PREPARED, httpAuthorization: authorization },
409 signer: Option.none(),
410 }).pipe(Effect.provide(harness.httpLayer));
411
412 expect(result).toEqual(UNAUTHENTICATED_SESSION);
413 expect(harness.calls).toHaveLength(1);
414 expect(harness.authorizations).toEqual([]);
415 expect(new Headers(harness.calls[0]!.init.headers).get("authorization")).toBe(
416 authorization === null ? null : "Bearer bearer-token",
417 );
418 expect(harness.calls[0]!.init.credentials).toBe(
419 authorization === null ? "include" : undefined,
420 );
421 }),
422 );
423
424 it.effect("keeps the caller's timeout while waiting for renewal", () =>
425 Effect.gen(function* () {
426 const harness = makeHarness(() => Response.json(SESSION));
427 const authorizing = yield* Deferred.make<void>();
428 const remoteAuthorization = RemoteEnvironmentAuthorization.of({
429 ...harness.remoteAuthorization,
430 authorizeDpopHttp: () =>
431 Deferred.succeed(authorizing, undefined).pipe(Effect.andThen(Effect.never)),
432 });
433 const pending = yield* fetchEnvironmentSessionState({
434 ...harness.input,
435 remoteAuthorization: Option.some(remoteAuthorization),
436 timeoutMs: 100,
437 }).pipe(Effect.provide(harness.httpLayer), Effect.flip, Effect.forkChild);
438 yield* Deferred.await(authorizing);
439 yield* TestClock.adjust(100);
440
441 expect(yield* Fiber.join(pending)).toMatchObject({
442 _tag: "RemoteEnvironmentAuthTimeoutError",
443 requestUrl: `${PREPARED.httpBaseUrl}/api/auth/session`,
444 timeoutMs: 100,
445 });
446 expect(harness.calls).toEqual([]);
447 }),
448 );
449
450 it.effect("reports the current endpoint when the total timeout expires during HTTP", () =>
451 Effect.gen(function* () {
452 const requested = Promise.withResolvers<void>();
453 const response = Promise.withResolvers<Response>();
454 const harness = makeHarness(() => {
455 requested.resolve();
456 return response.promise;
457 });
458 const authorizing = yield* Deferred.make<void>();
459 const authorize = yield* Deferred.make<void>();
460 const remoteAuthorization = RemoteEnvironmentAuthorization.of({
461 ...harness.remoteAuthorization,
462 authorizeDpopHttp: (input) =>
463 Deferred.succeed(authorizing, undefined).pipe(
464 Effect.andThen(Deferred.await(authorize)),
465 Effect.andThen(harness.remoteAuthorization.authorizeDpopHttp(input)),
466 ),
467 });
468 const pending = yield* fetchEnvironmentSessionState({
469 ...harness.input,
470 remoteAuthorization: Option.some(remoteAuthorization),
471 timeoutMs: 100,
472 }).pipe(Effect.provide(harness.httpLayer), Effect.flip, Effect.forkChild);
473 yield* Deferred.await(authorizing);
474 yield* TestClock.adjust(25);
475 yield* Deferred.succeed(authorize, undefined);
476 yield* Effect.promise(() => requested.promise);
477 yield* TestClock.adjust(75);
478
479 expect(yield* Fiber.join(pending)).toMatchObject({
480 _tag: "RemoteEnvironmentAuthTimeoutError",
481 requestUrl: `${CURRENT_ORIGIN}/api/auth/session`,
482 timeoutMs: 100,
483 });
484 expect(harness.calls.map((call) => call.url)).toEqual([`${CURRENT_ORIGIN}/api/auth/session`]);
485 expect(harness.authorizations).toHaveLength(1);
486 response.resolve(Response.json(SESSION));
487 }),
488 );
489
490 it.effect("reports renewal failure without sending the expired prepared token", () =>
491 Effect.gen(function* () {
492 const harness = makeHarness(() => Response.json(SESSION));
493 const failure = new ConnectionTransientError({
494 reason: "transport",
495 detail: "Relay unavailable",
496 });
497 const remoteAuthorization = RemoteEnvironmentAuthorization.of({
498 ...harness.remoteAuthorization,
499 authorizeDpopHttp: () => Effect.fail(failure),
500 });
501 const error = yield* fetchEnvironmentSessionState({
502 ...harness.input,
503 remoteAuthorization: Option.some(remoteAuthorization),
504 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
505
506 expect(error).toMatchObject({
507 _tag: "RemoteEnvironmentAuthFetchError",
508 message: "Could not authorize the environment request.",
509 cause: failure,
510 });
511 expect(harness.calls).toEqual([]);
512 expect(harness.proofs).toEqual([]);
513 }),
514 );
515
516 it.effect("does not fall back to a captured DPoP token when authorization is unavailable", () =>
517 Effect.gen(function* () {
518 const harness = makeHarness(() => Response.json(SESSION));
519 const error = yield* fetchEnvironmentSessionState({
520 prepared: PREPARED,
521 signer: harness.input.signer,
522 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
523
524 expect(error).toMatchObject({
525 _tag: "RemoteEnvironmentAuthFetchError",
526 message: "No relay authorization service is available for the environment request.",
527 });
528 expect(harness.calls).toEqual([]);
529 }),
530 );
531});
packages/client-runtime/src/state/environmentHttpAuth.ts+115-5
......@@ -1,10 +1,18 @@
11import * as Effect from "effect/Effect";
22import * as Option from "effect/Option";
3import { FetchHttpClient, type HttpMethod } from "effect/unstable/http";
3import * as Result from "effect/Result";
4import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/http";
45
5import type { PreparedHttpAuthorization } from "../connection/model.ts";
6import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
7import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";
68import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
7import { RemoteEnvironmentAuthFetchError } from "../rpc/http.ts";
9import {
10 executeEnvironmentHttpRequest,
11 makeEnvironmentHttpApiClient,
12 RemoteEnvironmentAuthFetchError,
13 RemoteEnvironmentAuthTimeoutError,
14 type RemoteEnvironmentRequestError,
15} from "../rpc/http.ts";
816
917export interface EnvironmentHttpAuthHeaders {
1018 readonly authorization?: string;
......@@ -28,8 +36,7 @@ export const withEnvironmentCredentials = <A, E, R>(
2836 : request;
2937
3038/**
31 * Build the authorization headers for an authenticated environment HTTP
32 * request, matching the credential the connection was prepared with:
39 * Build request-bound headers from the current environment credential:
3340 * - primary/local connections carry no credential,
3441 * - bearer connections send a static `Bearer` token,
3542 * - relay connections send a `DPoP` access token with a freshly signed proof
......@@ -71,3 +78,106 @@ export const buildEnvironmentAuthHeaders = (
7178 );
7279 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };
7380 });
81
82/**
83 * Resolve relay credentials at request time without replacing the live socket.
84 * A rejected credential gets one refresh and retry, with a new request-bound
85 * proof. Cookie and bearer requests keep their existing authentication behavior.
86 */
87export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn(
88 "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest",
89)(function* <A, E, R>(input: {
90 readonly prepared: PreparedConnection;
91 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
92 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
93 readonly method: HttpMethod.HttpMethod;
94 readonly url: (httpBaseUrl: string) => string;
95 readonly timeoutMs: number;
96 readonly request: (input: {
97 readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiClient>>;
98 readonly headers: EnvironmentHttpAuthHeaders;
99 }) => Effect.Effect<A, E, R>;
100 /** Some endpoints report rejected credentials in a successful response. */
101 readonly isUnauthorizedResponse?: (response: NoInfer<A>) => boolean;
102}): Effect.fn.Return<A, RemoteEnvironmentRequestError, HttpClient.HttpClient | R> {
103 let httpBaseUrl = input.prepared.httpBaseUrl;
104 return yield* Effect.gen(function* () {
105 let rejectedAccessToken: string | undefined;
106 for (;;) {
107 let authorization = input.prepared.httpAuthorization;
108 if (authorization?._tag === "Dpop") {
109 const remote = input.remoteAuthorization;
110 if (remote === undefined || Option.isNone(remote)) {
111 return yield* new RemoteEnvironmentAuthFetchError({
112 message: "No relay authorization service is available for the environment request.",
113 cause: input.prepared.target._tag,
114 });
115 }
116 const current = yield* remote.value
117 .authorizeDpopHttp({
118 expectedEnvironmentId: input.prepared.environmentId,
119 ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }),
120 })
121 .pipe(
122 Effect.mapError(
123 (cause) =>
124 new RemoteEnvironmentAuthFetchError({
125 message: "Could not authorize the environment request.",
126 cause,
127 }),
128 ),
129 );
130 httpBaseUrl = current.httpBaseUrl;
131 authorization = current.httpAuthorization;
132 }
133
134 const requestUrl = input.url(httpBaseUrl);
135 const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl);
136 const headers = yield* buildEnvironmentAuthHeaders(
137 authorization,
138 input.method,
139 requestUrl,
140 input.signer,
141 );
142 const result = yield* executeEnvironmentHttpRequest(
143 requestUrl,
144 input.timeoutMs,
145 withEnvironmentCredentials(authorization, input.request({ client, headers })),
146 ).pipe(Effect.result);
147
148 if (Result.isFailure(result)) {
149 if (
150 authorization?._tag === "Dpop" &&
151 rejectedAccessToken === undefined &&
152 result.failure._tag === "EnvironmentAuthInvalidError" &&
153 result.failure.reason === "invalid_credential"
154 ) {
155 rejectedAccessToken = authorization.accessToken;
156 continue;
157 }
158 return yield* result.failure;
159 }
160
161 if (
162 authorization?._tag === "Dpop" &&
163 input.isUnauthorizedResponse?.(result.success) === true
164 ) {
165 if (rejectedAccessToken === undefined) {
166 rejectedAccessToken = authorization.accessToken;
167 continue;
168 }
169 return yield* new RemoteEnvironmentAuthFetchError({
170 message: "The environment rejected the renewed session authorization.",
171 cause: result.success,
172 });
173 }
174 return result.success;
175 }
176 }).pipe(
177 Effect.timeoutOrElse({
178 duration: input.timeoutMs,
179 orElse: () =>
180 Effect.fail(new RemoteEnvironmentAuthTimeoutError(input.url(httpBaseUrl), input.timeoutMs)),
181 }),
182 );
183});
packages/client-runtime/src/state/pullRequestDiffHttp.ts+17-24
......@@ -10,15 +10,14 @@ import * as Option from "effect/Option";
1010import * as Schema from "effect/Schema";
1111import { HttpClient } from "effect/unstable/http";
1212
13import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
1314import type { PreparedConnection } from "../connection/model.ts";
1415import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
1516import {
16 executeEnvironmentHttpRequest,
17 makeEnvironmentHttpApiClient,
1817 makeEnvironmentHttpApiUrlBuilder,
1918 type RemoteEnvironmentRequestError,
2019} from "../rpc/http.ts";
21import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
20import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
2221
2322const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;
2423
......@@ -46,26 +45,16 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
4645 readonly prepared: PreparedConnection;
4746 readonly diff: PullRequestDiffInput;
4847 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
48 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
4949 readonly timeoutMs?: number;
5050}) {
51 const requestUrl = makeEnvironmentHttpApiUrlBuilder(
52 input.prepared.httpBaseUrl,
53 ).pullRequests.diff();
54 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
55 const headers = yield* buildEnvironmentAuthHeaders(
56 input.prepared.httpAuthorization,
57 "POST",
58 requestUrl,
59 input.signer,
60 );
61 return yield* executeEnvironmentHttpRequest(
62 requestUrl,
63 input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,
64 withEnvironmentCredentials(
65 input.prepared.httpAuthorization,
66 client.pullRequests.diff({ payload: input.diff, headers }),
67 ),
68 ).pipe(
51 return yield* executeAuthenticatedEnvironmentHttpRequest({
52 ...input,
53 method: "POST",
54 url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(),
55 timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,
56 request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }),
57 }).pipe(
6958 Effect.mapError((error) =>
7059 error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential"
7160 ? new PullRequestDiffCredentialRejectedError({
......@@ -98,11 +87,15 @@ export const pullRequestDiffLoaderLayer: Layer.Layer<
9887 Effect.gen(function* () {
9988 const httpClient = yield* HttpClient.HttpClient;
10089 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
90 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
10191 return PullRequestDiffLoader.of({
10292 load: (prepared, input) =>
103 fetchEnvironmentPullRequestDiff({ prepared, diff: input, signer }).pipe(
104 Effect.provideService(HttpClient.HttpClient, httpClient),
105 ),
93 fetchEnvironmentPullRequestDiff({
94 prepared,
95 diff: input,
96 signer,
97 remoteAuthorization,
98 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
10699 });
107100 }),
108101);
packages/client-runtime/src/state/session.ts+16-18
......@@ -6,14 +6,14 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
66import type { HttpClient } from "effect/unstable/http";
77import { AsyncResult, Atom } from "effect/unstable/reactivity";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
910import { EnvironmentRegistry } from "../connection/registry.ts";
1011import type { PreparedConnection } from "../connection/model.ts";
1112import { EnvironmentSupervisor } from "../connection/supervisor.ts";
1213import { environmentEndpointUrl } from "../environment/endpoint.ts";
1314import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
1415import { safeErrorLogAttributes } from "../errors/safeLog.ts";
15import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
16import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
16import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
1717import { followStreamInEnvironment } from "./runtime.ts";
1818
1919function initialConfigOption<E>(
......@@ -36,29 +36,26 @@ const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
3636
3737/**
3838 * Read the granted scopes of this client's session on one environment via its
39 * `/api/auth/session` endpoint, authenticated with whatever credential the
40 * connection was prepared with (cookie, bearer, or DPoP).
39 * `/api/auth/session` endpoint, using the connection's authentication method
40 * and refreshing relay credentials when needed.
4141 */
4242export const fetchEnvironmentSessionState = Effect.fn(
4343 "clientRuntime.state.fetchEnvironmentSessionState",
4444)(function* (input: {
4545 readonly prepared: PreparedConnection;
4646 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
47 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
4748 readonly timeoutMs?: number;
4849}) {
49 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session");
50 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
51 const headers = yield* buildEnvironmentAuthHeaders(
52 input.prepared.httpAuthorization,
53 "GET",
54 requestUrl,
55 input.signer,
56 );
57 return yield* executeEnvironmentHttpRequest(
58 requestUrl,
59 input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
60 withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })),
61 );
50 return yield* executeAuthenticatedEnvironmentHttpRequest({
51 ...input,
52 method: "GET",
53 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"),
54 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
55 request: ({ client, headers }) => client.auth.session({ headers }),
56 // This endpoint returns 200 with authenticated:false for expired credentials.
57 isUnauthorizedResponse: (response) => !response.authenticated,
58 });
6259});
6360
6461export function createEnvironmentSessionAtoms<R, E>(
......@@ -134,7 +131,8 @@ export function createEnvironmentSessionAtoms<R, E>(
134131 }
135132 return Effect.gen(function* () {
136133 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
137 return yield* fetchEnvironmentSessionState({ prepared, signer });
134 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
135 return yield* fetchEnvironmentSessionState({ prepared, signer, remoteAuthorization });
138136 });
139137 })
140138 .pipe(
packages/client-runtime/src/state/shellSnapshotHttp.ts+12-19
......@@ -6,11 +6,11 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import { HttpClient } from "effect/unstable/http";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
910import type { PreparedConnection } from "../connection/model.ts";
1011import { environmentEndpointUrl } from "../environment/endpoint.ts";
1112import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
13import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
13import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
1414
1515// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
1616// fallback for long. The cached shell renders while this runs.
......@@ -27,24 +27,16 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
2727)(function* (input: {
2828 readonly prepared: PreparedConnection;
2929 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
30 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
3031 readonly timeoutMs?: number;
3132}) {
32 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/orchestration/shell");
33 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
34 const headers = yield* buildEnvironmentAuthHeaders(
35 input.prepared.httpAuthorization,
36 "GET",
37 requestUrl,
38 input.signer,
39 );
40 return yield* executeEnvironmentHttpRequest(
41 requestUrl,
42 input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
43 withEnvironmentCredentials(
44 input.prepared.httpAuthorization,
45 client.orchestration.shellSnapshot({ headers }),
46 ),
47 );
33 return yield* executeAuthenticatedEnvironmentHttpRequest({
34 ...input,
35 method: "GET",
36 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"),
37 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
38 request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }),
39 });
4840});
4941
5042/**
......@@ -73,9 +65,10 @@ export const shellSnapshotLoaderLayer: Layer.Layer<
7365 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP
7466 // connections, so the loader must not hard-require it.
7567 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
68 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
7669 return ShellSnapshotLoader.of({
7770 load: (prepared: PreparedConnection) =>
78 fetchEnvironmentShellSnapshot({ prepared, signer }).pipe(
71 fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe(
7972 Effect.map(Option.some<OrchestrationShellSnapshot>),
8073 Effect.provideService(HttpClient.HttpClient, httpClient),
8174 Effect.catchCause((cause) =>
packages/client-runtime/src/state/threadSnapshotHttp.ts+14-24
......@@ -6,15 +6,12 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import { HttpClient } from "effect/unstable/http";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
910import type { PreparedConnection } from "../connection/model.ts";
1011import { environmentEndpointUrl } from "../environment/endpoint.ts";
1112import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import {
13 executeEnvironmentHttpRequest,
14 makeEnvironmentHttpApiClient,
15 type RemoteEnvironmentRequestError,
16} from "../rpc/http.ts";
17import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
13import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
14import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
1815
1916// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
2017// fallback for long. The cached thread renders while this runs, so the wait only
......@@ -42,25 +39,17 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
4239 readonly prepared: PreparedConnection;
4340 readonly threadId: ThreadId;
4441 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
42 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
4543 readonly timeoutMs?: number;
4644 readonly window?: ThreadSnapshotWindow;
4745}) {
48 const requestUrl = environmentEndpointUrl(
49 input.prepared.httpBaseUrl,
50 `/api/orchestration/threads/${input.threadId}`,
51 );
52 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
53 const headers = yield* buildEnvironmentAuthHeaders(
54 input.prepared.httpAuthorization,
55 "GET",
56 requestUrl,
57 input.signer,
58 );
59 return yield* executeEnvironmentHttpRequest(
60 requestUrl,
61 input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
62 withEnvironmentCredentials(
63 input.prepared.httpAuthorization,
46 return yield* executeAuthenticatedEnvironmentHttpRequest({
47 ...input,
48 method: "GET",
49 url: (httpBaseUrl) =>
50 environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`),
51 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
52 request: ({ client, headers }) =>
6453 client.orchestration.threadSnapshot({
6554 params: { threadId: input.threadId },
6655 payload: {
......@@ -71,8 +60,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
7160 },
7261 headers,
7362 }),
74 ),
75 );
63 });
7664});
7765
7866export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
......@@ -106,12 +94,14 @@ export const threadSnapshotLoaderLayer: Layer.Layer<
10694 // connections, so the loader must not hard-require it (bearer/primary
10795 // connections work without one).
10896 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
97 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
10998 return ThreadSnapshotLoader.of({
11099 load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) =>
111100 fetchEnvironmentThreadSnapshot({
112101 prepared,
113102 threadId,
114103 signer,
104 remoteAuthorization,
115105 ...(window !== undefined ? { window } : {}),
116106 }).pipe(
117107 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),