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(...@@ -118,6 +118,9 @@ const capabilitiesLayer = Layer.effectContext(
118 return Context.make(118 return Context.make(
119 CloudSession,119 CloudSession,
120 CloudSession.of({120 CloudSession.of({
121 identity: Effect.sync(() =>
122 Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)),
123 ),
121 clerkToken: Effect.gen(function* () {124 clerkToken: Effect.gen(function* () {
122 const session = appAtomRegistry.get(managedRelaySessionAtom);125 const session = appAtomRegistry.get(managedRelaySessionAtom);
123 if (session === null) {126 if (session === null) {
apps/mobile/src/connection/runtime.ts+2-4
...@@ -30,10 +30,8 @@ type ConnectionLayerSource =...@@ -30,10 +30,8 @@ type ConnectionLayerSource =
30 | typeof mobileBackgroundActivityObserverLayer30 | typeof mobileBackgroundActivityObserverLayer
31 | typeof mobileBackgroundActivityReporterLayer;31 | typeof mobileBackgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = Layer.merge(33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Connection.layerWithOptions({ usageLimitSources: true }),34 Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })),
35 snapshotLoaderLayer,
36).pipe(
37 Layer.provideMerge(35 Layer.provideMerge(
38 Layer.mergeAll(36 Layer.mergeAll(
39 runtimeContextLayer,37 runtimeContextLayer,
apps/mobile/src/connection/storage.ts+2-9
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 ConnectionPersistenceError,2 ConnectionPersistenceError,
3 ConnectionRegistrationStore,3 ConnectionRegistrationStore,
4 ConnectionTargetStore,4 ConnectionTargetStore,
5 putRemoteDpopTokenInCatalog,
5 registerConnectionInCatalog,6 registerConnectionInCatalog,
6 removeConnectionFromCatalog,7 removeConnectionFromCatalog,
7 removeCatalogValue,8 removeCatalogValue,
...@@ -109,15 +110,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -109,15 +110,7 @@ export const connectionStorageLayer = Layer.effectContext(
109 ),110 ),
110 ),111 ),
111 ),112 ),
112 put: (token) =>113 put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)),
113 catalog.update((document) => ({
114 ...document,
115 remoteDpopTokens: replaceCatalogValue(
116 document.remoteDpopTokens,
117 (value) => value.environmentId,
118 token,
119 ),
120 })),
121 remove: (environmentId) =>114 remove: (environmentId) =>
122 catalog.update((document) => ({115 catalog.update((document) => ({
123 ...document,116 ...document,
apps/server/src/auth/SessionStore.test.ts+128
...@@ -5,6 +5,8 @@ import * as Duration from "effect/Duration";...@@ -5,6 +5,8 @@ import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Queue from "effect/Queue";
9import * as Stream from "effect/Stream";
8import * as TestClock from "effect/testing/TestClock";10import * as TestClock from "effect/testing/TestClock";
9import * as SqlClient from "effect/unstable/sql/SqlClient";11import * as SqlClient from "effect/unstable/sql/SqlClient";
1012
...@@ -44,6 +46,14 @@ const makeSessionStoreLayer = (...@@ -44,6 +46,14 @@ const makeSessionStoreLayer = (
44 Layer.provide(makeServerConfigLayer(overrides)),46 Layer.provide(makeServerConfigLayer(overrides)),
45 );47 );
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
47const repositoryFailure = new PersistenceSqlError({57const repositoryFailure = new PersistenceSqlError({
48 operation: "AuthSessionRepository.getById:query",58 operation: "AuthSessionRepository.getById:query",
49 detail: "sqlite is unavailable",59 detail: "sqlite is unavailable",
...@@ -420,6 +430,124 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -420,6 +430,124 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
420 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());430 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
421 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),431 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
422 );432 );
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
423 it.effect("records client connection metadata without clearing prior values", () =>551 it.effect("records client connection metadata without clearing prior values", () =>
424 Effect.gen(function* () {552 Effect.gen(function* () {
425 const sessions = yield* SessionStore.SessionStore;553 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+12-4
...@@ -482,7 +482,7 @@ export const make = Effect.gen(function* () {...@@ -482,7 +482,7 @@ export const make = Effect.gen(function* () {
482 const secretStore = yield* ServerSecretStore.ServerSecretStore;482 const secretStore = yield* ServerSecretStore.ServerSecretStore;
483 const authSessions = yield* AuthSessions.AuthSessionRepository;483 const authSessions = yield* AuthSessions.AuthSessionRepository;
484 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);484 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>());
486 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();486 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
487 const cookieInput = {487 const cookieInput = {
488 mode: serverConfig.mode,488 mode: serverConfig.mode,
...@@ -515,6 +515,11 @@ export const make = Effect.gen(function* () {...@@ -515,6 +515,11 @@ export const make = Effect.gen(function* () {
515 }515 }
516516
517 const connectedSessions = yield* Ref.get(connectedSessionsRef);517 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 }
518 return Option.some(523 return Option.some(
519 toAuthClientSession({524 toAuthClientSession({
520 sessionId: row.value.sessionId,525 sessionId: row.value.sessionId,
...@@ -525,7 +530,7 @@ export const make = Effect.gen(function* () {...@@ -525,7 +530,7 @@ export const make = Effect.gen(function* () {
525 issuedAt: row.value.issuedAt,530 issuedAt: row.value.issuedAt,
526 expiresAt: row.value.expiresAt,531 expiresAt: row.value.expiresAt,
527 lastConnectedAt: row.value.lastConnectedAt,532 lastConnectedAt: row.value.lastConnectedAt,
528 connected: connectedSessions.has(row.value.sessionId),533 connected,
529 }),534 }),
530 );535 );
531 });536 });
...@@ -599,7 +604,7 @@ export const make = Effect.gen(function* () {...@@ -599,7 +604,7 @@ export const make = Effect.gen(function* () {
599 }).pipe(604 }).pipe(
600 Effect.flatMap(() => loadActiveSession(sessionId)),605 Effect.flatMap(() => loadActiveSession(sessionId)),
601 Effect.flatMap((session) =>606 Effect.flatMap((session) =>
602 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,607 Option.isSome(session) ? emitUpsert(session.value) : emitRemoved(sessionId),
603 ),608 ),
604 Effect.catchCause((cause) =>609 Effect.catchCause((cause) =>
605 Effect.logError("Failed to publish disconnected-session auth update.").pipe(610 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
...@@ -882,7 +887,10 @@ export const make = Effect.gen(function* () {...@@ -882,7 +887,10 @@ export const make = Effect.gen(function* () {
882 function* () {887 function* () {
883 const now = yield* DateTime.now;888 const now = yield* DateTime.now;
884 const connectedSessions = yield* Ref.get(connectedSessionsRef);889 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
887 return rows.map((row) =>895 return rows.map((row) =>
888 toAuthClientSession({896 toAuthClientSession({
apps/server/src/persistence/AuthSessions.ts+3-2
...@@ -69,6 +69,7 @@ export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;...@@ -69,6 +69,7 @@ export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;
6969
70export const ListActiveAuthSessionsInput = Schema.Struct({70export const ListActiveAuthSessionsInput = Schema.Struct({
71 now: Schema.DateTimeUtcFromString,71 now: Schema.DateTimeUtcFromString,
72 connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)),
72});73});
73export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;74export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;
7475
...@@ -282,7 +283,7 @@ export const make = Effect.gen(function* () {...@@ -282,7 +283,7 @@ export const make = Effect.gen(function* () {
282 const listActiveSessionRows = SqlSchema.findAll({283 const listActiveSessionRows = SqlSchema.findAll({
283 Request: ListActiveAuthSessionsInput,284 Request: ListActiveAuthSessionsInput,
284 Result: AuthSessionRawDbRow,285 Result: AuthSessionRawDbRow,
285 execute: ({ now }) =>286 execute: ({ now, connectedSessionIds = [] }) =>
286 sql`287 sql`
287 SELECT288 SELECT
288 session_id AS "sessionId",289 session_id AS "sessionId",
...@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {...@@ -301,7 +302,7 @@ export const make = Effect.gen(function* () {
301 revoked_at AS "revokedAt"302 revoked_at AS "revokedAt"
302 FROM auth_sessions303 FROM auth_sessions
303 WHERE revoked_at IS NULL304 WHERE revoked_at IS NULL
304 AND expires_at > ${now}305 AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)})
305 ORDER BY issued_at DESC, session_id DESC306 ORDER BY issued_at DESC, session_id DESC
306 `,307 `,
307 });308 });
apps/web/src/connection/platform.ts+3
...@@ -182,6 +182,9 @@ const capabilitiesLayer = Layer.effectContext(...@@ -182,6 +182,9 @@ const capabilitiesLayer = Layer.effectContext(
182 scopes: AuthStandardClientScopes,182 scopes: AuthStandardClientScopes,
183 });183 });
184 const cloudSession = CloudSession.of({184 const cloudSession = CloudSession.of({
185 identity: Effect.sync(() =>
186 Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)),
187 ),
185 clerkToken: Effect.gen(function* () {188 clerkToken: Effect.gen(function* () {
186 const session = appAtomRegistry.get(managedRelaySessionAtom);189 const session = appAtomRegistry.get(managedRelaySessionAtom);
187 if (session === null) {190 if (session === null) {
apps/web/src/connection/runtime.ts+4-4
...@@ -30,10 +30,10 @@ type ConnectionLayerSource =...@@ -30,10 +30,10 @@ type ConnectionLayerSource =
30 | typeof backgroundActivityObserverLayer30 | typeof backgroundActivityObserverLayer
31 | typeof backgroundActivityReporterLayer;31 | typeof backgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = Layer.merge(33const providedClientConnectionLayer = snapshotLoaderLayer.pipe(
34 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),34 Layer.provideMerge(
35 snapshotLoaderLayer,35 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
36).pipe(36 ),
37 Layer.provideMerge(37 Layer.provideMerge(
38 Layer.mergeAll(38 Layer.mergeAll(
39 runtimeContextLayer,39 runtimeContextLayer,
apps/web/src/connection/storage.ts+2-9
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ConnectionTargetStore,6 ConnectionTargetStore,
7 EMPTY_CONNECTION_CATALOG_DOCUMENT,7 EMPTY_CONNECTION_CATALOG_DOCUMENT,
8 EnvironmentCacheStore,8 EnvironmentCacheStore,
9 putRemoteDpopTokenInCatalog,
9 registerConnectionInCatalog,10 registerConnectionInCatalog,
10 removeCatalogValue,11 removeCatalogValue,
11 removeConnectionFromCatalog,12 removeConnectionFromCatalog,
...@@ -446,15 +447,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -446,15 +447,7 @@ export const connectionStorageLayer = Layer.effectContext(
446 ),447 ),
447 ),448 ),
448 ),449 ),
449 put: (token) =>450 put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)),
450 catalog.update((document) => ({
451 ...document,
452 remoteDpopTokens: replaceCatalogValue(
453 document.remoteDpopTokens,
454 (value) => value.environmentId,
455 token,
456 ),
457 })),
458 remove: (environmentId) =>451 remove: (environmentId) =>
459 catalog.update((document) => ({452 catalog.update((document) => ({
460 ...document,453 ...document,
docs/internals/connection-runtime.md+16-2
...@@ -6,10 +6,10 @@ signals, and application lifecycle events. React views consume the runtime....@@ -6,10 +6,10 @@ signals, and application lifecycle events. React views consume the runtime.
6Keeping retries and session lifetime here prevents competing reconnect loops when6Keeping retries and session lifetime here prevents competing reconnect loops when
7several views need the same environment.7several views need the same environment.
88
9## One retry owner9## One transport retry owner
1010
11The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns11The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns
12retry policy; resolving an endpoint and opening an RPC session are single12transport retry policy; resolving an endpoint and opening an RPC session are single
13attempts. Transient failures retry with capped backoff. Offline states and13attempts. Transient failures retry with capped backoff. Offline states and
14authentication failures wait for a wakeup instead of spending attempts on14authentication failures wait for a wakeup instead of spending attempts on
15unchanged conditions.15unchanged conditions.
...@@ -27,6 +27,20 @@ and cached data. Explicit removal closes the scope and clears credentials,...@@ -27,6 +27,20 @@ and cached data. Explicit removal closes the scope and clears credentials,
27projections, and platform-owned state such as drafts. Cloud-account changes apply27projections, and platform-owned state such as drafts. Cloud-account changes apply
28to relay registrations; they must not discard directly paired environments.28to 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
30## Transport health and data freshness are separate44## Transport health and data freshness are separate
3145
32A socket opening is insufficient evidence that the environment is usable. The46A 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...@@ -24,6 +24,11 @@ On your other device, sign in to the same T3 Connect account and choose the
24environment. Over SSH, the CLI prints a browser link and accepts the returned24environment. Over SSH, the CLI prints a browser link and accepts the returned
25authorization code, so you do not need to forward an OAuth callback port.25authorization 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
27## Pair over a LAN or private network32## Pair over a LAN or private network
2833
29Use direct pairing when the other device can reach the host's network address.34Use 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...@@ -127,6 +132,9 @@ pairing links and revoke client sessions. Revoking an unused link prevents new
127pairings; revoke a device's session to remove its existing access. Command-line132pairings; revoke a device's session to remove its existing access. Command-line
128management is available through `npx t3 auth --help`.133management is available through `npx t3 auth --help`.
129134
135A session with an open connection stays listed after its access credential
136expires.
137
130To remove an environment from T3 Connect, open your account menu's **T3 Connect**138To remove an environment from T3 Connect, open your account menu's **T3 Connect**
131page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This139page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
132revokes its cloud access and frees its host space even when the environment is140revokes 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 @@...@@ -1,7 +1,7 @@
1export * from "./remote.ts";1export * from "./remote.ts";
2export {2export {
3 type AuthorizedRemoteEnvironment,3 type AuthorizedRemoteEnvironment,
4 type RelayEnvironmentAuthorization,4 type AuthorizedRemoteHttpEnvironment,
5 RemoteEnvironmentAuthorization,5 RemoteEnvironmentAuthorization,
6} from "./service.ts";6} from "./service.ts";
7export * as TokenStore from "./tokenStore.ts";7export * as TokenStore from "./tokenStore.ts";
packages/client-runtime/src/authorization/layer.test.ts+524-20
...@@ -1,8 +1,16 @@...@@ -1,8 +1,16 @@
1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
2import {
3 RelayEnvironmentConnectScope,
4 type RelayEnvironmentConnectResponse,
5} from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7import * as Clock from "effect/Clock";
8import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";12import * as Option from "effect/Option";
13import * as Queue from "effect/Queue";
6import * as Ref from "effect/Ref";14import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";15import * as TestClock from "effect/testing/TestClock";
816
...@@ -31,10 +39,11 @@ const DESCRIPTOR = {...@@ -31,10 +39,11 @@ const DESCRIPTOR = {
31 repositoryIdentity: true,39 repositoryIdentity: true,
32 },40 },
33};41};
34const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = {42const BOOTSTRAP: RelayEnvironmentConnectResponse = {
35 environmentId: ENVIRONMENT_ID,43 environmentId: ENVIRONMENT_ID,
36 endpoint: ENDPOINT,44 endpoint: ENDPOINT,
37 credential: "relay-bootstrap",45 credential: "relay-bootstrap",
46 expiresAt: "2026-06-06T01:00:00.000Z",
38};47};
3948
40function recordedFetch(responses: ReadonlyArray<Response>) {49function recordedFetch(responses: ReadonlyArray<Response>) {
...@@ -76,9 +85,31 @@ const authInvalid = () =>...@@ -76,9 +85,31 @@ const authInvalid = () =>
76 { status: 401 },85 { status: 401 },
77 );86 );
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
79const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {106const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {
80 readonly initialToken?: TokenStore.RemoteDpopAccessToken;107 readonly initialToken?: TokenStore.RemoteDpopAccessToken;
81 readonly responses: ReadonlyArray<Response>;108 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"];
82}) {113}) {
83 const tokens = yield* Ref.make(114 const tokens = yield* Ref.make(
84 new Map(115 new Map(
...@@ -88,6 +119,14 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -88,6 +119,14 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
88 ),119 ),
89 );120 );
90 const bootstrapCalls = yield* Ref.make(0);121 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>();
91 const proofInputs = yield* Ref.make<130 const proofInputs = yield* Ref.make<
92 ReadonlyArray<{131 ReadonlyArray<{
93 readonly method: string;132 readonly method: string;
...@@ -101,13 +140,18 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -101,13 +140,18 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
101 get: (environmentId) =>140 get: (environmentId) =>
102 Ref.get(tokens).pipe(141 Ref.get(tokens).pipe(
103 Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))),142 Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))),
143 Effect.tap(() => Queue.offer(tokenReads, environmentId)),
104 ),144 ),
105 put: (token) =>145 put: (token) =>
106 Ref.update(tokens, (current) => {146 (input.beforePut ?? Effect.void).pipe(
107 const next = new Map(current);147 Effect.andThen(
108 next.set(token.environmentId, token);148 Ref.update(tokens, (current) => {
109 return next;149 const next = new Map(current);
110 }),150 next.set(token.environmentId, token);
151 return next;
152 }),
153 ),
154 ),
111 remove: (environmentId) =>155 remove: (environmentId) =>
112 Ref.update(tokens, (current) => {156 Ref.update(tokens, (current) => {
113 const next = new Map(current);157 const next = new Map(current);
...@@ -116,17 +160,47 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -116,17 +160,47 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
116 }),160 }),
117 });161 });
118 const signer = ManagedRelay.ManagedRelayDpopSigner.of({162 const signer = ManagedRelay.ManagedRelayDpopSigner.of({
119 thumbprint: Effect.succeed("thumbprint-1"),163 thumbprint: Ref.get(thumbprint),
120 createProof: (proofInput) =>164 createProof: (proofInput) =>
121 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(165 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(
122 Effect.as(`proof:${proofInput.url}`),166 Effect.as(`proof:${proofInput.url}`),
123 ),167 ),
124 });168 });
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 });
125 const layer = RemoteEnvironmentAuthorization.layer.pipe(191 const layer = RemoteEnvironmentAuthorization.layer.pipe(
126 Layer.provide(192 Layer.provide(
127 Layer.mergeAll(193 Layer.mergeAll(
128 remoteHttpClientLayer(fetch.fetchFn),194 remoteHttpClientLayer(fetch.fetchFn),
129 Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer),195 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 }),
130 Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore),204 Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore),
131 Layer.succeed(205 Layer.succeed(
132 ClientCapabilities.ClientPresentation,206 ClientCapabilities.ClientPresentation,
...@@ -142,17 +216,16 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (...@@ -142,17 +216,16 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
142 ),216 ),
143 ),217 ),
144 );218 );
145 const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe(
146 Effect.as(BOOTSTRAP),
147 );
148
149 return {219 return {
150 layer,220 layer,
151 tokens,221 tokens,
152 bootstrapCalls,222 bootstrapCalls,
153 proofInputs,223 proofInputs,
154 fetch,224 fetch,
155 obtainBootstrap,225 relayInputs,
226 session,
227 thumbprint,
228 tokenReads,
156 };229 };
157});230});
158231
...@@ -234,10 +307,11 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -234,10 +307,11 @@ describe("RemoteEnvironmentAuthorization", () => {
234 }),307 }),
235 );308 );
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", () =>
238 Effect.gen(function* () {311 Effect.gen(function* () {
239 const cached = new TokenStore.RemoteDpopAccessToken({312 const cached = new TokenStore.RemoteDpopAccessToken({
240 environmentId: ENVIRONMENT_ID,313 environmentId: ENVIRONMENT_ID,
314 accountId: "account-1",
241 label: DESCRIPTOR.label,315 label: DESCRIPTOR.label,
242 endpoint: ENDPOINT,316 endpoint: ENDPOINT,
243 accessToken: "cached-access-token",317 accessToken: "cached-access-token",
...@@ -253,7 +327,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -253,7 +327,6 @@ describe("RemoteEnvironmentAuthorization", () => {
253 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;327 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
254 return yield* remote.authorizeDpop({328 return yield* remote.authorizeDpop({
255 expectedEnvironmentId: ENVIRONMENT_ID,329 expectedEnvironmentId: ENVIRONMENT_ID,
256 obtainBootstrap: harness.obtainBootstrap,
257 });330 });
258 }).pipe(Effect.provide(harness.layer));331 }).pipe(Effect.provide(harness.layer));
259332
...@@ -271,6 +344,7 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -271,6 +344,7 @@ describe("RemoteEnvironmentAuthorization", () => {
271 Effect.gen(function* () {344 Effect.gen(function* () {
272 const expired = new TokenStore.RemoteDpopAccessToken({345 const expired = new TokenStore.RemoteDpopAccessToken({
273 environmentId: ENVIRONMENT_ID,346 environmentId: ENVIRONMENT_ID,
347 accountId: "account-1",
274 label: DESCRIPTOR.label,348 label: DESCRIPTOR.label,
275 endpoint: ENDPOINT,349 endpoint: ENDPOINT,
276 accessToken: "expired-access-token",350 accessToken: "expired-access-token",
...@@ -290,7 +364,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -290,7 +364,6 @@ describe("RemoteEnvironmentAuthorization", () => {
290 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;364 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
291 return yield* remote.authorizeDpop({365 return yield* remote.authorizeDpop({
292 expectedEnvironmentId: ENVIRONMENT_ID,366 expectedEnvironmentId: ENVIRONMENT_ID,
293 obtainBootstrap: harness.obtainBootstrap,
294 });367 });
295 }).pipe(Effect.provide(harness.layer));368 }).pipe(Effect.provide(harness.layer));
296369
...@@ -310,6 +383,7 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -310,6 +383,7 @@ describe("RemoteEnvironmentAuthorization", () => {
310 Effect.gen(function* () {383 Effect.gen(function* () {
311 const cached = new TokenStore.RemoteDpopAccessToken({384 const cached = new TokenStore.RemoteDpopAccessToken({
312 environmentId: ENVIRONMENT_ID,385 environmentId: ENVIRONMENT_ID,
386 accountId: "account-1",
313 label: DESCRIPTOR.label,387 label: DESCRIPTOR.label,
314 endpoint: ENDPOINT,388 endpoint: ENDPOINT,
315 accessToken: "invalid-access-token",389 accessToken: "invalid-access-token",
...@@ -330,7 +404,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -330,7 +404,6 @@ describe("RemoteEnvironmentAuthorization", () => {
330 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;404 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
331 return yield* remote.authorizeDpop({405 return yield* remote.authorizeDpop({
332 expectedEnvironmentId: ENVIRONMENT_ID,406 expectedEnvironmentId: ENVIRONMENT_ID,
333 obtainBootstrap: harness.obtainBootstrap,
334 });407 });
335 }).pipe(Effect.provide(harness.layer));408 }).pipe(Effect.provide(harness.layer));
336409
...@@ -355,7 +428,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -355,7 +428,6 @@ describe("RemoteEnvironmentAuthorization", () => {
355 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;428 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
356 return yield* remote.authorizeDpop({429 return yield* remote.authorizeDpop({
357 expectedEnvironmentId: ENVIRONMENT_ID,430 expectedEnvironmentId: ENVIRONMENT_ID,
358 obtainBootstrap: harness.obtainBootstrap,
359 });431 });
360 }).pipe(Effect.provide(harness.layer), Effect.flip);432 }).pipe(Effect.provide(harness.layer), Effect.flip);
361433
...@@ -372,6 +444,7 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -372,6 +444,7 @@ describe("RemoteEnvironmentAuthorization", () => {
372 Effect.gen(function* () {444 Effect.gen(function* () {
373 const cached = new TokenStore.RemoteDpopAccessToken({445 const cached = new TokenStore.RemoteDpopAccessToken({
374 environmentId: ENVIRONMENT_ID,446 environmentId: ENVIRONMENT_ID,
447 accountId: "account-1",
375 label: DESCRIPTOR.label,448 label: DESCRIPTOR.label,
376 endpoint: ENDPOINT,449 endpoint: ENDPOINT,
377 accessToken: "cached-access-token",450 accessToken: "cached-access-token",
...@@ -392,7 +465,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -392,7 +465,6 @@ describe("RemoteEnvironmentAuthorization", () => {
392 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;465 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
393 return yield* remote.authorizeDpop({466 return yield* remote.authorizeDpop({
394 expectedEnvironmentId: ENVIRONMENT_ID,467 expectedEnvironmentId: ENVIRONMENT_ID,
395 obtainBootstrap: harness.obtainBootstrap,
396 });468 });
397 }).pipe(Effect.provide(harness.layer));469 }).pipe(Effect.provide(harness.layer));
398470
...@@ -407,7 +479,7 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -407,7 +479,7 @@ describe("RemoteEnvironmentAuthorization", () => {
407 }),479 }),
408 );480 );
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", () =>
411 Effect.gen(function* () {483 Effect.gen(function* () {
412 const harness = yield* makeHarness({484 const harness = yield* makeHarness({
413 responses: [485 responses: [
...@@ -421,7 +493,6 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -421,7 +493,6 @@ describe("RemoteEnvironmentAuthorization", () => {
421 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;493 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
422 return yield* remote.authorizeDpop({494 return yield* remote.authorizeDpop({
423 expectedEnvironmentId: ENVIRONMENT_ID,495 expectedEnvironmentId: ENVIRONMENT_ID,
424 obtainBootstrap: harness.obtainBootstrap,
425 });496 });
426 }).pipe(Effect.provide(harness.layer), Effect.flip);497 }).pipe(Effect.provide(harness.layer), Effect.flip);
427498
...@@ -430,4 +501,437 @@ describe("RemoteEnvironmentAuthorization", () => {...@@ -430,4 +501,437 @@ describe("RemoteEnvironmentAuthorization", () => {
430 expect(harness.fetch.calls).toHaveLength(3);501 expect(harness.fetch.calls).toHaveLength(3);
431 }),502 }),
432 );503 );
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 );
433});937});
packages/client-runtime/src/authorization/service.ts+272-89
...@@ -3,7 +3,8 @@ import {...@@ -3,7 +3,8 @@ import {
3 EnvironmentId,3 EnvironmentId,
4 type ExecutionEnvironmentDescriptor,4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
7import {8import {
8 exchangeRemoteDpopAccessToken,9 exchangeRemoteDpopAccessToken,
9 type RemoteEnvironmentAuthError,10 type RemoteEnvironmentAuthError,
...@@ -12,10 +13,15 @@ import {...@@ -12,10 +13,15 @@ import {
12} from "./remote.ts";13} from "./remote.ts";
13import {14import {
14 environmentMismatchError,15 environmentMismatchError,
16 mapManagedRelayError,
15 mapRemoteDpopEnvironmentError,17 mapRemoteDpopEnvironmentError,
16 mapRemoteEnvironmentError,18 mapRemoteEnvironmentError,
17} from "../connection/errors.ts";19} 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";
19import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";25import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
20import { environmentEndpointUrl } from "../environment/endpoint.ts";26import { environmentEndpointUrl } from "../environment/endpoint.ts";
21import * as ClientCapabilities from "../platform/capabilities.ts";27import * as ClientCapabilities from "../platform/capabilities.ts";
...@@ -24,10 +30,13 @@ import * as TokenStore from "./tokenStore.ts";...@@ -24,10 +30,13 @@ import * as TokenStore from "./tokenStore.ts";
24import * as Clock from "effect/Clock";30import * as Clock from "effect/Clock";
25import * as Context from "effect/Context";31import * as Context from "effect/Context";
26import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
33import * as Fiber from "effect/Fiber";
27import * as Layer from "effect/Layer";34import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";35import * as Option from "effect/Option";
29import * as Ref from "effect/Ref";36import * as Ref from "effect/Ref";
30import * as Result from "effect/Result";37import * as Result from "effect/Result";
38import * as Scope from "effect/Scope";
39import * as Semaphore from "effect/Semaphore";
31import * as HttpClient from "effect/unstable/http/HttpClient";40import * as HttpClient from "effect/unstable/http/HttpClient";
3241
33import {42import {
...@@ -35,12 +44,6 @@ import {...@@ -35,12 +44,6 @@ import {
35 type PreparedHttpAuthorization,44 type PreparedHttpAuthorization,
36} from "../connection/model.ts";45} from "../connection/model.ts";
3746
38export interface RelayEnvironmentAuthorization {
39 readonly environmentId: EnvironmentId;
40 readonly endpoint: RelayManagedEndpoint;
41 readonly credential: string;
42}
43
44export interface AuthorizedRemoteEnvironment {47export interface AuthorizedRemoteEnvironment {
45 readonly environmentId: EnvironmentId;48 readonly environmentId: EnvironmentId;
46 readonly label: string;49 readonly label: string;
...@@ -49,6 +52,13 @@ export interface AuthorizedRemoteEnvironment {...@@ -49,6 +52,13 @@ export interface AuthorizedRemoteEnvironment {
49 readonly httpAuthorization: PreparedHttpAuthorization;52 readonly httpAuthorization: PreparedHttpAuthorization;
50}53}
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
52export class RemoteEnvironmentAuthorization extends Context.Service<62export class RemoteEnvironmentAuthorization extends Context.Service<
53 RemoteEnvironmentAuthorization,63 RemoteEnvironmentAuthorization,
54 {64 {
...@@ -61,16 +71,17 @@ export class RemoteEnvironmentAuthorization extends Context.Service<...@@ -61,16 +71,17 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
61 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;71 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
62 readonly authorizeDpop: (input: {72 readonly authorizeDpop: (input: {
63 readonly expectedEnvironmentId: EnvironmentId;73 readonly expectedEnvironmentId: EnvironmentId;
64 readonly obtainBootstrap: Effect.Effect<
65 RelayEnvironmentAuthorization,
66 ConnectionAttemptError
67 >;
68 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;74 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
75 readonly authorizeDpopHttp: (input: {
76 readonly expectedEnvironmentId: EnvironmentId;
77 readonly rejectedAccessToken?: string;
78 }) => Effect.Effect<AuthorizedRemoteHttpEnvironment, ConnectionAttemptError>;
69 }79 }
70>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}80>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
7181
72const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;82const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
73const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;83const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
84const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000;
7485
75function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {86function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
76 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"87 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
...@@ -88,10 +99,28 @@ const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescript...@@ -88,10 +99,28 @@ const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescript
88});99});
89100
90export const make = Effect.gen(function* () {101export const make = Effect.gen(function* () {
102 const serviceScope = yield* Scope.Scope;
91 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;103 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
104 const relay = yield* ManagedRelay.ManagedRelayClient;
105 const cloudSession = yield* ClientCapabilities.CloudSession;
106 const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity;
92 const presentation = yield* ClientCapabilities.ClientPresentation;107 const presentation = yield* ClientCapabilities.ClientPresentation;
93 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;108 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
94 const httpClient = yield* HttpClient.HttpClient;109 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 >();
95 const bearerDescriptors = yield* Ref.make<124 const bearerDescriptors = yield* Ref.make<
96 ReadonlyMap<125 ReadonlyMap<
97 EnvironmentId,126 EnvironmentId,
...@@ -192,74 +221,77 @@ export const make = Effect.gen(function* () {...@@ -192,74 +221,77 @@ export const make = Effect.gen(function* () {
192 },221 },
193 );222 );
194223
195 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(224 const sessionChanged = () =>
196 function* (input: {225 new ConnectionBlockedError({
197 readonly expectedEnvironmentId: Parameters<226 reason: "authentication",
198 RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]227 detail: "Your cloud sign-in changed. Sign in again to authorize the environment.",
199 >[0]["expectedEnvironmentId"];228 });
200 readonly obtainBootstrap: Parameters<229
201 RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]230 const assertSession = Effect.fnUntraced(function* (
202 >[0]["obtainBootstrap"];231 identity: ClientCapabilities.CloudSessionIdentity,
203 }) {232 ) {
204 const thumbprint = yield* signer.thumbprint.pipe(233 const current = yield* cloudSession.identity;
205 Effect.mapError(234 if (Option.isNone(current) || current.value !== identity) {
206 () =>235 return yield* sessionChanged();
207 new ConnectionBlockedError({236 }
208 reason: "configuration",237 });
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 }
251238
252 yield* Effect.annotateCurrentSpan({239 // Called under tokenLock so a late rejection cannot remove a newer credential.
253 "connection.remote_token_cache": "miss",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,
254 });275 });
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);
256 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(288 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
257 Effect.provideService(HttpClient.HttpClient, httpClient),289 Effect.provideService(HttpClient.HttpClient, httpClient),
258 Effect.withSpan("environment.authorization.descriptor"),290 Effect.withSpan("environment.authorization.descriptor"),
259 );291 );
260 if (descriptor.environmentId !== input.expectedEnvironmentId) {292 if (descriptor.environmentId !== environmentId) {
261 return yield* environmentMismatchError({293 return yield* environmentMismatchError({
262 expected: input.expectedEnvironmentId,294 expected: environmentId,
263 actual: descriptor.environmentId,295 actual: descriptor.environmentId,
264 });296 });
265 }297 }
...@@ -277,6 +309,7 @@ export const make = Effect.gen(function* () {...@@ -277,6 +309,7 @@ export const make = Effect.gen(function* () {
277 }),309 }),
278 ),310 ),
279 );311 );
312 yield* assertSession(identity);
280 const access = yield* exchangeRemoteDpopAccessToken({313 const access = yield* exchangeRemoteDpopAccessToken({
281 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,314 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
282 credential: bootstrap.credential,315 credential: bootstrap.credential,
...@@ -289,36 +322,186 @@ export const make = Effect.gen(function* () {...@@ -289,36 +322,186 @@ export const make = Effect.gen(function* () {
289 Effect.withSpan("environment.authorization.accessToken.exchange"),322 Effect.withSpan("environment.authorization.accessToken.exchange"),
290 );323 );
291 const issuedAt = yield* Clock.currentTimeMillis;324 const issuedAt = yield* Clock.currentTimeMillis;
292 const token = new TokenStore.RemoteDpopAccessToken({325 return new TokenStore.RemoteDpopAccessToken({
293 environmentId: descriptor.environmentId,326 environmentId: descriptor.environmentId,
327 accountId: identity.accountId,
294 label: descriptor.label,328 label: descriptor.label,
295 endpoint: bootstrap.endpoint,329 endpoint: bootstrap.endpoint,
296 accessToken: access.access_token,330 accessToken: access.access_token,
297 expiresAtEpochMs: issuedAt + access.expires_in * 1_000,331 expiresAtEpochMs: issuedAt + access.expires_in * 1_000,
298 dpopThumbprint: thumbprint,332 dpopThumbprint: thumbprint,
299 });333 });
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 };
315 },334 },
316 );335 );
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
318 return RemoteEnvironmentAuthorization.of({495 return RemoteEnvironmentAuthorization.of({
319 authorizeBearer,496 authorizeBearer,
320 authorizeDpop: (input) =>497 authorizeDpop: (input) =>
321 authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")),498 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 ),
322 });505 });
323});506});
324507
packages/client-runtime/src/authorization/tokenStore.ts+1
...@@ -12,6 +12,7 @@ export class RemoteDpopAccessToken extends Schema.Class<RemoteDpopAccessToken>(...@@ -12,6 +12,7 @@ export class RemoteDpopAccessToken extends Schema.Class<RemoteDpopAccessToken>(
12 "@t3tools/client-runtime/authorization/RemoteDpopAccessToken",12 "@t3tools/client-runtime/authorization/RemoteDpopAccessToken",
13)({13)({
14 environmentId: EnvironmentId,14 environmentId: EnvironmentId,
15 accountId: Schema.optionalKey(Schema.String),
15 label: Schema.String,16 label: Schema.String,
16 endpoint: RelayManagedEndpoint,17 endpoint: RelayManagedEndpoint,
17 accessToken: Schema.String,18 accessToken: Schema.String,
packages/client-runtime/src/connection/layer.ts+5-6
...@@ -11,13 +11,9 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";...@@ -11,13 +11,9 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
12import * as RpcSession from "../rpc/session.ts";12import * as RpcSession from "../rpc/session.ts";
1313
14const resolverLayer = ConnectionResolver.layer.pipe(
15 Layer.provide(RemoteEnvironmentAuthorization.layer),
16);
17
18export function layerWithOptions(options: RpcSession.RpcSessionOptions) {14export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
19 const driverLayer = ConnectionDriver.layer.pipe(15 const driverLayer = ConnectionDriver.layer.pipe(
20 Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))),16 Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))),
21 );17 );
22 const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer));18 const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer));
23 const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer));19 const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer));
...@@ -37,7 +33,10 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {...@@ -37,7 +33,10 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
37 );33 );
38 }).pipe(Effect.withSpan("clientRuntime.connection.application.start")),34 }).pipe(Effect.withSpan("clientRuntime.connection.application.start")),
39 );35 );
40 return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer));36 return connectionStartupLayer.pipe(
37 Layer.provideMerge(connectionServicesLayer),
38 Layer.provideMerge(RemoteEnvironmentAuthorization.layer),
39 );
41}40}
4241
43export const layer = layerWithOptions({});42export const layer = layerWithOptions({});
packages/client-runtime/src/connection/resolver.test.ts+44-141
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts";1import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts";
2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
3import { RelayClientTracer } from "@t3tools/shared/relayTracing";2import { RelayClientTracer } from "@t3tools/shared/relayTracing";
4import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -8,7 +7,6 @@ import * as Option from "effect/Option";...@@ -8,7 +7,6 @@ import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";7import * as Ref from "effect/Ref";
9import * as Tracer from "effect/Tracer";8import * as Tracer from "effect/Tracer";
109
11import * as ManagedRelay from "../relay/managedRelay.ts";
12import * as ConnectionResolver from "./resolver.ts";10import * as ConnectionResolver from "./resolver.ts";
13import * as ClientCapabilities from "../platform/capabilities.ts";11import * as ClientCapabilities from "../platform/capabilities.ts";
14import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";12import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
...@@ -35,7 +33,6 @@ const ENVIRONMENT_ID = EnvironmentId.make("environment-1");...@@ -35,7 +33,6 @@ const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
35const ENDPOINT = {33const ENDPOINT = {
36 httpBaseUrl: "https://environment.example.test",34 httpBaseUrl: "https://environment.example.test",
37 wsBaseUrl: "wss://environment.example.test",35 wsBaseUrl: "wss://environment.example.test",
38 providerKind: "cloudflare_tunnel" as const,
39};36};
40const SSH_TARGET: DesktopSshEnvironmentTarget = {37const SSH_TARGET: DesktopSshEnvironmentTarget = {
41 alias: "development",38 alias: "development",
...@@ -51,10 +48,6 @@ function catalogEntry(...@@ -51,10 +48,6 @@ function catalogEntry(
51 return { target, profile };48 return { target, profile };
52}49}
5350
54function unsupported<A>(name: string): Effect.Effect<A> {
55 return Effect.die(new Error(`Unexpected relay call: ${name}`));
56}
57
58function collectingTracer(spans: Array<string>): Tracer.Tracer {51function collectingTracer(spans: Array<string>): Tracer.Tracer {
59 return Tracer.make({52 return Tracer.make({
60 span: (options) => {53 span: (options) => {
...@@ -69,30 +62,9 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {...@@ -69,30 +62,9 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {
69 });62 });
70}63}
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
92const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {65const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
93 readonly profiles?: ReadonlyArray<ConnectionProfile>;66 readonly profiles?: ReadonlyArray<ConnectionProfile>;
94 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;67 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
95 readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"];
96 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];68 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
97 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];69 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
98 readonly primaryBearerToken?: string;70 readonly primaryBearerToken?: string;
...@@ -131,19 +103,18 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -131,19 +103,18 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
131 authorizeDpop:103 authorizeDpop:
132 options?.authorizeDpop ??104 options?.authorizeDpop ??
133 ((input) =>105 ((input) =>
134 input.obtainBootstrap.pipe(106 Effect.succeed({
135 Effect.as({107 environmentId: input.expectedEnvironmentId,
136 environmentId: input.expectedEnvironmentId,108 label: "Authorized relay environment",
137 label: "Authorized relay environment",109 httpBaseUrl: ENDPOINT.httpBaseUrl,
138 httpBaseUrl: ENDPOINT.httpBaseUrl,110 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
139 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",111 httpAuthorization: {
140 httpAuthorization: {112 _tag: "Dpop" as const,
141 _tag: "Dpop" as const,113 accessToken: "dpop-access-token",
142 accessToken: "dpop-access-token",114 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
143 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,115 },
144 },116 })),
145 }),117 authorizeDpopHttp: () => Effect.die("unused"),
146 )),
147 });118 });
148 const ssh = ClientCapabilities.SshEnvironmentGateway.of({119 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
149 provision: () => Effect.die("unused"),120 provision: () => Effect.die("unused"),
...@@ -165,10 +136,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -165,10 +136,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
165 const dependencies = Layer.mergeAll(136 const dependencies = Layer.mergeAll(
166 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),137 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),
167 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),138 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
168 Layer.succeed(
169 ClientCapabilities.CloudSession,
170 ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }),
171 ),
172 Layer.succeed(139 Layer.succeed(
173 ClientCapabilities.PrimaryEnvironmentAuth,140 ClientCapabilities.PrimaryEnvironmentAuth,
174 ClientCapabilities.PrimaryEnvironmentAuth.of({141 ClientCapabilities.PrimaryEnvironmentAuth.of({
...@@ -182,27 +149,8 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -182,27 +149,8 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
182 scopes: [],149 scopes: [],
183 }),150 }),
184 ),151 ),
185 Layer.succeed(
186 ClientCapabilities.RelayDeviceIdentity,
187 ClientCapabilities.RelayDeviceIdentity.of({
188 deviceId: Effect.succeed(Option.some("device-1")),
189 }),
190 ),
191 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),152 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),
192 Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh),153 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 ),
206 );154 );
207155
208 return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies)));156 return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies)));
...@@ -314,66 +262,27 @@ describe("ConnectionResolver", () => {...@@ -314,66 +262,27 @@ describe("ConnectionResolver", () => {
314 }),262 }),
315 );263 );
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", () =>
318 Effect.gen(function* () {266 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>>([]);
327 const target = new RelayConnectionTarget({267 const target = new RelayConnectionTarget({
328 environmentId: ENVIRONMENT_ID,268 environmentId: ENVIRONMENT_ID,
329 label: "Cloud",269 label: "Cloud",
330 });270 });
331 const brokerLayer = yield* makeDependencies({271 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 });
366 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));272 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
367273
368 expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop");274 expect(yield* broker.prepare(catalogEntry(target))).toEqual({
369 expect(yield* Ref.get(relayInputs)).toEqual([275 environmentId: ENVIRONMENT_ID,
370 {276 label: "Authorized relay environment",
371 clerkToken: "clerk-session",277 httpBaseUrl: ENDPOINT.httpBaseUrl,
372 scopes: [RelayEnvironmentConnectScope],278 socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop",
373 deviceId: "device-1",279 httpAuthorization: {
280 _tag: "Dpop",
281 accessToken: "dpop-access-token",
282 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
374 },283 },
375 ]);284 target,
376 expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]);285 });
377 }),286 }),
378 );287 );
379288
...@@ -387,20 +296,17 @@ describe("ConnectionResolver", () => {...@@ -387,20 +296,17 @@ describe("ConnectionResolver", () => {
387 });296 });
388 const brokerLayer = yield* makeDependencies({297 const brokerLayer = yield* makeDependencies({
389 authorizeDpop: (input) =>298 authorizeDpop: (input) =>
390 input.obtainBootstrap.pipe(299 Effect.succeed({
391 Effect.as({300 environmentId: input.expectedEnvironmentId,
392 environmentId: input.expectedEnvironmentId,301 label: "Cloud",
393 label: "Cloud",302 httpBaseUrl: ENDPOINT.httpBaseUrl,
394 httpBaseUrl: ENDPOINT.httpBaseUrl,303 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",
395 socketUrl: "wss://environment.example.test/ws?wsTicket=dpop",304 httpAuthorization: {
396 httpAuthorization: {305 _tag: "Dpop" as const,
397 _tag: "Dpop" as const,306 accessToken: "dpop-access-token",
398 accessToken: "dpop-access-token",307 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
399 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,308 },
400 },309 }).pipe(Effect.withSpan("test.remote.authorizeDpop")),
401 }),
402 Effect.withSpan("test.remote.authorizeDpop"),
403 ),
404 });310 });
405 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));311 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
406312
...@@ -470,27 +376,24 @@ describe("ConnectionResolver", () => {...@@ -470,27 +376,24 @@ describe("ConnectionResolver", () => {
470 }),376 }),
471 );377 );
472378
473 it.effect("classifies relay request timeouts as retryable connection failures", () =>379 it.effect("preserves relay authorization failure classification and trace details", () =>
474 Effect.gen(function* () {380 Effect.gen(function* () {
475 const target = new RelayConnectionTarget({381 const target = new RelayConnectionTarget({
476 environmentId: ENVIRONMENT_ID,382 environmentId: ENVIRONMENT_ID,
477 label: "Cloud",383 label: "Cloud",
478 });384 });
385 const authorizationError = new ConnectionTransientError({
386 reason: "timeout",
387 detail: "Relay environment connection timed out.",
388 traceId: "relay-trace",
389 });
479 const brokerLayer = yield* makeDependencies({390 const brokerLayer = yield* makeDependencies({
480 connectEnvironment: () =>391 authorizeDpop: () => Effect.fail(authorizationError),
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 ),
488 });392 });
489 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));393 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
490 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));394 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
491395
492 expect(error).toBeInstanceOf(ConnectionTransientError);396 expect(error).toBe(authorizationError);
493 expect(error).toMatchObject({ reason: "timeout" });
494 }),397 }),
495 );398 );
496});399});
packages/client-runtime/src/connection/resolver.ts+1-34
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
3import { withRelayClientTracing } from "@t3tools/shared/relayTracing";2import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
4import * as Context from "effect/Context";3import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -9,7 +8,6 @@ import * as Schema from "effect/Schema";...@@ -9,7 +8,6 @@ import * as Schema from "effect/Schema";
98
10import { appendClientConnectionParams } from "../authorization/remote.ts";9import { appendClientConnectionParams } from "../authorization/remote.ts";
11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
12import * as ManagedRelay from "../relay/managedRelay.ts";
13import * as ClientCapabilities from "../platform/capabilities.ts";11import * as ClientCapabilities from "../platform/capabilities.ts";
14import {12import {
15 BearerConnectionCredential,13 BearerConnectionCredential,
...@@ -18,12 +16,7 @@ import {...@@ -18,12 +16,7 @@ import {
18 SshConnectionProfile,16 SshConnectionProfile,
19} from "./catalog.ts";17} from "./catalog.ts";
20import * as ConnectionCredentialStore from "./credentialStore.ts";18import * as ConnectionCredentialStore from "./credentialStore.ts";
21import {19import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts";
22 credentialMissingError,
23 environmentMismatchError,
24 mapManagedRelayError,
25 profileMissingError,
26} from "./errors.ts";
27import type {20import type {
28 BearerConnectionTarget,21 BearerConnectionTarget,
29 ConnectionTarget,22 ConnectionTarget,
...@@ -148,38 +141,12 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")...@@ -148,38 +141,12 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
148});141});
149142
150const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {143const 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;
154 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;144 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
155145
156 return Effect.fnUntraced(146 return Effect.fnUntraced(
157 function* (target: RelayConnectionTarget) {147 function* (target: RelayConnectionTarget) {
158 const authorized = yield* remote.authorizeDpop({148 const authorized = yield* remote.authorizeDpop({
159 expectedEnvironmentId: target.environmentId,149 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")),
183 });150 });
184 return {151 return {
185 environmentId: authorized.environmentId,152 environmentId: authorized.environmentId,
packages/client-runtime/src/connection/supervisor.test.ts+186-140
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
2import { RelayClientTracer } from "@t3tools/shared/relayTracing";2import { RelayClientTracer } from "@t3tools/shared/relayTracing";
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
...@@ -11,12 +11,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef";...@@ -11,12 +11,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
11import * as TestClock from "effect/testing/TestClock";11import * as TestClock from "effect/testing/TestClock";
12import * as Tracer from "effect/Tracer";12import * 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";
14import type { WsRpcProtocolClient } from "../rpc/protocol.ts";23import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import { fetchEnvironmentSessionState } from "../state/session.ts";
15import type { ConnectionCatalogEntry } from "./catalog.ts";25import type { ConnectionCatalogEntry } from "./catalog.ts";
16import * as Connectivity from "./connectivity.ts";26import * as Connectivity from "./connectivity.ts";
17import * as ConnectionDriver from "./driver.ts";27import * as ConnectionDriver from "./driver.ts";
18import {28import {
19 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
20 ConnectionBlockedError,29 ConnectionBlockedError,
21 ConnectionTransientError,30 ConnectionTransientError,
22 PrimaryConnectionTarget,31 PrimaryConnectionTarget,
...@@ -1127,183 +1136,220 @@ describe("EnvironmentSupervisor", () => {...@@ -1127,183 +1136,220 @@ describe("EnvironmentSupervisor", () => {
1127 }),1136 }),
1128 );1137 );
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", () =>
1131 Effect.gen(function* () {1140 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>();
1135 const harness = yield* makeHarness({1141 const harness = yield* makeHarness({
1136 prepare: (attempt) =>1142 prepare: (attempt) =>
1137 attempt === 21143 Effect.succeed({
1138 ? Effect.fail(transient("Authorization refresh failed."))1144 ...PREPARED_CONNECTION,
1139 : Effect.succeed({1145 target: RELAY_TARGET,
1140 ...PREPARED_CONNECTION,1146 httpAuthorization: {
1141 target: RELAY_TARGET,1147 _tag: "Dpop",
1142 httpAuthorization: {1148 accessToken: `access-token-${attempt}`,
1143 _tag: "Dpop",1149 expiresAtEpochMs: 3_600_000 * attempt,
1144 accessToken: `access-token-${attempt}`,1150 },
1145 expiresAtEpochMs: tokenLifetimeMs * attempt,1151 }),
1146 },
1147 }),
1148 ready: (attempt) =>
1149 attempt === 2
1150 ? Deferred.succeed(replacementStarted, undefined).pipe(
1151 Effect.andThen(Deferred.await(releaseReplacement)),
1152 )
1153 : Effect.void,
1154 });1152 });
1155 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {1153 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
1156 initiallyDesired: true,1154 initiallyDesired: true,
1157 }).pipe(Effect.provide(harness.dependencies));1155 }).pipe(Effect.provide(harness.dependencies));
11581156
1159 yield* awaitState(supervisor.state, (state) => state.phase === "connected");1157 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1160 const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));1158 const session = 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);
11641159
1165 yield* TestClock.adjust(1);1160 yield* TestClock.adjust("2 hours");
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;
11701161
1171 yield* TestClock.adjust("2999 millis");1162 expect(yield* Ref.get(harness.sessionCount)).toBe(1);
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);
1178 expect(yield* Ref.get(harness.releaseCount)).toBe(0);1163 expect(yield* Ref.get(harness.releaseCount)).toBe(0);
1164 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session);
1179 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({1165 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1180 phase: "connected",1166 phase: "connected",
1181 generation: 1,1167 generation: 1,
1182 });1168 });
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" });
1201 }).pipe(Effect.provide(TestClock.layer())),1169 }).pipe(Effect.provide(TestClock.layer())),
1202 );1170 );
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", () =>
1205 Effect.gen(function* () {1173 Effect.gen(function* () {
1206 const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2;1174 const endpoint = {
1207 const replacementStarted = yield* Deferred.make<void>();1175 httpBaseUrl: TARGET.httpBaseUrl,
1208 const retryStarted = yield* Deferred.make<void>();1176 wsBaseUrl: TARGET.wsBaseUrl,
1209 const harness = yield* makeHarness({1177 providerKind: "cloudflare_tunnel" as const,
1210 prepare: (attempt) =>1178 };
1211 Effect.succeed({1179 const token = yield* Ref.make(
1212 ...PREPARED_CONNECTION,1180 Option.some(
1213 target: RELAY_TARGET,1181 new TokenStore.RemoteDpopAccessToken({
1214 httpAuthorization: {1182 environmentId: TARGET.environmentId,
1215 _tag: "Dpop",1183 accountId: "test-account",
1216 accessToken: `access-token-${attempt}`,1184 label: TARGET.label,
1217 expiresAtEpochMs: tokenLifetimeMs * attempt,1185 endpoint,
1218 },1186 accessToken: "access-token-1",
1187 expiresAtEpochMs: 3_600_000,
1188 dpopThumbprint: "test-thumbprint",
1219 }),1189 }),
1220 ready: (attempt) => {1190 ),
1221 if (attempt === 2) {1191 );
1222 return Deferred.succeed(replacementStarted, undefined).pipe(1192 const bootstrapFails = yield* Ref.make(false);
1223 Effect.andThen(Effect.never),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 }),
1224 );1242 );
1225 }1243 }
1226 if (attempt === 3) {1244 default:
1227 return Deferred.succeed(retryStarted, undefined).pipe(Effect.andThen(Effect.never));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 });
1228 }1269 }
1229 return Effect.void;1270 return {
1230 },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 }))),
1231 });1314 });
1232 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {1315 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
1233 initiallyDesired: true,1316 initiallyDesired: true,
1234 }).pipe(Effect.provide(harness.dependencies));1317 }).pipe(Effect.provide(harness.dependencies));
1235
1236 yield* awaitState(supervisor.state, (state) => state.phase === "connected");1318 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
1237 const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));1319 const session = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session));
1238 yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS);1320 const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared));
1239 yield* Deferred.await(replacementStarted);1321 const readSession = fetchEnvironmentSessionState({
12401322 prepared,
1241 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);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);
1242 expect(yield* Ref.get(harness.releaseCount)).toBe(0);1332 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);
1248 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({1333 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1249 phase: "connected",1334 phase: "connected",
1250 generation: 1,1335 generation: 1,
1251 });1336 });
12521337
1253 yield* TestClock.adjust("3 seconds");1338 yield* TestClock.adjust("2 hours");
1254 yield* Deferred.await(retryStarted);1339 yield* Ref.set(bootstrapFails, true);
1255 expect(yield* Ref.get(harness.sessionCount)).toBe(3);1340 const failure = yield* readSession.pipe(Effect.flip);
1256 expect(yield* Ref.get(harness.releaseCount)).toBe(1);1341 expect(failure._tag).toBe("RemoteEnvironmentAuthFetchError");
1257 expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession);1342 expect(yield* Ref.get(bootstrapCalls)).toBe(2);
12581343 expect(sessionAuthorizations).toEqual(["DPoP access-token-2"]);
1259 yield* supervisor.disconnect;1344 expect(httpPaths.filter((path) => path === "/api/auth/websocket-ticket")).toHaveLength(1);
1260 yield* awaitState(supervisor.state, (state) => state.phase === "available");1345 expect(httpPaths.filter((path) => path === "/oauth/token")).toHaveLength(1);
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
1299 expect(yield* Ref.get(harness.sessionCount)).toBe(1);1346 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);
1301 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({1349 expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({
1302 phase: "backoff",1350 phase: "connected",
1303 generation: 1,1351 generation: 1,
1304 });1352 });
1305 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true);
1306 expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true);
1307 }).pipe(Effect.provide(TestClock.layer())),1353 }).pipe(Effect.provide(TestClock.layer())),
1308 );1354 );
13091355
packages/client-runtime/src/connection/supervisor.ts+44-270
...@@ -18,7 +18,6 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";...@@ -18,7 +18,6 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";
18import * as Connectivity from "./connectivity.ts";18import * as Connectivity from "./connectivity.ts";
19import * as ConnectionDriver from "./driver.ts";19import * as ConnectionDriver from "./driver.ts";
20import {20import {
21 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
22 type ConnectionAttemptError,21 type ConnectionAttemptError,
23 type ConnectionTarget,22 type ConnectionTarget,
24 ConnectionTransientError,23 ConnectionTransientError,
...@@ -61,24 +60,16 @@ interface TracedAttemptFailure {...@@ -61,24 +60,16 @@ interface TracedAttemptFailure {
61 readonly attemptSpan: Option.Option<Tracer.Span>;60 readonly attemptSpan: Option.Option<Tracer.Span>;
62}61}
6362
64interface ScopedConnection {
65 readonly attemptSpan: Option.Option<Tracer.Span>;
66 readonly lease: ConnectionDriver.EnvironmentConnectionLease;
67 readonly scope: Scope.Closeable;
68}
69
70type AttemptOutcome =63type AttemptOutcome =
71 | {64 | {
72 readonly _tag: "Interrupted";65 readonly _tag: "Interrupted";
73 readonly established: boolean;66 readonly established: boolean;
74 readonly generation: number;
75 readonly stable: boolean;67 readonly stable: boolean;
76 readonly resetRetry: boolean;68 readonly resetRetry: boolean;
77 }69 }
78 | {70 | {
79 readonly _tag: "Failure";71 readonly _tag: "Failure";
80 readonly established: boolean;72 readonly established: boolean;
81 readonly generation: number;
82 readonly stable: boolean;73 readonly stable: boolean;
83 readonly failure: TracedAttemptFailure;74 readonly failure: TracedAttemptFailure;
84 };75 };
...@@ -86,26 +77,17 @@ type AttemptOutcome =...@@ -86,26 +77,17 @@ type AttemptOutcome =
86type EstablishmentEvent =77type EstablishmentEvent =
87 | {78 | {
88 readonly _tag: "Completed";79 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 >;
90 }87 }
91 | { readonly _tag: "Interrupted"; readonly resetRetry: boolean }88 | { readonly _tag: "Interrupted"; readonly resetRetry: boolean }
92 | { readonly _tag: "TimedOut" };89 | { 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
109function exitUnlessInterrupted<A, E, R>(91function exitUnlessInterrupted<A, E, R>(
110 effect: Effect.Effect<A, E, R>,92 effect: Effect.Effect<A, E, R>,
111): Effect.Effect<Exit.Exit<A, E>, never, R> {93): Effect.Effect<Exit.Exit<A, E>, never, R> {
...@@ -186,18 +168,16 @@ function failureFromExit<A>(...@@ -186,18 +168,16 @@ function failureFromExit<A>(
186 target: ConnectionTarget,168 target: ConnectionTarget,
187 exit: Exit.Exit<A, TracedAttemptFailure>,169 exit: Exit.Exit<A, TracedAttemptFailure>,
188 established: boolean,170 established: boolean,
189 generation: number,
190 stable: boolean,171 stable: boolean,
191): AttemptOutcome {172): AttemptOutcome {
192 if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {173 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 };
194 }175 }
195 const typedFailure = exit.cause.reasons.find(Cause.isFailReason);176 const typedFailure = exit.cause.reasons.find(Cause.isFailReason);
196 if (typedFailure) {177 if (typedFailure) {
197 return {178 return {
198 _tag: "Failure",179 _tag: "Failure",
199 established,180 established,
200 generation,
201 stable,181 stable,
202 failure: typedFailure.error,182 failure: typedFailure.error,
203 };183 };
...@@ -205,7 +185,6 @@ function failureFromExit<A>(...@@ -205,7 +185,6 @@ function failureFromExit<A>(
205 return {185 return {
206 _tag: "Failure",186 _tag: "Failure",
207 established,187 established,
208 generation,
209 stable,188 stable,
210 failure: {189 failure: {
211 error: new ConnectionTransientError({190 error: new ConnectionTransientError({
...@@ -313,10 +292,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -313,10 +292,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
313 attempt: number,292 attempt: number,
314 generation: number,293 generation: number,
315 lastFailure: ConnectionAttemptError | null,294 lastFailure: ConnectionAttemptError | null,
316 publishProgress: boolean,
317 ) {295 ) {
318 return yield* driver.connect(entry, (progress) =>296 return yield* driver.connect(entry, (progress) =>
319 publishProgress ? reportProgress(attempt, generation, lastFailure, progress) : Effect.void,297 reportProgress(attempt, generation, lastFailure, progress),
320 );298 );
321 });299 });
322300
...@@ -367,17 +345,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -367,17 +345,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
367 generation: number,345 generation: number,
368 lastFailure: ConnectionAttemptError | null,346 lastFailure: ConnectionAttemptError | null,
369 pendingRetry: Option.Option<PendingRetryTrace>,347 pendingRetry: Option.Option<PendingRetryTrace>,
370 publishProgress: boolean,
371 ) {348 ) {
372 if (target._tag === "RelayConnectionTarget") {349 if (target._tag === "RelayConnectionTarget") {
373 return yield* traceRelayEstablishment(350 return yield* traceRelayEstablishment(
374 establishConnection(attempt, generation, lastFailure, publishProgress),351 establishConnection(attempt, generation, lastFailure),
375 attempt,352 attempt,
376 generation,353 generation,
377 pendingRetry,354 pendingRetry,
378 );355 );
379 }356 }
380 return yield* establishConnection(attempt, generation, lastFailure, publishProgress).pipe(357 return yield* establishConnection(attempt, generation, lastFailure).pipe(
381 Effect.map((lease) => ({358 Effect.map((lease) => ({
382 attemptSpan: Option.none<Tracer.Span>(),359 attemptSpan: Option.none<Tracer.Span>(),
383 lease,360 lease,
...@@ -389,37 +366,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -389,37 +366,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
389 );366 );
390 });367 });
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
423 const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () {369 const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () {
424 for (;;) {370 for (;;) {
425 const next = yield* Queue.take(signals);371 const next = yield* Queue.take(signals);
...@@ -541,136 +487,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -541,136 +487,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
541 }487 }
542 });488 });
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
657 const runAttempt = Effect.fnUntraced(function* (490 const runAttempt = Effect.fnUntraced(function* (
658 attempt: number,491 attempt: number,
659 previousGeneration: number,492 generation: number,
660 lastFailure: ConnectionAttemptError | null,493 lastFailure: ConnectionAttemptError | null,
661 pendingRetry: Option.Option<PendingRetryTrace>,494 pendingRetry: Option.Option<PendingRetryTrace>,
662 ) {495 ) {
663 const initialGeneration = previousGeneration + 1;
664 yield* SubscriptionRef.set(prepared, Option.none());496 yield* SubscriptionRef.set(prepared, Option.none());
665 const initial = yield* forkScopedTracedConnection(
666 attempt,
667 initialGeneration,
668 lastFailure,
669 pendingRetry,
670 true,
671 );
672 const establishment = yield* Effect.raceAllFirst([497 const establishment = yield* Effect.raceAllFirst([
673 Fiber.await(initial.fiber).pipe(498 exitUnlessInterrupted(
499 establishTracedConnection(attempt, generation, lastFailure, pendingRetry),
500 ).pipe(
674 Effect.map((exit): EstablishmentEvent => ({501 Effect.map((exit): EstablishmentEvent => ({
675 _tag: "Completed",502 _tag: "Completed",
676 exit,503 exit,
...@@ -687,18 +514,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -687,18 +514,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
687 ),514 ),
688 ]);515 ]);
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 }
697 if (establishment._tag === "Interrupted") {517 if (establishment._tag === "Interrupted") {
698 return {518 return {
699 _tag: "Interrupted",519 _tag: "Interrupted",
700 established: false,520 established: false,
701 generation: previousGeneration,
702 stable: false,521 stable: false,
703 resetRetry: establishment.resetRetry,522 resetRetry: establishment.resetRetry,
704 } satisfies AttemptOutcome;523 } satisfies AttemptOutcome;
...@@ -707,7 +526,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -707,7 +526,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
707 return {526 return {
708 _tag: "Failure",527 _tag: "Failure",
709 established: false,528 established: false,
710 generation: previousGeneration,
711 stable: false,529 stable: false,
712 failure: {530 failure: {
713 error: new ConnectionTransientError({531 error: new ConnectionTransientError({
...@@ -722,7 +540,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -722,7 +540,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
722 const isUnexpectedDefect =540 const isUnexpectedDefect =
723 !Cause.hasInterruptsOnly(establishment.exit.cause) &&541 !Cause.hasInterruptsOnly(establishment.exit.cause) &&
724 !establishment.exit.cause.reasons.some(Cause.isFailReason);542 !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);
726 if (isUnexpectedDefect) {544 if (isUnexpectedDefect) {
727 const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect;545 const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect;
728 yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe(546 yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe(
...@@ -737,20 +555,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -737,20 +555,18 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
737 return outcome;555 return outcome;
738 }556 }
739557
558 const active = establishment.exit.value;
740 const currentIntent = yield* Ref.get(intent);559 const currentIntent = yield* Ref.get(intent);
741 if (!currentIntent.desired || currentIntent.network === "offline") {560 if (!currentIntent.desired || currentIntent.network === "offline") {
742 return {561 return {
743 _tag: "Interrupted",562 _tag: "Interrupted",
744 established: false,563 established: false,
745 generation: previousGeneration,
746 stable: false,564 stable: false,
747 resetRetry: false,565 resetRetry: false,
748 } satisfies AttemptOutcome;566 } satisfies AttemptOutcome;
749 }567 }
750568
751 const connectedAt = yield* Clock.currentTimeMillis;569 const connectedAt = yield* Clock.currentTimeMillis;
752 let active = establishment.exit.value;
753 let activeGeneration = initialGeneration;
754 yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared));570 yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared));
755 yield* SubscriptionRef.set(session, Option.some(active.lease.session));571 yield* SubscriptionRef.set(session, Option.some(active.lease.session));
756 yield* setState({572 yield* setState({
...@@ -759,78 +575,35 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -759,78 +575,35 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
759 phase: "connected",575 phase: "connected",
760 stage: null,576 stage: null,
761 attempt,577 attempt,
762 generation: activeGeneration,578 generation,
763 lastFailure: null,579 lastFailure: null,
764 retryAt: null,580 retryAt: null,
765 });581 });
766582
767 for (;;) {583 const connectedExit = yield* Effect.raceFirst(
768 const connectedEvent = yield* Effect.raceAllFirst([584 active.lease.session.closed.pipe(
769 waitForActiveCompletion(active),585 Effect.mapError((error): TracedAttemptFailure => ({
770 exitUnlessInterrupted(prepareReplacement(active, activeGeneration + 1)).pipe(586 error,
771 Effect.map((exit): ConnectedLeaseEvent => ({ _tag: "ReplacementCompleted", exit })),587 attemptSpan: active.attemptSpan,
772 ),588 })),
773 ]);589 ),
774 const stable = (yield* Clock.currentTimeMillis) - connectedAt >= BACKOFF_RESET_AFTER_MS;590 monitorConnectedLease(active.lease).pipe(
775 if (connectedEvent._tag === "ActiveCompleted") {591 Effect.mapError((error): TracedAttemptFailure => ({
776 if (Exit.isSuccess(connectedEvent.exit)) {592 error,
777 return {593 attemptSpan: active.attemptSpan,
778 _tag: "Interrupted",594 })),
779 established: true,595 ),
780 generation: activeGeneration,596 ).pipe(exitUnlessInterrupted);
781 stable,597 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;
782 resetRetry: connectedEvent.exit.value,598 if (Exit.isSuccess(connectedExit)) {
783 } satisfies AttemptOutcome;599 return {
784 }600 _tag: "Interrupted",
785 return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable);601 established: true,
786 }602 stable: connectedForMs >= BACKOFF_RESET_AFTER_MS,
787 if (Exit.isFailure(connectedEvent.exit)) {603 resetRetry: connectedExit.value,
788 return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable);604 } satisfies AttemptOutcome;
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 );
833 }605 }
606 return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS);
834 }, Effect.ensuring(clearLease));607 }, Effect.ensuring(clearLease));
835608
836 const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) {609 const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) {
...@@ -895,14 +668,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -895,14 +668,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
895 }668 }
896669
897 const attempt = failureCount + 1;670 const attempt = failureCount + 1;
671 const nextGeneration = generation + 1;
898 const outcome: AttemptOutcome = yield* Effect.scoped(672 const outcome: AttemptOutcome = yield* Effect.scoped(
899 runAttempt(attempt, generation, latestFailure, pendingRetry),673 runAttempt(attempt, nextGeneration, latestFailure, pendingRetry),
900 );674 );
901 // Consumed on every iteration so a stale marker can never leak into a675 // Consumed on every iteration so a stale marker can never leak into a
902 // later, unrelated failure.676 // later, unrelated failure.
903 const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false);677 const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false);
904 generation = outcome.generation;
905 if (outcome.established) {678 if (outcome.established) {
679 generation = nextGeneration;
906 if (outcome.stable) {680 if (outcome.stable) {
907 resetRetryLadder();681 resetRetryLadder();
908 latestFailure = null;682 latestFailure = null;
packages/client-runtime/src/platform/capabilities.ts+6
...@@ -21,9 +21,15 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment {...@@ -21,9 +21,15 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment {
21 readonly label: string;21 readonly label: string;
22}22}
2323
24/** Stable for one signed-in session, including same-account token refreshes. */
25export interface CloudSessionIdentity {
26 readonly accountId: string;
27}
28
24export class CloudSession extends Context.Service<29export class CloudSession extends Context.Service<
25 CloudSession,30 CloudSession,
26 {31 {
32 readonly identity: Effect.Effect<Option.Option<CloudSessionIdentity>>;
27 readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>;33 readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>;
28 }34 }
29>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {}35>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {}
packages/client-runtime/src/platform/storageDocument.test.ts+86
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Schema from "effect/Schema";
34
4import * as TokenStore from "../authorization/tokenStore.ts";5import * as TokenStore from "../authorization/tokenStore.ts";
5import {6import {
...@@ -16,13 +17,19 @@ import {...@@ -16,13 +17,19 @@ import {
16 SshConnectionTarget,17 SshConnectionTarget,
17} from "../connection/model.ts";18} from "../connection/model.ts";
18import {19import {
20 ConnectionCatalogDocument,
19 EMPTY_CONNECTION_CATALOG_DOCUMENT,21 EMPTY_CONNECTION_CATALOG_DOCUMENT,
22 putRemoteDpopTokenInCatalog,
20 registerConnectionInCatalog,23 registerConnectionInCatalog,
21 removeConnectionFromCatalog,24 removeConnectionFromCatalog,
22} from "./storageDocument.ts";25} from "./storageDocument.ts";
2326
24const ENVIRONMENT_ID = EnvironmentId.make("environment-1");27const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
2528
29const RELAY_TARGET = new RelayConnectionTarget({
30 environmentId: ENVIRONMENT_ID,
31 label: "Remote",
32});
26const BEARER_TARGET = new BearerConnectionTarget({33const BEARER_TARGET = new BearerConnectionTarget({
27 environmentId: ENVIRONMENT_ID,34 environmentId: ENVIRONMENT_ID,
28 label: "Remote",35 label: "Remote",
...@@ -52,6 +59,26 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({...@@ -52,6 +59,26 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({
52});59});
5360
54describe("ConnectionCatalogDocument", () => {61describe("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
55 it("registers a bearer connection as one catalog mutation", () => {82 it("registers a bearer connection as one catalog mutation", () => {
56 const document = registerConnectionInCatalog(83 const document = registerConnectionInCatalog(
57 EMPTY_CONNECTION_CATALOG_DOCUMENT,84 EMPTY_CONNECTION_CATALOG_DOCUMENT,
...@@ -117,6 +144,65 @@ describe("ConnectionCatalogDocument", () => {...@@ -117,6 +144,65 @@ describe("ConnectionCatalogDocument", () => {
117 );144 );
118 });145 });
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
120 it("persists the normalized SSH profile beside its target", () => {206 it("persists the normalized SSH profile beside its target", () => {
121 const target = new SshConnectionTarget({207 const target = new SshConnectionTarget({
122 environmentId: ENVIRONMENT_ID,208 environmentId: ENVIRONMENT_ID,
packages/client-runtime/src/platform/storageDocument.ts+21
...@@ -139,3 +139,24 @@ export function removeConnectionFromCatalog(...@@ -139,3 +139,24 @@ export function removeConnectionFromCatalog(
139): ConnectionCatalogDocument {139): ConnectionCatalogDocument {
140 return removeConnectionMetadata(document, target, true);140 return removeConnectionMetadata(document, target, true);
141}141}
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* () {...@@ -62,6 +62,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
62 const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null);62 const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null);
63 const secondListCall = yield* Deferred.make<void>();63 const secondListCall = yield* Deferred.make<void>();
64 const clerkToken = yield* Ref.make<string | null>("clerk-token");64 const clerkToken = yield* Ref.make<string | null>("clerk-token");
65 const sessionIdentity = { accountId: "account-1" };
65 const wakeups = yield* SubscriptionRef.make<{66 const wakeups = yield* SubscriptionRef.make<{
66 readonly sequence: number;67 readonly sequence: number;
67 readonly reason: "application-active" | "credentials-changed";68 readonly reason: "application-active" | "credentials-changed";
...@@ -127,6 +128,11 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {...@@ -127,6 +128,11 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
127 Layer.succeed(128 Layer.succeed(
128 ClientCapabilities.CloudSession,129 ClientCapabilities.CloudSession,
129 ClientCapabilities.CloudSession.of({130 ClientCapabilities.CloudSession.of({
131 identity: Ref.get(clerkToken).pipe(
132 Effect.map((token) =>
133 token === null ? Option.none() : Option.some(sessionIdentity),
134 ),
135 ),
130 clerkToken: Ref.get(clerkToken).pipe(136 clerkToken: Ref.get(clerkToken).pipe(
131 Effect.flatMap((token) =>137 Effect.flatMap((token) =>
132 token === null138 token === null
...@@ -283,6 +289,7 @@ describe("RelayEnvironmentDiscovery", () => {...@@ -283,6 +289,7 @@ describe("RelayEnvironmentDiscovery", () => {
283 Layer.mergeAll(289 Layer.mergeAll(
284 Layer.succeed(ManagedRelay.ManagedRelayClient, client),290 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
285 Layer.succeed(ClientCapabilities.CloudSession, {291 Layer.succeed(ClientCapabilities.CloudSession, {
292 identity: Effect.succeed(Option.some({ accountId: "account-1" })),
286 clerkToken: Effect.succeed("clerk-token"),293 clerkToken: Effect.succeed("clerk-token"),
287 }),294 }),
288 Layer.succeed(Connectivity.Connectivity, {295 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 @@...@@ -1,10 +1,18 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";2import * 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";
6import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";8import 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
9export interface EnvironmentHttpAuthHeaders {17export interface EnvironmentHttpAuthHeaders {
10 readonly authorization?: string;18 readonly authorization?: string;
...@@ -28,8 +36,7 @@ export const withEnvironmentCredentials = <A, E, R>(...@@ -28,8 +36,7 @@ export const withEnvironmentCredentials = <A, E, R>(
28 : request;36 : request;
2937
30/**38/**
31 * Build the authorization headers for an authenticated environment HTTP39 * Build request-bound headers from the current environment credential:
32 * request, matching the credential the connection was prepared with:
33 * - primary/local connections carry no credential,40 * - primary/local connections carry no credential,
34 * - bearer connections send a static `Bearer` token,41 * - bearer connections send a static `Bearer` token,
35 * - relay connections send a `DPoP` access token with a freshly signed proof42 * - relay connections send a `DPoP` access token with a freshly signed proof
...@@ -71,3 +78,106 @@ export const buildEnvironmentAuthHeaders = (...@@ -71,3 +78,106 @@ export const buildEnvironmentAuthHeaders = (
71 );78 );
72 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };79 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };
73 });80 });
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";...@@ -10,15 +10,14 @@ import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import { HttpClient } from "effect/unstable/http";11import { HttpClient } from "effect/unstable/http";
1212
13import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
13import type { PreparedConnection } from "../connection/model.ts";14import type { PreparedConnection } from "../connection/model.ts";
14import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";15import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
15import {16import {
16 executeEnvironmentHttpRequest,
17 makeEnvironmentHttpApiClient,
18 makeEnvironmentHttpApiUrlBuilder,17 makeEnvironmentHttpApiUrlBuilder,
19 type RemoteEnvironmentRequestError,18 type RemoteEnvironmentRequestError,
20} from "../rpc/http.ts";19} from "../rpc/http.ts";
21import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";20import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
2221
23const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;22const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;
2423
...@@ -46,26 +45,16 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(...@@ -46,26 +45,16 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
46 readonly prepared: PreparedConnection;45 readonly prepared: PreparedConnection;
47 readonly diff: PullRequestDiffInput;46 readonly diff: PullRequestDiffInput;
48 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;47 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
48 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
49 readonly timeoutMs?: number;49 readonly timeoutMs?: number;
50}) {50}) {
51 const requestUrl = makeEnvironmentHttpApiUrlBuilder(51 return yield* executeAuthenticatedEnvironmentHttpRequest({
52 input.prepared.httpBaseUrl,52 ...input,
53 ).pullRequests.diff();53 method: "POST",
54 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);54 url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(),
55 const headers = yield* buildEnvironmentAuthHeaders(55 timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS,
56 input.prepared.httpAuthorization,56 request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }),
57 "POST",57 }).pipe(
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(
69 Effect.mapError((error) =>58 Effect.mapError((error) =>
70 error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential"59 error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential"
71 ? new PullRequestDiffCredentialRejectedError({60 ? new PullRequestDiffCredentialRejectedError({
...@@ -98,11 +87,15 @@ export const pullRequestDiffLoaderLayer: Layer.Layer<...@@ -98,11 +87,15 @@ export const pullRequestDiffLoaderLayer: Layer.Layer<
98 Effect.gen(function* () {87 Effect.gen(function* () {
99 const httpClient = yield* HttpClient.HttpClient;88 const httpClient = yield* HttpClient.HttpClient;
100 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);89 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
90 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
101 return PullRequestDiffLoader.of({91 return PullRequestDiffLoader.of({
102 load: (prepared, input) =>92 load: (prepared, input) =>
103 fetchEnvironmentPullRequestDiff({ prepared, diff: input, signer }).pipe(93 fetchEnvironmentPullRequestDiff({
104 Effect.provideService(HttpClient.HttpClient, httpClient),94 prepared,
105 ),95 diff: input,
96 signer,
97 remoteAuthorization,
98 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
106 });99 });
107 }),100 }),
108);101);
packages/client-runtime/src/state/session.ts+16-18
...@@ -6,14 +6,14 @@ import * as SubscriptionRef from "effect/SubscriptionRef";...@@ -6,14 +6,14 @@ import * as SubscriptionRef from "effect/SubscriptionRef";
6import type { HttpClient } from "effect/unstable/http";6import type { HttpClient } from "effect/unstable/http";
7import { AsyncResult, Atom } from "effect/unstable/reactivity";7import { AsyncResult, Atom } from "effect/unstable/reactivity";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
9import { EnvironmentRegistry } from "../connection/registry.ts";10import { EnvironmentRegistry } from "../connection/registry.ts";
10import type { PreparedConnection } from "../connection/model.ts";11import type { PreparedConnection } from "../connection/model.ts";
11import { EnvironmentSupervisor } from "../connection/supervisor.ts";12import { EnvironmentSupervisor } from "../connection/supervisor.ts";
12import { environmentEndpointUrl } from "../environment/endpoint.ts";13import { environmentEndpointUrl } from "../environment/endpoint.ts";
13import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";14import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
14import { safeErrorLogAttributes } from "../errors/safeLog.ts";15import { safeErrorLogAttributes } from "../errors/safeLog.ts";
15import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";16import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
16import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
17import { followStreamInEnvironment } from "./runtime.ts";17import { followStreamInEnvironment } from "./runtime.ts";
1818
19function initialConfigOption<E>(19function initialConfigOption<E>(
...@@ -36,29 +36,26 @@ const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;...@@ -36,29 +36,26 @@ const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
3636
37/**37/**
38 * Read the granted scopes of this client's session on one environment via its38 * Read the granted scopes of this client's session on one environment via its
39 * `/api/auth/session` endpoint, authenticated with whatever credential the39 * `/api/auth/session` endpoint, using the connection's authentication method
40 * connection was prepared with (cookie, bearer, or DPoP).40 * and refreshing relay credentials when needed.
41 */41 */
42export const fetchEnvironmentSessionState = Effect.fn(42export const fetchEnvironmentSessionState = Effect.fn(
43 "clientRuntime.state.fetchEnvironmentSessionState",43 "clientRuntime.state.fetchEnvironmentSessionState",
44)(function* (input: {44)(function* (input: {
45 readonly prepared: PreparedConnection;45 readonly prepared: PreparedConnection;
46 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;46 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
47 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
47 readonly timeoutMs?: number;48 readonly timeoutMs?: number;
48}) {49}) {
49 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session");50 return yield* executeAuthenticatedEnvironmentHttpRequest({
50 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);51 ...input,
51 const headers = yield* buildEnvironmentAuthHeaders(52 method: "GET",
52 input.prepared.httpAuthorization,53 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"),
53 "GET",54 timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
54 requestUrl,55 request: ({ client, headers }) => client.auth.session({ headers }),
55 input.signer,56 // This endpoint returns 200 with authenticated:false for expired credentials.
56 );57 isUnauthorizedResponse: (response) => !response.authenticated,
57 return yield* executeEnvironmentHttpRequest(58 });
58 requestUrl,
59 input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
60 withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })),
61 );
62});59});
6360
64export function createEnvironmentSessionAtoms<R, E>(61export function createEnvironmentSessionAtoms<R, E>(
...@@ -134,7 +131,8 @@ export function createEnvironmentSessionAtoms<R, E>(...@@ -134,7 +131,8 @@ export function createEnvironmentSessionAtoms<R, E>(
134 }131 }
135 return Effect.gen(function* () {132 return Effect.gen(function* () {
136 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);133 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 });
138 });136 });
139 })137 })
140 .pipe(138 .pipe(
packages/client-runtime/src/state/shellSnapshotHttp.ts+12-19
...@@ -6,11 +6,11 @@ import * as Layer from "effect/Layer";...@@ -6,11 +6,11 @@ import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
9import type { PreparedConnection } from "../connection/model.ts";10import type { PreparedConnection } from "../connection/model.ts";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";11import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";12import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";13import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
13import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
1414
15// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket15// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
16// fallback for long. The cached shell renders while this runs.16// fallback for long. The cached shell renders while this runs.
...@@ -27,24 +27,16 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(...@@ -27,24 +27,16 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
27)(function* (input: {27)(function* (input: {
28 readonly prepared: PreparedConnection;28 readonly prepared: PreparedConnection;
29 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;29 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
30 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
30 readonly timeoutMs?: number;31 readonly timeoutMs?: number;
31}) {32}) {
32 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/orchestration/shell");33 return yield* executeAuthenticatedEnvironmentHttpRequest({
33 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);34 ...input,
34 const headers = yield* buildEnvironmentAuthHeaders(35 method: "GET",
35 input.prepared.httpAuthorization,36 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"),
36 "GET",37 timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
37 requestUrl,38 request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }),
38 input.signer,39 });
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 );
48});40});
4941
50/**42/**
...@@ -73,9 +65,10 @@ export const shellSnapshotLoaderLayer: Layer.Layer<...@@ -73,9 +65,10 @@ export const shellSnapshotLoaderLayer: Layer.Layer<
73 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP65 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP
74 // connections, so the loader must not hard-require it.66 // connections, so the loader must not hard-require it.
75 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);67 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
68 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
76 return ShellSnapshotLoader.of({69 return ShellSnapshotLoader.of({
77 load: (prepared: PreparedConnection) =>70 load: (prepared: PreparedConnection) =>
78 fetchEnvironmentShellSnapshot({ prepared, signer }).pipe(71 fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe(
79 Effect.map(Option.some<OrchestrationShellSnapshot>),72 Effect.map(Option.some<OrchestrationShellSnapshot>),
80 Effect.provideService(HttpClient.HttpClient, httpClient),73 Effect.provideService(HttpClient.HttpClient, httpClient),
81 Effect.catchCause((cause) =>74 Effect.catchCause((cause) =>
packages/client-runtime/src/state/threadSnapshotHttp.ts+14-24
...@@ -6,15 +6,12 @@ import * as Layer from "effect/Layer";...@@ -6,15 +6,12 @@ import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
9import type { PreparedConnection } from "../connection/model.ts";10import type { PreparedConnection } from "../connection/model.ts";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";11import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";12import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import {13import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
13 executeEnvironmentHttpRequest,14import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
14 makeEnvironmentHttpApiClient,
15 type RemoteEnvironmentRequestError,
16} from "../rpc/http.ts";
17import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
1815
19// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket16// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
20// fallback for long. The cached thread renders while this runs, so the wait only17// fallback for long. The cached thread renders while this runs, so the wait only
...@@ -42,25 +39,17 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(...@@ -42,25 +39,17 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
42 readonly prepared: PreparedConnection;39 readonly prepared: PreparedConnection;
43 readonly threadId: ThreadId;40 readonly threadId: ThreadId;
44 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;41 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
42 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
45 readonly timeoutMs?: number;43 readonly timeoutMs?: number;
46 readonly window?: ThreadSnapshotWindow;44 readonly window?: ThreadSnapshotWindow;
47}) {45}) {
48 const requestUrl = environmentEndpointUrl(46 return yield* executeAuthenticatedEnvironmentHttpRequest({
49 input.prepared.httpBaseUrl,47 ...input,
50 `/api/orchestration/threads/${input.threadId}`,48 method: "GET",
51 );49 url: (httpBaseUrl) =>
52 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);50 environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`),
53 const headers = yield* buildEnvironmentAuthHeaders(51 timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
54 input.prepared.httpAuthorization,52 request: ({ client, headers }) =>
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,
64 client.orchestration.threadSnapshot({53 client.orchestration.threadSnapshot({
65 params: { threadId: input.threadId },54 params: { threadId: input.threadId },
66 payload: {55 payload: {
...@@ -71,8 +60,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(...@@ -71,8 +60,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn(
71 },60 },
72 headers,61 headers,
73 }),62 }),
74 ),63 });
75 );
76});64});
7765
78export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;66export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
...@@ -106,12 +94,14 @@ export const threadSnapshotLoaderLayer: Layer.Layer<...@@ -106,12 +94,14 @@ export const threadSnapshotLoaderLayer: Layer.Layer<
106 // connections, so the loader must not hard-require it (bearer/primary94 // connections, so the loader must not hard-require it (bearer/primary
107 // connections work without one).95 // connections work without one).
108 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);96 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
97 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
109 return ThreadSnapshotLoader.of({98 return ThreadSnapshotLoader.of({
110 load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) =>99 load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) =>
111 fetchEnvironmentThreadSnapshot({100 fetchEnvironmentThreadSnapshot({
112 prepared,101 prepared,
113 threadId,102 threadId,
114 signer,103 signer,
104 remoteAuthorization,
115 ...(window !== undefined ? { window } : {}),105 ...(window !== undefined ? { window } : {}),
116 }).pipe(106 }).pipe(
117 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),107 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),