| author | |
| committer | |
| log | 363cde4114c2186c496ed97cafba4a8737ea5585 |
| tree | 7bb4f9e39a2226e0a89b53c34d75bbf68893f8bf |
| parent | ac90950f1ac315bcef5f92b53c5e0d6579daeefd |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 2086 insertions(+), 825 deletions(-)
apps/mobile/src/connection/platform.ts+3| ... | ... | @@ -118,6 +118,9 @@ const capabilitiesLayer = Layer.effectContext( |
| 118 | 118 | return Context.make( |
| 119 | 119 | CloudSession, |
| 120 | 120 | CloudSession.of({ |
| 121 | identity: Effect.sync(() => | |
| 122 | Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)), | |
| 123 | ), | |
| 121 | 124 | clerkToken: Effect.gen(function* () { |
| 122 | 125 | const session = appAtomRegistry.get(managedRelaySessionAtom); |
| 123 | 126 | if (session === null) { |
apps/mobile/src/connection/runtime.ts+2-4| ... | ... | @@ -30,10 +30,8 @@ type ConnectionLayerSource = |
| 30 | 30 | | typeof mobileBackgroundActivityObserverLayer |
| 31 | 31 | | typeof mobileBackgroundActivityReporterLayer; |
| 32 | 32 | |
| 33 | const providedClientConnectionLayer = Layer.merge( | |
| 34 | Connection.layerWithOptions({ usageLimitSources: true }), | |
| 35 | snapshotLoaderLayer, | |
| 36 | ).pipe( | |
| 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( | |
| 34 | Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), | |
| 37 | 35 | Layer.provideMerge( |
| 38 | 36 | Layer.mergeAll( |
| 39 | 37 | runtimeContextLayer, |
apps/mobile/src/connection/storage.ts+2-9| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | ConnectionPersistenceError, |
| 3 | 3 | ConnectionRegistrationStore, |
| 4 | 4 | ConnectionTargetStore, |
| 5 | putRemoteDpopTokenInCatalog, | |
| 5 | 6 | registerConnectionInCatalog, |
| 6 | 7 | removeConnectionFromCatalog, |
| 7 | 8 | removeCatalogValue, |
| ... | ... | @@ -109,15 +110,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 109 | 110 | ), |
| 110 | 111 | ), |
| 111 | 112 | ), |
| 112 | put: (token) => | |
| 113 | catalog.update((document) => ({ | |
| 114 | ...document, | |
| 115 | remoteDpopTokens: replaceCatalogValue( | |
| 116 | document.remoteDpopTokens, | |
| 117 | (value) => value.environmentId, | |
| 118 | token, | |
| 119 | ), | |
| 120 | })), | |
| 113 | put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)), | |
| 121 | 114 | remove: (environmentId) => |
| 122 | 115 | catalog.update((document) => ({ |
| 123 | 116 | ...document, |
apps/server/src/auth/SessionStore.test.ts+128| ... | ... | @@ -5,6 +5,8 @@ import * as Duration from "effect/Duration"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Queue from "effect/Queue"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 8 | 10 | import * as TestClock from "effect/testing/TestClock"; |
| 9 | 11 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | 12 | |
| ... | ... | @@ -44,6 +46,14 @@ const makeSessionStoreLayer = ( |
| 44 | 46 | Layer.provide(makeServerConfigLayer(overrides)), |
| 45 | 47 | ); |
| 46 | 48 | |
| 49 | const 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 | ||
| 47 | 57 | const repositoryFailure = new PersistenceSqlError({ |
| 48 | 58 | operation: "AuthSessionRepository.getById:query", |
| 49 | 59 | detail: "sqlite is unavailable", |
| ... | ... | @@ -420,6 +430,124 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 420 | 430 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); |
| 421 | 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 | 551 | it.effect("records client connection metadata without clearing prior values", () => |
| 424 | 552 | Effect.gen(function* () { |
| 425 | 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 | 482 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 483 | 483 | const authSessions = yield* AuthSessions.AuthSessionRepository; |
| 484 | 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 | 486 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); |
| 487 | 487 | const cookieInput = { |
| 488 | 488 | mode: serverConfig.mode, |
| ... | ... | @@ -515,6 +515,11 @@ export const make = Effect.gen(function* () { |
| 515 | 515 | } |
| 516 | 516 | |
| 517 | 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 | 523 | return Option.some( |
| 519 | 524 | toAuthClientSession({ |
| 520 | 525 | sessionId: row.value.sessionId, |
| ... | ... | @@ -525,7 +530,7 @@ export const make = Effect.gen(function* () { |
| 525 | 530 | issuedAt: row.value.issuedAt, |
| 526 | 531 | expiresAt: row.value.expiresAt, |
| 527 | 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 | 604 | }).pipe( |
| 600 | 605 | Effect.flatMap(() => loadActiveSession(sessionId)), |
| 601 | 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 | 609 | Effect.catchCause((cause) => |
| 605 | 610 | Effect.logError("Failed to publish disconnected-session auth update.").pipe( |
| ... | ... | @@ -882,7 +887,10 @@ export const make = Effect.gen(function* () { |
| 882 | 887 | function* () { |
| 883 | 888 | const now = yield* DateTime.now; |
| 884 | 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 | }); | |
| 886 | 894 | |
| 887 | 895 | return rows.map((row) => |
| 888 | 896 | toAuthClientSession({ |
apps/server/src/persistence/AuthSessions.ts+3-2| ... | ... | @@ -69,6 +69,7 @@ export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; |
| 69 | 69 | |
| 70 | 70 | export const ListActiveAuthSessionsInput = Schema.Struct({ |
| 71 | 71 | now: Schema.DateTimeUtcFromString, |
| 72 | connectedSessionIds: Schema.optionalKey(Schema.Array(AuthSessionId)), | |
| 72 | 73 | }); |
| 73 | 74 | export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; |
| 74 | 75 | |
| ... | ... | @@ -282,7 +283,7 @@ export const make = Effect.gen(function* () { |
| 282 | 283 | const listActiveSessionRows = SqlSchema.findAll({ |
| 283 | 284 | Request: ListActiveAuthSessionsInput, |
| 284 | 285 | Result: AuthSessionRawDbRow, |
| 285 | execute: ({ now }) => | |
| 286 | execute: ({ now, connectedSessionIds = [] }) => | |
| 286 | 287 | sql` |
| 287 | 288 | SELECT |
| 288 | 289 | session_id AS "sessionId", |
| ... | ... | @@ -301,7 +302,7 @@ export const make = Effect.gen(function* () { |
| 301 | 302 | revoked_at AS "revokedAt" |
| 302 | 303 | FROM auth_sessions |
| 303 | 304 | WHERE revoked_at IS NULL |
| 304 | AND expires_at > ${now} | |
| 305 | AND (expires_at > ${now} OR ${sql.in("session_id", connectedSessionIds)}) | |
| 305 | 306 | 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 | 182 | scopes: AuthStandardClientScopes, |
| 183 | 183 | }); |
| 184 | 184 | const cloudSession = CloudSession.of({ |
| 185 | identity: Effect.sync(() => | |
| 186 | Option.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)), | |
| 187 | ), | |
| 185 | 188 | clerkToken: Effect.gen(function* () { |
| 186 | 189 | const session = appAtomRegistry.get(managedRelaySessionAtom); |
| 187 | 190 | if (session === null) { |
apps/web/src/connection/runtime.ts+4-4| ... | ... | @@ -30,10 +30,10 @@ type ConnectionLayerSource = |
| 30 | 30 | | typeof backgroundActivityObserverLayer |
| 31 | 31 | | typeof backgroundActivityReporterLayer; |
| 32 | 32 | |
| 33 | const providedClientConnectionLayer = Layer.merge( | |
| 34 | Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), | |
| 35 | snapshotLoaderLayer, | |
| 36 | ).pipe( | |
| 33 | const providedClientConnectionLayer = snapshotLoaderLayer.pipe( | |
| 34 | Layer.provideMerge( | |
| 35 | Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), | |
| 36 | ), | |
| 37 | 37 | Layer.provideMerge( |
| 38 | 38 | Layer.mergeAll( |
| 39 | 39 | runtimeContextLayer, |
apps/web/src/connection/storage.ts+2-9| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ConnectionTargetStore, |
| 7 | 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 8 | 8 | EnvironmentCacheStore, |
| 9 | putRemoteDpopTokenInCatalog, | |
| 9 | 10 | registerConnectionInCatalog, |
| 10 | 11 | removeCatalogValue, |
| 11 | 12 | removeConnectionFromCatalog, |
| ... | ... | @@ -446,15 +447,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 446 | 447 | ), |
| 447 | 448 | ), |
| 448 | 449 | ), |
| 449 | put: (token) => | |
| 450 | catalog.update((document) => ({ | |
| 451 | ...document, | |
| 452 | remoteDpopTokens: replaceCatalogValue( | |
| 453 | document.remoteDpopTokens, | |
| 454 | (value) => value.environmentId, | |
| 455 | token, | |
| 456 | ), | |
| 457 | })), | |
| 450 | put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)), | |
| 458 | 451 | remove: (environmentId) => |
| 459 | 452 | catalog.update((document) => ({ |
| 460 | 453 | ...document, |
docs/internals/connection-runtime.md+16-2| ... | ... | @@ -6,10 +6,10 @@ signals, and application lifecycle events. React views consume the runtime. |
| 6 | 6 | Keeping retries and session lifetime here prevents competing reconnect loops when |
| 7 | 7 | several views need the same environment. |
| 8 | 8 | |
| 9 | ## One retry owner | |
| 9 | ## One transport retry owner | |
| 10 | 10 | |
| 11 | 11 | The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns |
| 12 | retry policy; resolving an endpoint and opening an RPC session are single | |
| 12 | transport retry policy; resolving an endpoint and opening an RPC session are single | |
| 13 | 13 | attempts. Transient failures retry with capped backoff. Offline states and |
| 14 | 14 | authentication failures wait for a wakeup instead of spending attempts on |
| 15 | 15 | unchanged conditions. |
| ... | ... | @@ -27,6 +27,20 @@ and cached data. Explicit removal closes the scope and clears credentials, |
| 27 | 27 | projections, and platform-owned state such as drafts. Cloud-account changes apply |
| 28 | 28 | to relay registrations; they must not discard directly paired environments. |
| 29 | 29 | |
| 30 | ## HTTP authorization | |
| 31 | ||
| 32 | RPC sessions authenticate at socket upgrade, while HTTP requests need current | |
| 33 | credentials from the | |
| 34 | [authorization service](../../packages/client-runtime/src/authorization/service.ts). | |
| 35 | Replacing a healthy socket for HTTP renewal would interrupt conversations and | |
| 36 | change the transport generation without a transport failure. Credential expiry | |
| 37 | does not close the socket, and refresh failure belongs to the HTTP operation. | |
| 38 | ||
| 39 | Session listings must retain unrevoked connected sessions after credential expiry | |
| 40 | so an open connection does not disappear from connection management. This does | |
| 41 | not extend the credential's lifetime. New HTTP requests and socket upgrades still | |
| 42 | require valid credentials. | |
| 43 | ||
| 30 | 44 | ## Transport health and data freshness are separate |
| 31 | 45 | |
| 32 | 46 | A 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 | 24 | environment. Over SSH, the CLI prints a browser link and accepts the returned |
| 25 | 25 | authorization code, so you do not need to forward an OAuth callback port. |
| 26 | 26 | |
| 27 | T3 Connect renews access credentials when needed without disconnecting a healthy | |
| 28 | connection. Pull request diffs and provider settings keep working after the | |
| 29 | previous credential expires. A failed renewal affects that request; it does not | |
| 30 | disconnect an otherwise healthy conversation. | |
| 31 | ||
| 27 | 32 | ## Pair over a LAN or private network |
| 28 | 33 | |
| 29 | 34 | Use 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 | 132 | pairings; revoke a device's session to remove its existing access. Command-line |
| 128 | 133 | management is available through `npx t3 auth --help`. |
| 129 | 134 | |
| 135 | A session with an open connection stays listed after its access credential | |
| 136 | expires. | |
| 137 | ||
| 130 | 138 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 131 | 139 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 132 | 140 | revokes 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 | 1 | export * from "./remote.ts"; |
| 2 | 2 | export { |
| 3 | 3 | type AuthorizedRemoteEnvironment, |
| 4 | type RelayEnvironmentAuthorization, | |
| 4 | type AuthorizedRemoteHttpEnvironment, | |
| 5 | 5 | RemoteEnvironmentAuthorization, |
| 6 | 6 | } from "./service.ts"; |
| 7 | 7 | export * as TokenStore from "./tokenStore.ts"; |
packages/client-runtime/src/authorization/layer.test.ts+524-20| ... | ... | @@ -1,8 +1,16 @@ |
| 1 | 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | |
| 3 | RelayEnvironmentConnectScope, | |
| 4 | type RelayEnvironmentConnectResponse, | |
| 5 | } from "@t3tools/contracts/relay"; | |
| 2 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Deferred from "effect/Deferred"; | |
| 3 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | |
| 4 | 11 | import * as Layer from "effect/Layer"; |
| 5 | 12 | import * as Option from "effect/Option"; |
| 13 | import * as Queue from "effect/Queue"; | |
| 6 | 14 | import * as Ref from "effect/Ref"; |
| 7 | 15 | import * as TestClock from "effect/testing/TestClock"; |
| 8 | 16 | |
| ... | ... | @@ -31,10 +39,11 @@ const DESCRIPTOR = { |
| 31 | 39 | repositoryIdentity: true, |
| 32 | 40 | }, |
| 33 | 41 | }; |
| 34 | const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = { | |
| 42 | const BOOTSTRAP: RelayEnvironmentConnectResponse = { | |
| 35 | 43 | environmentId: ENVIRONMENT_ID, |
| 36 | 44 | endpoint: ENDPOINT, |
| 37 | 45 | credential: "relay-bootstrap", |
| 46 | expiresAt: "2026-06-06T01:00:00.000Z", | |
| 38 | 47 | }; |
| 39 | 48 | |
| 40 | 49 | function recordedFetch(responses: ReadonlyArray<Response>) { |
| ... | ... | @@ -76,9 +85,31 @@ const authInvalid = () => |
| 76 | 85 | { status: 401 }, |
| 77 | 86 | ); |
| 78 | 87 | |
| 88 | const 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 | ||
| 79 | 106 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { |
| 80 | 107 | readonly initialToken?: TokenStore.RemoteDpopAccessToken; |
| 81 | 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 | 114 | const tokens = yield* Ref.make( |
| 84 | 115 | new Map( |
| ... | ... | @@ -88,6 +119,14 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 88 | 119 | ), |
| 89 | 120 | ); |
| 90 | 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 | 130 | const proofInputs = yield* Ref.make< |
| 92 | 131 | ReadonlyArray<{ |
| 93 | 132 | readonly method: string; |
| ... | ... | @@ -101,13 +140,18 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 101 | 140 | get: (environmentId) => |
| 102 | 141 | Ref.get(tokens).pipe( |
| 103 | 142 | Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), |
| 143 | Effect.tap(() => Queue.offer(tokenReads, environmentId)), | |
| 104 | 144 | ), |
| 105 | 145 | put: (token) => |
| 106 | Ref.update(tokens, (current) => { | |
| 107 | const next = new Map(current); | |
| 108 | next.set(token.environmentId, token); | |
| 109 | return next; | |
| 110 | }), | |
| 146 | (input.beforePut ?? Effect.void).pipe( | |
| 147 | Effect.andThen( | |
| 148 | Ref.update(tokens, (current) => { | |
| 149 | const next = new Map(current); | |
| 150 | next.set(token.environmentId, token); | |
| 151 | return next; | |
| 152 | }), | |
| 153 | ), | |
| 154 | ), | |
| 111 | 155 | remove: (environmentId) => |
| 112 | 156 | Ref.update(tokens, (current) => { |
| 113 | 157 | const next = new Map(current); |
| ... | ... | @@ -116,17 +160,47 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 116 | 160 | }), |
| 117 | 161 | }); |
| 118 | 162 | const signer = ManagedRelay.ManagedRelayDpopSigner.of({ |
| 119 | thumbprint: Effect.succeed("thumbprint-1"), | |
| 163 | thumbprint: Ref.get(thumbprint), | |
| 120 | 164 | createProof: (proofInput) => |
| 121 | 165 | Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( |
| 122 | 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 | 191 | const layer = RemoteEnvironmentAuthorization.layer.pipe( |
| 126 | 192 | Layer.provide( |
| 127 | 193 | Layer.mergeAll( |
| 128 | 194 | remoteHttpClientLayer(fetch.fetchFn), |
| 129 | 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 | 204 | Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), |
| 131 | 205 | Layer.succeed( |
| 132 | 206 | ClientCapabilities.ClientPresentation, |
| ... | ... | @@ -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 | 219 | return { |
| 150 | 220 | layer, |
| 151 | 221 | tokens, |
| 152 | 222 | bootstrapCalls, |
| 153 | 223 | proofInputs, |
| 154 | 224 | fetch, |
| 155 | obtainBootstrap, | |
| 225 | relayInputs, | |
| 226 | session, | |
| 227 | thumbprint, | |
| 228 | tokenReads, | |
| 156 | 229 | }; |
| 157 | 230 | }); |
| 158 | 231 | |
| ... | ... | @@ -234,10 +307,11 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 234 | 307 | }), |
| 235 | 308 | ); |
| 236 | 309 | |
| 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 | 311 | Effect.gen(function* () { |
| 239 | 312 | const cached = new TokenStore.RemoteDpopAccessToken({ |
| 240 | 313 | environmentId: ENVIRONMENT_ID, |
| 314 | accountId: "account-1", | |
| 241 | 315 | label: DESCRIPTOR.label, |
| 242 | 316 | endpoint: ENDPOINT, |
| 243 | 317 | accessToken: "cached-access-token", |
| ... | ... | @@ -253,7 +327,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 253 | 327 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 254 | 328 | return yield* remote.authorizeDpop({ |
| 255 | 329 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 256 | obtainBootstrap: harness.obtainBootstrap, | |
| 257 | 330 | }); |
| 258 | 331 | }).pipe(Effect.provide(harness.layer)); |
| 259 | 332 | |
| ... | ... | @@ -271,6 +344,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 271 | 344 | Effect.gen(function* () { |
| 272 | 345 | const expired = new TokenStore.RemoteDpopAccessToken({ |
| 273 | 346 | environmentId: ENVIRONMENT_ID, |
| 347 | accountId: "account-1", | |
| 274 | 348 | label: DESCRIPTOR.label, |
| 275 | 349 | endpoint: ENDPOINT, |
| 276 | 350 | accessToken: "expired-access-token", |
| ... | ... | @@ -290,7 +364,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 290 | 364 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 291 | 365 | return yield* remote.authorizeDpop({ |
| 292 | 366 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 293 | obtainBootstrap: harness.obtainBootstrap, | |
| 294 | 367 | }); |
| 295 | 368 | }).pipe(Effect.provide(harness.layer)); |
| 296 | 369 | |
| ... | ... | @@ -310,6 +383,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 310 | 383 | Effect.gen(function* () { |
| 311 | 384 | const cached = new TokenStore.RemoteDpopAccessToken({ |
| 312 | 385 | environmentId: ENVIRONMENT_ID, |
| 386 | accountId: "account-1", | |
| 313 | 387 | label: DESCRIPTOR.label, |
| 314 | 388 | endpoint: ENDPOINT, |
| 315 | 389 | accessToken: "invalid-access-token", |
| ... | ... | @@ -330,7 +404,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 330 | 404 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 331 | 405 | return yield* remote.authorizeDpop({ |
| 332 | 406 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 333 | obtainBootstrap: harness.obtainBootstrap, | |
| 334 | 407 | }); |
| 335 | 408 | }).pipe(Effect.provide(harness.layer)); |
| 336 | 409 | |
| ... | ... | @@ -355,7 +428,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 355 | 428 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 356 | 429 | return yield* remote.authorizeDpop({ |
| 357 | 430 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 358 | obtainBootstrap: harness.obtainBootstrap, | |
| 359 | 431 | }); |
| 360 | 432 | }).pipe(Effect.provide(harness.layer), Effect.flip); |
| 361 | 433 | |
| ... | ... | @@ -372,6 +444,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 372 | 444 | Effect.gen(function* () { |
| 373 | 445 | const cached = new TokenStore.RemoteDpopAccessToken({ |
| 374 | 446 | environmentId: ENVIRONMENT_ID, |
| 447 | accountId: "account-1", | |
| 375 | 448 | label: DESCRIPTOR.label, |
| 376 | 449 | endpoint: ENDPOINT, |
| 377 | 450 | accessToken: "cached-access-token", |
| ... | ... | @@ -392,7 +465,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 392 | 465 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 393 | 466 | return yield* remote.authorizeDpop({ |
| 394 | 467 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 395 | obtainBootstrap: harness.obtainBootstrap, | |
| 396 | 468 | }); |
| 397 | 469 | }).pipe(Effect.provide(harness.layer)); |
| 398 | 470 | |
| ... | ... | @@ -407,7 +479,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 407 | 479 | }), |
| 408 | 480 | ); |
| 409 | 481 | |
| 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 | 483 | Effect.gen(function* () { |
| 412 | 484 | const harness = yield* makeHarness({ |
| 413 | 485 | responses: [ |
| ... | ... | @@ -421,7 +493,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 421 | 493 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 422 | 494 | return yield* remote.authorizeDpop({ |
| 423 | 495 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 424 | obtainBootstrap: harness.obtainBootstrap, | |
| 425 | 496 | }); |
| 426 | 497 | }).pipe(Effect.provide(harness.layer), Effect.flip); |
| 427 | 498 | |
| ... | ... | @@ -430,4 +501,437 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 430 | 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 | 3 | EnvironmentId, |
| 4 | 4 | type ExecutionEnvironmentDescriptor, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | |
| 6 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | |
| 7 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | |
| 7 | 8 | import { |
| 8 | 9 | exchangeRemoteDpopAccessToken, |
| 9 | 10 | type RemoteEnvironmentAuthError, |
| ... | ... | @@ -12,10 +13,15 @@ import { |
| 12 | 13 | } from "./remote.ts"; |
| 13 | 14 | import { |
| 14 | 15 | environmentMismatchError, |
| 16 | mapManagedRelayError, | |
| 15 | 17 | mapRemoteDpopEnvironmentError, |
| 16 | 18 | mapRemoteEnvironmentError, |
| 17 | 19 | } from "../connection/errors.ts"; |
| 18 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | |
| 20 | import { | |
| 21 | ConnectionBlockedError, | |
| 22 | ConnectionTransientError, | |
| 23 | type ConnectionAttemptError, | |
| 24 | } from "../connection/model.ts"; | |
| 19 | 25 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 20 | 26 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 21 | 27 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| ... | ... | @@ -24,10 +30,13 @@ import * as TokenStore from "./tokenStore.ts"; |
| 24 | 30 | import * as Clock from "effect/Clock"; |
| 25 | 31 | import * as Context from "effect/Context"; |
| 26 | 32 | import * as Effect from "effect/Effect"; |
| 33 | import * as Fiber from "effect/Fiber"; | |
| 27 | 34 | import * as Layer from "effect/Layer"; |
| 28 | 35 | import * as Option from "effect/Option"; |
| 29 | 36 | import * as Ref from "effect/Ref"; |
| 30 | 37 | import * as Result from "effect/Result"; |
| 38 | import * as Scope from "effect/Scope"; | |
| 39 | import * as Semaphore from "effect/Semaphore"; | |
| 31 | 40 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 32 | 41 | |
| 33 | 42 | import { |
| ... | ... | @@ -35,12 +44,6 @@ import { |
| 35 | 44 | type PreparedHttpAuthorization, |
| 36 | 45 | } from "../connection/model.ts"; |
| 37 | 46 | |
| 38 | export interface RelayEnvironmentAuthorization { | |
| 39 | readonly environmentId: EnvironmentId; | |
| 40 | readonly endpoint: RelayManagedEndpoint; | |
| 41 | readonly credential: string; | |
| 42 | } | |
| 43 | ||
| 44 | 47 | export interface AuthorizedRemoteEnvironment { |
| 45 | 48 | readonly environmentId: EnvironmentId; |
| 46 | 49 | readonly label: string; |
| ... | ... | @@ -49,6 +52,13 @@ export interface AuthorizedRemoteEnvironment { |
| 49 | 52 | readonly httpAuthorization: PreparedHttpAuthorization; |
| 50 | 53 | } |
| 51 | 54 | |
| 55 | export interface AuthorizedRemoteHttpEnvironment { | |
| 56 | readonly environmentId: EnvironmentId; | |
| 57 | readonly label: string; | |
| 58 | readonly httpBaseUrl: string; | |
| 59 | readonly httpAuthorization: Extract<PreparedHttpAuthorization, { readonly _tag: "Dpop" }>; | |
| 60 | } | |
| 61 | ||
| 52 | 62 | export class RemoteEnvironmentAuthorization extends Context.Service< |
| 53 | 63 | RemoteEnvironmentAuthorization, |
| 54 | 64 | { |
| ... | ... | @@ -61,16 +71,17 @@ export class RemoteEnvironmentAuthorization extends Context.Service< |
| 61 | 71 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; |
| 62 | 72 | readonly authorizeDpop: (input: { |
| 63 | 73 | readonly expectedEnvironmentId: EnvironmentId; |
| 64 | readonly obtainBootstrap: Effect.Effect< | |
| 65 | RelayEnvironmentAuthorization, | |
| 66 | ConnectionAttemptError | |
| 67 | >; | |
| 68 | 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 | 80 | >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} |
| 71 | 81 | |
| 72 | 82 | const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; |
| 73 | 83 | const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; |
| 84 | const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000; | |
| 74 | 85 | |
| 75 | 86 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { |
| 76 | 87 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" |
| ... | ... | @@ -88,10 +99,28 @@ const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescript |
| 88 | 99 | }); |
| 89 | 100 | |
| 90 | 101 | export const make = Effect.gen(function* () { |
| 102 | const serviceScope = yield* Scope.Scope; | |
| 91 | 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 | 107 | const presentation = yield* ClientCapabilities.ClientPresentation; |
| 93 | 108 | const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; |
| 94 | 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 | 124 | const bearerDescriptors = yield* Ref.make< |
| 96 | 125 | ReadonlyMap< |
| 97 | 126 | EnvironmentId, |
| ... | ... | @@ -192,74 +221,77 @@ export const make = Effect.gen(function* () { |
| 192 | 221 | }, |
| 193 | 222 | ); |
| 194 | 223 | |
| 195 | const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( | |
| 196 | function* (input: { | |
| 197 | readonly expectedEnvironmentId: Parameters< | |
| 198 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 199 | >[0]["expectedEnvironmentId"]; | |
| 200 | readonly obtainBootstrap: Parameters< | |
| 201 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 202 | >[0]["obtainBootstrap"]; | |
| 203 | }) { | |
| 204 | const thumbprint = yield* signer.thumbprint.pipe( | |
| 205 | Effect.mapError( | |
| 206 | () => | |
| 207 | new ConnectionBlockedError({ | |
| 208 | reason: "configuration", | |
| 209 | detail: "Could not load the environment authorization key.", | |
| 210 | }), | |
| 211 | ), | |
| 212 | Effect.withSpan("environment.authorization.dpopKey.resolve"), | |
| 213 | ); | |
| 214 | const now = yield* Clock.currentTimeMillis; | |
| 215 | const cached = yield* tokenStore | |
| 216 | .get(input.expectedEnvironmentId) | |
| 217 | .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); | |
| 218 | if ( | |
| 219 | Option.isSome(cached) && | |
| 220 | cached.value.environmentId === input.expectedEnvironmentId && | |
| 221 | cached.value.dpopThumbprint === thumbprint && | |
| 222 | cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS | |
| 223 | ) { | |
| 224 | yield* Effect.annotateCurrentSpan({ | |
| 225 | "connection.remote_token_cache": "hit", | |
| 226 | }); | |
| 227 | const cachedSocket = yield* createDpopSocketUrl( | |
| 228 | cached.value, | |
| 229 | CACHED_ENDPOINT_SOCKET_TIMEOUT_MS, | |
| 230 | ).pipe(Effect.result); | |
| 231 | if (Result.isSuccess(cachedSocket)) { | |
| 232 | return { | |
| 233 | environmentId: cached.value.environmentId, | |
| 234 | label: cached.value.label, | |
| 235 | httpBaseUrl: cached.value.endpoint.httpBaseUrl, | |
| 236 | socketUrl: cachedSocket.success, | |
| 237 | httpAuthorization: { | |
| 238 | _tag: "Dpop" as const, | |
| 239 | accessToken: cached.value.accessToken, | |
| 240 | expiresAtEpochMs: cached.value.expiresAtEpochMs, | |
| 241 | }, | |
| 242 | }; | |
| 243 | } | |
| 244 | if (cachedSocket.failure._tag === "ConnectionBlockedError") { | |
| 245 | return yield* mapDpopSocketError(cachedSocket.failure); | |
| 246 | } | |
| 247 | yield* tokenStore | |
| 248 | .remove(input.expectedEnvironmentId) | |
| 249 | .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); | |
| 250 | } | |
| 224 | const sessionChanged = () => | |
| 225 | new ConnectionBlockedError({ | |
| 226 | reason: "authentication", | |
| 227 | detail: "Your cloud sign-in changed. Sign in again to authorize the environment.", | |
| 228 | }); | |
| 229 | ||
| 230 | const assertSession = Effect.fnUntraced(function* ( | |
| 231 | identity: ClientCapabilities.CloudSessionIdentity, | |
| 232 | ) { | |
| 233 | const current = yield* cloudSession.identity; | |
| 234 | if (Option.isNone(current) || current.value !== identity) { | |
| 235 | return yield* sessionChanged(); | |
| 236 | } | |
| 237 | }); | |
| 251 | 238 | |
| 252 | yield* Effect.annotateCurrentSpan({ | |
| 253 | "connection.remote_token_cache": "miss", | |
| 239 | // Called under tokenLock so a late rejection cannot remove a newer credential. | |
| 240 | const removeRejectedToken = Effect.fnUntraced(function* ( | |
| 241 | environmentId: EnvironmentId, | |
| 242 | accessToken: string, | |
| 243 | ) { | |
| 244 | const cached = yield* tokenStore.get(environmentId); | |
| 245 | if (Option.isSome(cached) && cached.value.accessToken === accessToken) { | |
| 246 | yield* tokenStore.remove(environmentId); | |
| 247 | tokenOwners.delete(environmentId); | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 251 | const obtainBootstrap = Effect.fn("relay.connection.bootstrap.obtain")(function* ( | |
| 252 | environmentId: EnvironmentId, | |
| 253 | identity: ClientCapabilities.CloudSessionIdentity, | |
| 254 | ) { | |
| 255 | yield* assertSession(identity); | |
| 256 | const clerkToken = yield* cloudSession.clerkToken.pipe( | |
| 257 | Effect.withSpan("relay.connection.cloudSessionToken.resolve"), | |
| 258 | ); | |
| 259 | const deviceId = yield* deviceIdentity.deviceId.pipe( | |
| 260 | Effect.withSpan("relay.connection.deviceIdentity.resolve"), | |
| 261 | ); | |
| 262 | yield* assertSession(identity); | |
| 263 | const connected = yield* relay | |
| 264 | .connectEnvironment({ | |
| 265 | clerkToken, | |
| 266 | scopes: [RelayEnvironmentConnectScope], | |
| 267 | environmentId, | |
| 268 | ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), | |
| 269 | }) | |
| 270 | .pipe(Effect.mapError(mapManagedRelayError)); | |
| 271 | if (connected.environmentId !== environmentId) { | |
| 272 | return yield* environmentMismatchError({ | |
| 273 | expected: environmentId, | |
| 274 | actual: connected.environmentId, | |
| 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 | 288 | const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe( |
| 257 | 289 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 258 | 290 | Effect.withSpan("environment.authorization.descriptor"), |
| 259 | 291 | ); |
| 260 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | |
| 292 | if (descriptor.environmentId !== environmentId) { | |
| 261 | 293 | return yield* environmentMismatchError({ |
| 262 | expected: input.expectedEnvironmentId, | |
| 294 | expected: environmentId, | |
| 263 | 295 | actual: descriptor.environmentId, |
| 264 | 296 | }); |
| 265 | 297 | } |
| ... | ... | @@ -277,6 +309,7 @@ export const make = Effect.gen(function* () { |
| 277 | 309 | }), |
| 278 | 310 | ), |
| 279 | 311 | ); |
| 312 | yield* assertSession(identity); | |
| 280 | 313 | const access = yield* exchangeRemoteDpopAccessToken({ |
| 281 | 314 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, |
| 282 | 315 | credential: bootstrap.credential, |
| ... | ... | @@ -289,36 +322,186 @@ export const make = Effect.gen(function* () { |
| 289 | 322 | Effect.withSpan("environment.authorization.accessToken.exchange"), |
| 290 | 323 | ); |
| 291 | 324 | const issuedAt = yield* Clock.currentTimeMillis; |
| 292 | const token = new TokenStore.RemoteDpopAccessToken({ | |
| 325 | return new TokenStore.RemoteDpopAccessToken({ | |
| 293 | 326 | environmentId: descriptor.environmentId, |
| 327 | accountId: identity.accountId, | |
| 294 | 328 | label: descriptor.label, |
| 295 | 329 | endpoint: bootstrap.endpoint, |
| 296 | 330 | accessToken: access.access_token, |
| 297 | 331 | expiresAtEpochMs: issuedAt + access.expires_in * 1_000, |
| 298 | 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 | ); |
| 317 | 336 | |
| 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 | 495 | return RemoteEnvironmentAuthorization.of({ |
| 319 | 496 | authorizeBearer, |
| 320 | 497 | authorizeDpop: (input) => |
| 321 | 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 | }); |
| 324 | 507 |
packages/client-runtime/src/authorization/tokenStore.ts+1| ... | ... | @@ -12,6 +12,7 @@ export class RemoteDpopAccessToken extends Schema.Class<RemoteDpopAccessToken>( |
| 12 | 12 | "@t3tools/client-runtime/authorization/RemoteDpopAccessToken", |
| 13 | 13 | )({ |
| 14 | 14 | environmentId: EnvironmentId, |
| 15 | accountId: Schema.optionalKey(Schema.String), | |
| 15 | 16 | label: Schema.String, |
| 16 | 17 | endpoint: RelayManagedEndpoint, |
| 17 | 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 | 11 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 12 | 12 | import * as RpcSession from "../rpc/session.ts"; |
| 13 | 13 | |
| 14 | const resolverLayer = ConnectionResolver.layer.pipe( | |
| 15 | Layer.provide(RemoteEnvironmentAuthorization.layer), | |
| 16 | ); | |
| 17 | ||
| 18 | 14 | export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 19 | 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 | 18 | const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); |
| 23 | 19 | const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); |
| ... | ... | @@ -37,7 +33,10 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 37 | 33 | ); |
| 38 | 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 | } |
| 42 | 41 | |
| 43 | 42 | export const layer = layerWithOptions({}); |
packages/client-runtime/src/connection/resolver.test.ts+44-141| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; |
| 2 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | |
| 3 | 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 4 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -8,7 +7,6 @@ import * as Option from "effect/Option"; |
| 8 | 7 | import * as Ref from "effect/Ref"; |
| 9 | 8 | import * as Tracer from "effect/Tracer"; |
| 10 | 9 | |
| 11 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 12 | 10 | import * as ConnectionResolver from "./resolver.ts"; |
| 13 | 11 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 14 | 12 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| ... | ... | @@ -35,7 +33,6 @@ const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 35 | 33 | const ENDPOINT = { |
| 36 | 34 | httpBaseUrl: "https://environment.example.test", |
| 37 | 35 | wsBaseUrl: "wss://environment.example.test", |
| 38 | providerKind: "cloudflare_tunnel" as const, | |
| 39 | 36 | }; |
| 40 | 37 | const SSH_TARGET: DesktopSshEnvironmentTarget = { |
| 41 | 38 | alias: "development", |
| ... | ... | @@ -51,10 +48,6 @@ function catalogEntry( |
| 51 | 48 | return { target, profile }; |
| 52 | 49 | } |
| 53 | 50 | |
| 54 | function unsupported<A>(name: string): Effect.Effect<A> { | |
| 55 | return Effect.die(new Error(`Unexpected relay call: ${name}`)); | |
| 56 | } | |
| 57 | ||
| 58 | 51 | function collectingTracer(spans: Array<string>): Tracer.Tracer { |
| 59 | 52 | return Tracer.make({ |
| 60 | 53 | span: (options) => { |
| ... | ... | @@ -69,30 +62,9 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer { |
| 69 | 62 | }); |
| 70 | 63 | } |
| 71 | 64 | |
| 72 | function 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 | ||
| 92 | 65 | const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { |
| 93 | 66 | readonly profiles?: ReadonlyArray<ConnectionProfile>; |
| 94 | 67 | readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>; |
| 95 | readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; | |
| 96 | 68 | readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; |
| 97 | 69 | readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; |
| 98 | 70 | readonly primaryBearerToken?: string; |
| ... | ... | @@ -131,19 +103,18 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 131 | 103 | authorizeDpop: |
| 132 | 104 | options?.authorizeDpop ?? |
| 133 | 105 | ((input) => |
| 134 | input.obtainBootstrap.pipe( | |
| 135 | Effect.as({ | |
| 136 | environmentId: input.expectedEnvironmentId, | |
| 137 | label: "Authorized relay environment", | |
| 138 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 139 | socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", | |
| 140 | httpAuthorization: { | |
| 141 | _tag: "Dpop" as const, | |
| 142 | accessToken: "dpop-access-token", | |
| 143 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 144 | }, | |
| 145 | }), | |
| 146 | )), | |
| 106 | Effect.succeed({ | |
| 107 | environmentId: input.expectedEnvironmentId, | |
| 108 | label: "Authorized relay environment", | |
| 109 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 110 | socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", | |
| 111 | httpAuthorization: { | |
| 112 | _tag: "Dpop" as const, | |
| 113 | accessToken: "dpop-access-token", | |
| 114 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 115 | }, | |
| 116 | })), | |
| 117 | authorizeDpopHttp: () => Effect.die("unused"), | |
| 147 | 118 | }); |
| 148 | 119 | const ssh = ClientCapabilities.SshEnvironmentGateway.of({ |
| 149 | 120 | provision: () => Effect.die("unused"), |
| ... | ... | @@ -165,10 +136,6 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 165 | 136 | const dependencies = Layer.mergeAll( |
| 166 | 137 | Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), |
| 167 | 138 | Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), |
| 168 | Layer.succeed( | |
| 169 | ClientCapabilities.CloudSession, | |
| 170 | ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }), | |
| 171 | ), | |
| 172 | 139 | Layer.succeed( |
| 173 | 140 | ClientCapabilities.PrimaryEnvironmentAuth, |
| 174 | 141 | ClientCapabilities.PrimaryEnvironmentAuth.of({ |
| ... | ... | @@ -182,27 +149,8 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 182 | 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 | 152 | Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), |
| 192 | 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 | ); |
| 207 | 155 | |
| 208 | 156 | return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies))); |
| ... | ... | @@ -314,66 +262,27 @@ describe("ConnectionResolver", () => { |
| 314 | 262 | }), |
| 315 | 263 | ); |
| 316 | 264 | |
| 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 | 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 | 267 | const target = new RelayConnectionTarget({ |
| 328 | 268 | environmentId: ENVIRONMENT_ID, |
| 329 | 269 | label: "Cloud", |
| 330 | 270 | }); |
| 331 | const brokerLayer = yield* makeDependencies({ | |
| 332 | connectEnvironment: (input) => | |
| 333 | Ref.update(relayInputs, (values) => [ | |
| 334 | ...values, | |
| 335 | { | |
| 336 | clerkToken: input.clerkToken, | |
| 337 | scopes: input.scopes, | |
| 338 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | |
| 339 | }, | |
| 340 | ]).pipe( | |
| 341 | Effect.as({ | |
| 342 | environmentId: input.environmentId, | |
| 343 | endpoint: ENDPOINT, | |
| 344 | credential: "relay-bootstrap", | |
| 345 | expiresAt: "2026-06-06T00:00:00.000Z", | |
| 346 | }), | |
| 347 | ), | |
| 348 | authorizeDpop: (input) => | |
| 349 | input.obtainBootstrap.pipe( | |
| 350 | Effect.tap((bootstrap) => | |
| 351 | Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]), | |
| 352 | ), | |
| 353 | Effect.as({ | |
| 354 | environmentId: input.expectedEnvironmentId, | |
| 355 | label: "Cloud", | |
| 356 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 357 | socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", | |
| 358 | httpAuthorization: { | |
| 359 | _tag: "Dpop" as const, | |
| 360 | accessToken: "dpop-access-token", | |
| 361 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 362 | }, | |
| 363 | }), | |
| 364 | ), | |
| 365 | }); | |
| 271 | const brokerLayer = yield* makeDependencies(); | |
| 366 | 272 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); |
| 367 | 273 | |
| 368 | expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); | |
| 369 | expect(yield* Ref.get(relayInputs)).toEqual([ | |
| 370 | { | |
| 371 | clerkToken: "clerk-session", | |
| 372 | scopes: [RelayEnvironmentConnectScope], | |
| 373 | deviceId: "device-1", | |
| 274 | expect(yield* broker.prepare(catalogEntry(target))).toEqual({ | |
| 275 | environmentId: ENVIRONMENT_ID, | |
| 276 | label: "Authorized relay environment", | |
| 277 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 278 | socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", | |
| 279 | httpAuthorization: { | |
| 280 | _tag: "Dpop", | |
| 281 | accessToken: "dpop-access-token", | |
| 282 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 374 | 283 | }, |
| 375 | ]); | |
| 376 | expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); | |
| 284 | target, | |
| 285 | }); | |
| 377 | 286 | }), |
| 378 | 287 | ); |
| 379 | 288 | |
| ... | ... | @@ -387,20 +296,17 @@ describe("ConnectionResolver", () => { |
| 387 | 296 | }); |
| 388 | 297 | const brokerLayer = yield* makeDependencies({ |
| 389 | 298 | authorizeDpop: (input) => |
| 390 | input.obtainBootstrap.pipe( | |
| 391 | Effect.as({ | |
| 392 | environmentId: input.expectedEnvironmentId, | |
| 393 | label: "Cloud", | |
| 394 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 395 | socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", | |
| 396 | httpAuthorization: { | |
| 397 | _tag: "Dpop" as const, | |
| 398 | accessToken: "dpop-access-token", | |
| 399 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 400 | }, | |
| 401 | }), | |
| 402 | Effect.withSpan("test.remote.authorizeDpop"), | |
| 403 | ), | |
| 299 | Effect.succeed({ | |
| 300 | environmentId: input.expectedEnvironmentId, | |
| 301 | label: "Cloud", | |
| 302 | httpBaseUrl: ENDPOINT.httpBaseUrl, | |
| 303 | socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", | |
| 304 | httpAuthorization: { | |
| 305 | _tag: "Dpop" as const, | |
| 306 | accessToken: "dpop-access-token", | |
| 307 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 308 | }, | |
| 309 | }).pipe(Effect.withSpan("test.remote.authorizeDpop")), | |
| 404 | 310 | }); |
| 405 | 311 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); |
| 406 | 312 | |
| ... | ... | @@ -470,27 +376,24 @@ describe("ConnectionResolver", () => { |
| 470 | 376 | }), |
| 471 | 377 | ); |
| 472 | 378 | |
| 473 | it.effect("classifies relay request timeouts as retryable connection failures", () => | |
| 379 | it.effect("preserves relay authorization failure classification and trace details", () => | |
| 474 | 380 | Effect.gen(function* () { |
| 475 | 381 | const target = new RelayConnectionTarget({ |
| 476 | 382 | environmentId: ENVIRONMENT_ID, |
| 477 | 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 | 390 | const brokerLayer = yield* makeDependencies({ |
| 480 | connectEnvironment: () => | |
| 481 | Effect.fail( | |
| 482 | new ManagedRelay.ManagedRelayRequestTimeoutError({ | |
| 483 | activity: "Relay environment connection", | |
| 484 | timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, | |
| 485 | traceId: null, | |
| 486 | }), | |
| 487 | ), | |
| 391 | authorizeDpop: () => Effect.fail(authorizationError), | |
| 488 | 392 | }); |
| 489 | 393 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); |
| 490 | 394 | const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); |
| 491 | 395 | |
| 492 | expect(error).toBeInstanceOf(ConnectionTransientError); | |
| 493 | expect(error).toMatchObject({ reason: "timeout" }); | |
| 396 | expect(error).toBe(authorizationError); | |
| 494 | 397 | }), |
| 495 | 398 | ); |
| 496 | 399 | }); |
packages/client-runtime/src/connection/resolver.ts+1-34| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; |
| 2 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | |
| 3 | 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 4 | 3 | import * as Context from "effect/Context"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -9,7 +8,6 @@ import * as Schema from "effect/Schema"; |
| 9 | 8 | |
| 10 | 9 | import { appendClientConnectionParams } from "../authorization/remote.ts"; |
| 11 | 10 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 12 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 13 | 11 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| 14 | 12 | import { |
| 15 | 13 | BearerConnectionCredential, |
| ... | ... | @@ -18,12 +16,7 @@ import { |
| 18 | 16 | SshConnectionProfile, |
| 19 | 17 | } from "./catalog.ts"; |
| 20 | 18 | import * as ConnectionCredentialStore from "./credentialStore.ts"; |
| 21 | import { | |
| 22 | credentialMissingError, | |
| 23 | environmentMismatchError, | |
| 24 | mapManagedRelayError, | |
| 25 | profileMissingError, | |
| 26 | } from "./errors.ts"; | |
| 19 | import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; | |
| 27 | 20 | import type { |
| 28 | 21 | BearerConnectionTarget, |
| 29 | 22 | ConnectionTarget, |
| ... | ... | @@ -148,38 +141,12 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 148 | 141 | }); |
| 149 | 142 | |
| 150 | 143 | const 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 | 144 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 155 | 145 | |
| 156 | 146 | return Effect.fnUntraced( |
| 157 | 147 | function* (target: RelayConnectionTarget) { |
| 158 | 148 | const authorized = yield* remote.authorizeDpop({ |
| 159 | 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 | 151 | return { |
| 185 | 152 | environmentId: authorized.environmentId, |
packages/client-runtime/src/connection/supervisor.test.ts+186-140| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 3 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -11,12 +11,21 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 11 | 11 | import * as TestClock from "effect/testing/TestClock"; |
| 12 | 12 | import * as Tracer from "effect/Tracer"; |
| 13 | 13 | |
| 14 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | |
| 15 | import * as TokenStore from "../authorization/tokenStore.ts"; | |
| 16 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 17 | import { | |
| 18 | ManagedRelayClient, | |
| 19 | ManagedRelayDpopSigner, | |
| 20 | ManagedRelayRequestTimeoutError, | |
| 21 | } from "../relay/managedRelay.ts"; | |
| 22 | import { remoteHttpClientLayer } from "../rpc/http.ts"; | |
| 14 | 23 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 24 | import { fetchEnvironmentSessionState } from "../state/session.ts"; | |
| 15 | 25 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 16 | 26 | import * as Connectivity from "./connectivity.ts"; |
| 17 | 27 | import * as ConnectionDriver from "./driver.ts"; |
| 18 | 28 | import { |
| 19 | DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, | |
| 20 | 29 | ConnectionBlockedError, |
| 21 | 30 | ConnectionTransientError, |
| 22 | 31 | PrimaryConnectionTarget, |
| ... | ... | @@ -1127,183 +1136,220 @@ describe("EnvironmentSupervisor", () => { |
| 1127 | 1136 | }), |
| 1128 | 1137 | ); |
| 1129 | 1138 | |
| 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 | 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 | 1141 | const harness = yield* makeHarness({ |
| 1136 | 1142 | prepare: (attempt) => |
| 1137 | attempt === 2 | |
| 1138 | ? Effect.fail(transient("Authorization refresh failed.")) | |
| 1139 | : Effect.succeed({ | |
| 1140 | ...PREPARED_CONNECTION, | |
| 1141 | target: RELAY_TARGET, | |
| 1142 | httpAuthorization: { | |
| 1143 | _tag: "Dpop", | |
| 1144 | accessToken: `access-token-${attempt}`, | |
| 1145 | expiresAtEpochMs: tokenLifetimeMs * attempt, | |
| 1146 | }, | |
| 1147 | }), | |
| 1148 | ready: (attempt) => | |
| 1149 | attempt === 2 | |
| 1150 | ? Deferred.succeed(replacementStarted, undefined).pipe( | |
| 1151 | Effect.andThen(Deferred.await(releaseReplacement)), | |
| 1152 | ) | |
| 1153 | : Effect.void, | |
| 1143 | Effect.succeed({ | |
| 1144 | ...PREPARED_CONNECTION, | |
| 1145 | target: RELAY_TARGET, | |
| 1146 | httpAuthorization: { | |
| 1147 | _tag: "Dpop", | |
| 1148 | accessToken: `access-token-${attempt}`, | |
| 1149 | expiresAtEpochMs: 3_600_000 * attempt, | |
| 1150 | }, | |
| 1151 | }), | |
| 1154 | 1152 | }); |
| 1155 | 1153 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { |
| 1156 | 1154 | initiallyDesired: true, |
| 1157 | 1155 | }).pipe(Effect.provide(harness.dependencies)); |
| 1158 | 1156 | |
| 1159 | 1157 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); |
| 1160 | const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); | |
| 1161 | const firstPrepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)); | |
| 1162 | yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1); | |
| 1163 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | |
| 1158 | const session = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); | |
| 1164 | 1159 | |
| 1165 | yield* TestClock.adjust(1); | |
| 1166 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | |
| 1167 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | |
| 1168 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); | |
| 1169 | yield* Effect.yieldNow; | |
| 1160 | yield* TestClock.adjust("2 hours"); | |
| 1170 | 1161 | |
| 1171 | yield* TestClock.adjust("2999 millis"); | |
| 1172 | expect(yield* Ref.get(harness.prepareCount)).toBe(2); | |
| 1173 | yield* TestClock.adjust("1 milli"); | |
| 1174 | yield* Deferred.await(replacementStarted); | |
| 1175 | ||
| 1176 | expect(yield* Ref.get(harness.prepareCount)).toBe(3); | |
| 1177 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | |
| 1162 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | |
| 1178 | 1163 | expect(yield* Ref.get(harness.releaseCount)).toBe(0); |
| 1164 | expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session); | |
| 1179 | 1165 | expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ |
| 1180 | 1166 | phase: "connected", |
| 1181 | 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 | 1169 | }).pipe(Effect.provide(TestClock.layer())), |
| 1202 | 1170 | ); |
| 1203 | 1171 | |
| 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 | 1173 | Effect.gen(function* () { |
| 1206 | const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; | |
| 1207 | const replacementStarted = yield* Deferred.make<void>(); | |
| 1208 | const retryStarted = yield* Deferred.make<void>(); | |
| 1209 | const harness = yield* makeHarness({ | |
| 1210 | prepare: (attempt) => | |
| 1211 | Effect.succeed({ | |
| 1212 | ...PREPARED_CONNECTION, | |
| 1213 | target: RELAY_TARGET, | |
| 1214 | httpAuthorization: { | |
| 1215 | _tag: "Dpop", | |
| 1216 | accessToken: `access-token-${attempt}`, | |
| 1217 | expiresAtEpochMs: tokenLifetimeMs * attempt, | |
| 1218 | }, | |
| 1174 | const endpoint = { | |
| 1175 | httpBaseUrl: TARGET.httpBaseUrl, | |
| 1176 | wsBaseUrl: TARGET.wsBaseUrl, | |
| 1177 | providerKind: "cloudflare_tunnel" as const, | |
| 1178 | }; | |
| 1179 | const token = yield* Ref.make( | |
| 1180 | Option.some( | |
| 1181 | new TokenStore.RemoteDpopAccessToken({ | |
| 1182 | environmentId: TARGET.environmentId, | |
| 1183 | accountId: "test-account", | |
| 1184 | label: TARGET.label, | |
| 1185 | endpoint, | |
| 1186 | accessToken: "access-token-1", | |
| 1187 | expiresAtEpochMs: 3_600_000, | |
| 1188 | dpopThumbprint: "test-thumbprint", | |
| 1219 | 1189 | }), |
| 1220 | ready: (attempt) => { | |
| 1221 | if (attempt === 2) { | |
| 1222 | return Deferred.succeed(replacementStarted, undefined).pipe( | |
| 1223 | Effect.andThen(Effect.never), | |
| 1190 | ), | |
| 1191 | ); | |
| 1192 | const bootstrapFails = yield* Ref.make(false); | |
| 1193 | const bootstrapCalls = yield* Ref.make(0); | |
| 1194 | const httpPaths: Array<string> = []; | |
| 1195 | const sessionAuthorizations: Array<string | null> = []; | |
| 1196 | const fetchFn = ((input, init) => { | |
| 1197 | const request = new Request(input, init); | |
| 1198 | const pathname = new URL(request.url).pathname; | |
| 1199 | httpPaths.push(pathname); | |
| 1200 | switch (pathname) { | |
| 1201 | case "/.well-known/t3/environment": | |
| 1202 | return Promise.resolve( | |
| 1203 | Response.json({ | |
| 1204 | environmentId: TARGET.environmentId, | |
| 1205 | label: TARGET.label, | |
| 1206 | platform: { os: "linux", arch: "x64" }, | |
| 1207 | serverVersion: "0.0.0-test", | |
| 1208 | capabilities: { repositoryIdentity: true }, | |
| 1209 | }), | |
| 1210 | ); | |
| 1211 | case "/oauth/token": | |
| 1212 | return Promise.resolve( | |
| 1213 | Response.json({ | |
| 1214 | access_token: "access-token-2", | |
| 1215 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | |
| 1216 | token_type: "DPoP", | |
| 1217 | expires_in: 3_600, | |
| 1218 | scope: AuthStandardClientScopes.join(" "), | |
| 1219 | }), | |
| 1220 | ); | |
| 1221 | case "/api/auth/websocket-ticket": | |
| 1222 | return Promise.resolve( | |
| 1223 | Response.json({ | |
| 1224 | ticket: "ws-ticket", | |
| 1225 | expiresAt: "2026-09-04T01:00:00.000Z", | |
| 1226 | }), | |
| 1227 | ); | |
| 1228 | case "/api/auth/session": { | |
| 1229 | const authorization = request.headers.get("authorization"); | |
| 1230 | sessionAuthorizations.push(authorization); | |
| 1231 | return Promise.resolve( | |
| 1232 | Response.json({ | |
| 1233 | authenticated: authorization === "DPoP access-token-2", | |
| 1234 | auth: { | |
| 1235 | policy: "loopback-browser", | |
| 1236 | bootstrapMethods: ["one-time-token"], | |
| 1237 | sessionMethods: ["dpop-access-token"], | |
| 1238 | sessionCookieName: "t3_session_test", | |
| 1239 | }, | |
| 1240 | scopes: AuthStandardClientScopes, | |
| 1241 | }), | |
| 1224 | 1242 | ); |
| 1225 | 1243 | } |
| 1226 | if (attempt === 3) { | |
| 1227 | return Deferred.succeed(retryStarted, undefined).pipe(Effect.andThen(Effect.never)); | |
| 1244 | default: | |
| 1245 | return Promise.reject(new Error(`Unexpected HTTP request to ${request.url}`)); | |
| 1246 | } | |
| 1247 | }) satisfies typeof fetch; | |
| 1248 | const signer = ManagedRelayDpopSigner.of({ | |
| 1249 | thumbprint: Effect.succeed("test-thumbprint"), | |
| 1250 | createProof: () => Effect.succeed("test-proof"), | |
| 1251 | }); | |
| 1252 | const unused = () => Effect.die("Unexpected relay operation."); | |
| 1253 | const relay = ManagedRelayClient.of({ | |
| 1254 | relayUrl: "https://relay.example.test", | |
| 1255 | listEnvironments: unused, | |
| 1256 | listDevices: unused, | |
| 1257 | createEnvironmentLinkChallenge: unused, | |
| 1258 | linkEnvironment: unused, | |
| 1259 | unlinkEnvironment: unused, | |
| 1260 | getEnvironmentStatus: unused, | |
| 1261 | connectEnvironment: Effect.fn("TestConnectionHttp.connectEnvironment")(function* () { | |
| 1262 | yield* Ref.update(bootstrapCalls, (count) => count + 1); | |
| 1263 | if (yield* Ref.get(bootstrapFails)) { | |
| 1264 | return yield* new ManagedRelayRequestTimeoutError({ | |
| 1265 | activity: "Relay environment connection", | |
| 1266 | timeoutMs: 6_000, | |
| 1267 | traceId: null, | |
| 1268 | }); | |
| 1228 | 1269 | } |
| 1229 | return Effect.void; | |
| 1230 | }, | |
| 1270 | return { | |
| 1271 | environmentId: TARGET.environmentId, | |
| 1272 | endpoint, | |
| 1273 | credential: "relay-bootstrap", | |
| 1274 | expiresAt: "2026-09-04T01:00:00.000Z", | |
| 1275 | }; | |
| 1276 | }), | |
| 1277 | registerDevice: unused, | |
| 1278 | unregisterDevice: unused, | |
| 1279 | registerLiveActivity: unused, | |
| 1280 | getAgentActivitySnapshot: unused, | |
| 1281 | resetTokenCache: Effect.void, | |
| 1282 | }); | |
| 1283 | const httpLayer = remoteHttpClientLayer(fetchFn); | |
| 1284 | const remoteAuthorization = yield* RemoteEnvironmentAuthorization.make.pipe( | |
| 1285 | Effect.provide( | |
| 1286 | Layer.mergeAll( | |
| 1287 | httpLayer, | |
| 1288 | Layer.succeed(ManagedRelayDpopSigner, signer), | |
| 1289 | Layer.succeed(ManagedRelayClient, relay), | |
| 1290 | Layer.succeed(ClientCapabilities.CloudSession, { | |
| 1291 | identity: Effect.succeed(Option.some({ accountId: "test-account" })), | |
| 1292 | clerkToken: Effect.succeed("clerk-token"), | |
| 1293 | }), | |
| 1294 | Layer.succeed(ClientCapabilities.RelayDeviceIdentity, { | |
| 1295 | deviceId: Effect.succeed(Option.none()), | |
| 1296 | }), | |
| 1297 | TokenStore.layer({ | |
| 1298 | get: () => Ref.get(token), | |
| 1299 | put: (value) => Ref.set(token, Option.some(value)), | |
| 1300 | remove: () => Ref.set(token, Option.none()), | |
| 1301 | }), | |
| 1302 | Layer.succeed(ClientCapabilities.ClientPresentation, { | |
| 1303 | metadata: { label: "Test client", deviceType: "desktop" }, | |
| 1304 | scopes: AuthStandardClientScopes, | |
| 1305 | }), | |
| 1306 | ), | |
| 1307 | ), | |
| 1308 | ); | |
| 1309 | const harness = yield* makeHarness({ | |
| 1310 | prepare: () => | |
| 1311 | remoteAuthorization | |
| 1312 | .authorizeDpop({ expectedEnvironmentId: TARGET.environmentId }) | |
| 1313 | .pipe(Effect.map((prepared) => ({ ...prepared, target: RELAY_TARGET }))), | |
| 1231 | 1314 | }); |
| 1232 | 1315 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { |
| 1233 | 1316 | initiallyDesired: true, |
| 1234 | 1317 | }).pipe(Effect.provide(harness.dependencies)); |
| 1235 | ||
| 1236 | 1318 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); |
| 1237 | const firstSession = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); | |
| 1238 | yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS); | |
| 1239 | yield* Deferred.await(replacementStarted); | |
| 1240 | ||
| 1241 | expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); | |
| 1319 | const session = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); | |
| 1320 | const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)); | |
| 1321 | const readSession = fetchEnvironmentSessionState({ | |
| 1322 | prepared, | |
| 1323 | signer: Option.some(signer), | |
| 1324 | remoteAuthorization: Option.some(remoteAuthorization), | |
| 1325 | }).pipe(Effect.provide(httpLayer)); | |
| 1326 | ||
| 1327 | yield* TestClock.adjust("2 hours"); | |
| 1328 | expect((yield* readSession).authenticated).toBe(true); | |
| 1329 | expect(sessionAuthorizations).toEqual(["DPoP access-token-2"]); | |
| 1330 | expect(yield* Ref.get(bootstrapCalls)).toBe(1); | |
| 1331 | expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(session); | |
| 1242 | 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 | 1333 | expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ |
| 1249 | 1334 | phase: "connected", |
| 1250 | 1335 | generation: 1, |
| 1251 | 1336 | }); |
| 1252 | 1337 | |
| 1253 | yield* TestClock.adjust("3 seconds"); | |
| 1254 | yield* Deferred.await(retryStarted); | |
| 1255 | expect(yield* Ref.get(harness.sessionCount)).toBe(3); | |
| 1256 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | |
| 1257 | expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(firstSession); | |
| 1258 | ||
| 1259 | yield* supervisor.disconnect; | |
| 1260 | yield* awaitState(supervisor.state, (state) => state.phase === "available"); | |
| 1261 | ||
| 1262 | expect(yield* Ref.get(harness.releaseCount)).toBe(3); | |
| 1263 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); | |
| 1264 | expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); | |
| 1265 | }).pipe(Effect.provide(TestClock.layer())), | |
| 1266 | ); | |
| 1267 | ||
| 1268 | it.effect("does not keep a relay session past its authorization expiry", () => | |
| 1269 | Effect.gen(function* () { | |
| 1270 | const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; | |
| 1271 | const harness = yield* makeHarness({ | |
| 1272 | prepare: (attempt) => | |
| 1273 | attempt === 1 | |
| 1274 | ? Effect.succeed({ | |
| 1275 | ...PREPARED_CONNECTION, | |
| 1276 | target: RELAY_TARGET, | |
| 1277 | httpAuthorization: { | |
| 1278 | _tag: "Dpop", | |
| 1279 | accessToken: "access-token-1", | |
| 1280 | expiresAtEpochMs: tokenLifetimeMs, | |
| 1281 | }, | |
| 1282 | }) | |
| 1283 | : Effect.fail(transient("Authorization refresh failed.")), | |
| 1284 | }); | |
| 1285 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { | |
| 1286 | initiallyDesired: true, | |
| 1287 | }).pipe(Effect.provide(harness.dependencies)); | |
| 1288 | ||
| 1289 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | |
| 1290 | yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS); | |
| 1291 | yield* Effect.yieldNow; | |
| 1292 | for (const delay of [3_000, 4_000, 8_000, 16_000, 16_000, 13_000]) { | |
| 1293 | yield* TestClock.adjust(delay); | |
| 1294 | yield* Effect.yieldNow; | |
| 1295 | } | |
| 1296 | ||
| 1297 | yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); | |
| 1298 | ||
| 1338 | yield* TestClock.adjust("2 hours"); | |
| 1339 | yield* Ref.set(bootstrapFails, true); | |
| 1340 | const failure = yield* readSession.pipe(Effect.flip); | |
| 1341 | expect(failure._tag).toBe("RemoteEnvironmentAuthFetchError"); | |
| 1342 | expect(yield* Ref.get(bootstrapCalls)).toBe(2); | |
| 1343 | expect(sessionAuthorizations).toEqual(["DPoP access-token-2"]); | |
| 1344 | expect(httpPaths.filter((path) => path === "/api/auth/websocket-ticket")).toHaveLength(1); | |
| 1345 | expect(httpPaths.filter((path) => path === "/oauth/token")).toHaveLength(1); | |
| 1299 | 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 | 1349 | expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ |
| 1302 | phase: "backoff", | |
| 1350 | phase: "connected", | |
| 1303 | 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 | 1353 | }).pipe(Effect.provide(TestClock.layer())), |
| 1308 | 1354 | ); |
| 1309 | 1355 |
packages/client-runtime/src/connection/supervisor.ts+44-270| ... | ... | @@ -18,7 +18,6 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 18 | 18 | import * as Connectivity from "./connectivity.ts"; |
| 19 | 19 | import * as ConnectionDriver from "./driver.ts"; |
| 20 | 20 | import { |
| 21 | DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, | |
| 22 | 21 | type ConnectionAttemptError, |
| 23 | 22 | type ConnectionTarget, |
| 24 | 23 | ConnectionTransientError, |
| ... | ... | @@ -61,24 +60,16 @@ interface TracedAttemptFailure { |
| 61 | 60 | readonly attemptSpan: Option.Option<Tracer.Span>; |
| 62 | 61 | } |
| 63 | 62 | |
| 64 | interface ScopedConnection { | |
| 65 | readonly attemptSpan: Option.Option<Tracer.Span>; | |
| 66 | readonly lease: ConnectionDriver.EnvironmentConnectionLease; | |
| 67 | readonly scope: Scope.Closeable; | |
| 68 | } | |
| 69 | ||
| 70 | 63 | type AttemptOutcome = |
| 71 | 64 | | { |
| 72 | 65 | readonly _tag: "Interrupted"; |
| 73 | 66 | readonly established: boolean; |
| 74 | readonly generation: number; | |
| 75 | 67 | readonly stable: boolean; |
| 76 | 68 | readonly resetRetry: boolean; |
| 77 | 69 | } |
| 78 | 70 | | { |
| 79 | 71 | readonly _tag: "Failure"; |
| 80 | 72 | readonly established: boolean; |
| 81 | readonly generation: number; | |
| 82 | 73 | readonly stable: boolean; |
| 83 | 74 | readonly failure: TracedAttemptFailure; |
| 84 | 75 | }; |
| ... | ... | @@ -86,26 +77,17 @@ type AttemptOutcome = |
| 86 | 77 | type EstablishmentEvent = |
| 87 | 78 | | { |
| 88 | 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 | 88 | | { readonly _tag: "Interrupted"; readonly resetRetry: boolean } |
| 92 | 89 | | { readonly _tag: "TimedOut" }; |
| 93 | 90 | |
| 94 | type ReplacementPreparationEvent = | |
| 95 | | { readonly _tag: "Completed"; readonly exit: Exit.Exit<ScopedConnection, TracedAttemptFailure> } | |
| 96 | | { readonly _tag: "TimedOut" } | |
| 97 | | { readonly _tag: "AuthorizationExpired" }; | |
| 98 | ||
| 99 | type 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 | ||
| 109 | 91 | function exitUnlessInterrupted<A, E, R>( |
| 110 | 92 | effect: Effect.Effect<A, E, R>, |
| 111 | 93 | ): Effect.Effect<Exit.Exit<A, E>, never, R> { |
| ... | ... | @@ -186,18 +168,16 @@ function failureFromExit<A>( |
| 186 | 168 | target: ConnectionTarget, |
| 187 | 169 | exit: Exit.Exit<A, TracedAttemptFailure>, |
| 188 | 170 | established: boolean, |
| 189 | generation: number, | |
| 190 | 171 | stable: boolean, |
| 191 | 172 | ): AttemptOutcome { |
| 192 | 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 | 176 | const typedFailure = exit.cause.reasons.find(Cause.isFailReason); |
| 196 | 177 | if (typedFailure) { |
| 197 | 178 | return { |
| 198 | 179 | _tag: "Failure", |
| 199 | 180 | established, |
| 200 | generation, | |
| 201 | 181 | stable, |
| 202 | 182 | failure: typedFailure.error, |
| 203 | 183 | }; |
| ... | ... | @@ -205,7 +185,6 @@ function failureFromExit<A>( |
| 205 | 185 | return { |
| 206 | 186 | _tag: "Failure", |
| 207 | 187 | established, |
| 208 | generation, | |
| 209 | 188 | stable, |
| 210 | 189 | failure: { |
| 211 | 190 | error: new ConnectionTransientError({ |
| ... | ... | @@ -313,10 +292,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 313 | 292 | attempt: number, |
| 314 | 293 | generation: number, |
| 315 | 294 | lastFailure: ConnectionAttemptError | null, |
| 316 | publishProgress: boolean, | |
| 317 | 295 | ) { |
| 318 | 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 | }); |
| 322 | 300 | |
| ... | ... | @@ -367,17 +345,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 367 | 345 | generation: number, |
| 368 | 346 | lastFailure: ConnectionAttemptError | null, |
| 369 | 347 | pendingRetry: Option.Option<PendingRetryTrace>, |
| 370 | publishProgress: boolean, | |
| 371 | 348 | ) { |
| 372 | 349 | if (target._tag === "RelayConnectionTarget") { |
| 373 | 350 | return yield* traceRelayEstablishment( |
| 374 | establishConnection(attempt, generation, lastFailure, publishProgress), | |
| 351 | establishConnection(attempt, generation, lastFailure), | |
| 375 | 352 | attempt, |
| 376 | 353 | generation, |
| 377 | 354 | pendingRetry, |
| 378 | 355 | ); |
| 379 | 356 | } |
| 380 | return yield* establishConnection(attempt, generation, lastFailure, publishProgress).pipe( | |
| 357 | return yield* establishConnection(attempt, generation, lastFailure).pipe( | |
| 381 | 358 | Effect.map((lease) => ({ |
| 382 | 359 | attemptSpan: Option.none<Tracer.Span>(), |
| 383 | 360 | lease, |
| ... | ... | @@ -389,37 +366,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 389 | 366 | ); |
| 390 | 367 | }); |
| 391 | 368 | |
| 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 | 369 | const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { |
| 424 | 370 | for (;;) { |
| 425 | 371 | const next = yield* Queue.take(signals); |
| ... | ... | @@ -541,136 +487,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 541 | 487 | } |
| 542 | 488 | }); |
| 543 | 489 | |
| 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 | 490 | const runAttempt = Effect.fnUntraced(function* ( |
| 658 | 491 | attempt: number, |
| 659 | previousGeneration: number, | |
| 492 | generation: number, | |
| 660 | 493 | lastFailure: ConnectionAttemptError | null, |
| 661 | 494 | pendingRetry: Option.Option<PendingRetryTrace>, |
| 662 | 495 | ) { |
| 663 | const initialGeneration = previousGeneration + 1; | |
| 664 | 496 | yield* SubscriptionRef.set(prepared, Option.none()); |
| 665 | const initial = yield* forkScopedTracedConnection( | |
| 666 | attempt, | |
| 667 | initialGeneration, | |
| 668 | lastFailure, | |
| 669 | pendingRetry, | |
| 670 | true, | |
| 671 | ); | |
| 672 | 497 | const establishment = yield* Effect.raceAllFirst([ |
| 673 | Fiber.await(initial.fiber).pipe( | |
| 498 | exitUnlessInterrupted( | |
| 499 | establishTracedConnection(attempt, generation, lastFailure, pendingRetry), | |
| 500 | ).pipe( | |
| 674 | 501 | Effect.map((exit): EstablishmentEvent => ({ |
| 675 | 502 | _tag: "Completed", |
| 676 | 503 | exit, |
| ... | ... | @@ -687,18 +514,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 687 | 514 | ), |
| 688 | 515 | ]); |
| 689 | 516 | |
| 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 | 517 | if (establishment._tag === "Interrupted") { |
| 698 | 518 | return { |
| 699 | 519 | _tag: "Interrupted", |
| 700 | 520 | established: false, |
| 701 | generation: previousGeneration, | |
| 702 | 521 | stable: false, |
| 703 | 522 | resetRetry: establishment.resetRetry, |
| 704 | 523 | } satisfies AttemptOutcome; |
| ... | ... | @@ -707,7 +526,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 707 | 526 | return { |
| 708 | 527 | _tag: "Failure", |
| 709 | 528 | established: false, |
| 710 | generation: previousGeneration, | |
| 711 | 529 | stable: false, |
| 712 | 530 | failure: { |
| 713 | 531 | error: new ConnectionTransientError({ |
| ... | ... | @@ -722,7 +540,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 722 | 540 | const isUnexpectedDefect = |
| 723 | 541 | !Cause.hasInterruptsOnly(establishment.exit.cause) && |
| 724 | 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 | 544 | if (isUnexpectedDefect) { |
| 727 | 545 | const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; |
| 728 | 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 | 555 | return outcome; |
| 738 | 556 | } |
| 739 | 557 | |
| 558 | const active = establishment.exit.value; | |
| 740 | 559 | const currentIntent = yield* Ref.get(intent); |
| 741 | 560 | if (!currentIntent.desired || currentIntent.network === "offline") { |
| 742 | 561 | return { |
| 743 | 562 | _tag: "Interrupted", |
| 744 | 563 | established: false, |
| 745 | generation: previousGeneration, | |
| 746 | 564 | stable: false, |
| 747 | 565 | resetRetry: false, |
| 748 | 566 | } satisfies AttemptOutcome; |
| 749 | 567 | } |
| 750 | 568 | |
| 751 | 569 | const connectedAt = yield* Clock.currentTimeMillis; |
| 752 | let active = establishment.exit.value; | |
| 753 | let activeGeneration = initialGeneration; | |
| 754 | 570 | yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); |
| 755 | 571 | yield* SubscriptionRef.set(session, Option.some(active.lease.session)); |
| 756 | 572 | yield* setState({ |
| ... | ... | @@ -759,78 +575,35 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 759 | 575 | phase: "connected", |
| 760 | 576 | stage: null, |
| 761 | 577 | attempt, |
| 762 | generation: activeGeneration, | |
| 578 | generation, | |
| 763 | 579 | lastFailure: null, |
| 764 | 580 | retryAt: null, |
| 765 | 581 | }); |
| 766 | 582 | |
| 767 | for (;;) { | |
| 768 | const connectedEvent = yield* Effect.raceAllFirst([ | |
| 769 | waitForActiveCompletion(active), | |
| 770 | exitUnlessInterrupted(prepareReplacement(active, activeGeneration + 1)).pipe( | |
| 771 | Effect.map((exit): ConnectedLeaseEvent => ({ _tag: "ReplacementCompleted", exit })), | |
| 772 | ), | |
| 773 | ]); | |
| 774 | const stable = (yield* Clock.currentTimeMillis) - connectedAt >= BACKOFF_RESET_AFTER_MS; | |
| 775 | if (connectedEvent._tag === "ActiveCompleted") { | |
| 776 | if (Exit.isSuccess(connectedEvent.exit)) { | |
| 777 | return { | |
| 778 | _tag: "Interrupted", | |
| 779 | established: true, | |
| 780 | generation: activeGeneration, | |
| 781 | stable, | |
| 782 | resetRetry: connectedEvent.exit.value, | |
| 783 | } satisfies AttemptOutcome; | |
| 784 | } | |
| 785 | return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable); | |
| 786 | } | |
| 787 | if (Exit.isFailure(connectedEvent.exit)) { | |
| 788 | return failureFromExit(target, connectedEvent.exit, true, activeGeneration, stable); | |
| 789 | } | |
| 790 | if (Option.isNone(connectedEvent.exit.value)) { | |
| 791 | return { | |
| 792 | _tag: "Interrupted", | |
| 793 | established: true, | |
| 794 | generation: activeGeneration, | |
| 795 | stable, | |
| 796 | resetRetry: true, | |
| 797 | } satisfies AttemptOutcome; | |
| 798 | } | |
| 799 | ||
| 800 | const candidate = connectedEvent.exit.value.value; | |
| 801 | const replacementIntent = yield* Ref.get(intent); | |
| 802 | if (!replacementIntent.desired || replacementIntent.network === "offline") { | |
| 803 | yield* Scope.close(candidate.scope, Exit.void).pipe(Effect.ignore); | |
| 804 | return { | |
| 805 | _tag: "Interrupted", | |
| 806 | established: true, | |
| 807 | generation: activeGeneration, | |
| 808 | stable, | |
| 809 | resetRetry: false, | |
| 810 | } satisfies AttemptOutcome; | |
| 811 | } | |
| 812 | ||
| 813 | const previous = active; | |
| 814 | yield* Effect.uninterruptible( | |
| 815 | Effect.gen(function* () { | |
| 816 | active = candidate; | |
| 817 | activeGeneration += 1; | |
| 818 | yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); | |
| 819 | yield* SubscriptionRef.set(session, Option.some(active.lease.session)); | |
| 820 | yield* setState({ | |
| 821 | desired: true, | |
| 822 | network: replacementIntent.network, | |
| 823 | phase: "connected", | |
| 824 | stage: null, | |
| 825 | attempt: 1, | |
| 826 | generation: activeGeneration, | |
| 827 | lastFailure: null, | |
| 828 | retryAt: null, | |
| 829 | }); | |
| 830 | yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore); | |
| 831 | }), | |
| 832 | ); | |
| 583 | const connectedExit = yield* Effect.raceFirst( | |
| 584 | active.lease.session.closed.pipe( | |
| 585 | Effect.mapError((error): TracedAttemptFailure => ({ | |
| 586 | error, | |
| 587 | attemptSpan: active.attemptSpan, | |
| 588 | })), | |
| 589 | ), | |
| 590 | monitorConnectedLease(active.lease).pipe( | |
| 591 | Effect.mapError((error): TracedAttemptFailure => ({ | |
| 592 | error, | |
| 593 | attemptSpan: active.attemptSpan, | |
| 594 | })), | |
| 595 | ), | |
| 596 | ).pipe(exitUnlessInterrupted); | |
| 597 | const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; | |
| 598 | if (Exit.isSuccess(connectedExit)) { | |
| 599 | return { | |
| 600 | _tag: "Interrupted", | |
| 601 | established: true, | |
| 602 | stable: connectedForMs >= BACKOFF_RESET_AFTER_MS, | |
| 603 | resetRetry: connectedExit.value, | |
| 604 | } satisfies AttemptOutcome; | |
| 833 | 605 | } |
| 606 | return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); | |
| 834 | 607 | }, Effect.ensuring(clearLease)); |
| 835 | 608 | |
| 836 | 609 | const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { |
| ... | ... | @@ -895,14 +668,15 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 895 | 668 | } |
| 896 | 669 | |
| 897 | 670 | const attempt = failureCount + 1; |
| 671 | const nextGeneration = generation + 1; | |
| 898 | 672 | const outcome: AttemptOutcome = yield* Effect.scoped( |
| 899 | runAttempt(attempt, generation, latestFailure, pendingRetry), | |
| 673 | runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), | |
| 900 | 674 | ); |
| 901 | 675 | // Consumed on every iteration so a stale marker can never leak into a |
| 902 | 676 | // later, unrelated failure. |
| 903 | 677 | const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); |
| 904 | generation = outcome.generation; | |
| 905 | 678 | if (outcome.established) { |
| 679 | generation = nextGeneration; | |
| 906 | 680 | if (outcome.stable) { |
| 907 | 681 | resetRetryLadder(); |
| 908 | 682 | latestFailure = null; |
packages/client-runtime/src/platform/capabilities.ts+6| ... | ... | @@ -21,9 +21,15 @@ export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { |
| 21 | 21 | readonly label: string; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | /** Stable for one signed-in session, including same-account token refreshes. */ | |
| 25 | export interface CloudSessionIdentity { | |
| 26 | readonly accountId: string; | |
| 27 | } | |
| 28 | ||
| 24 | 29 | export class CloudSession extends Context.Service< |
| 25 | 30 | CloudSession, |
| 26 | 31 | { |
| 32 | readonly identity: Effect.Effect<Option.Option<CloudSessionIdentity>>; | |
| 27 | 33 | readonly clerkToken: Effect.Effect<string, ConnectionAttemptError>; |
| 28 | 34 | } |
| 29 | 35 | >()("@t3tools/client-runtime/platform/capabilities/CloudSession") {} |
packages/client-runtime/src/platform/storageDocument.test.ts+86| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Schema from "effect/Schema"; | |
| 3 | 4 | |
| 4 | 5 | import * as TokenStore from "../authorization/tokenStore.ts"; |
| 5 | 6 | import { |
| ... | ... | @@ -16,13 +17,19 @@ import { |
| 16 | 17 | SshConnectionTarget, |
| 17 | 18 | } from "../connection/model.ts"; |
| 18 | 19 | import { |
| 20 | ConnectionCatalogDocument, | |
| 19 | 21 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 22 | putRemoteDpopTokenInCatalog, | |
| 20 | 23 | registerConnectionInCatalog, |
| 21 | 24 | removeConnectionFromCatalog, |
| 22 | 25 | } from "./storageDocument.ts"; |
| 23 | 26 | |
| 24 | 27 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 25 | 28 | |
| 29 | const RELAY_TARGET = new RelayConnectionTarget({ | |
| 30 | environmentId: ENVIRONMENT_ID, | |
| 31 | label: "Remote", | |
| 32 | }); | |
| 26 | 33 | const BEARER_TARGET = new BearerConnectionTarget({ |
| 27 | 34 | environmentId: ENVIRONMENT_ID, |
| 28 | 35 | label: "Remote", |
| ... | ... | @@ -52,6 +59,26 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ |
| 52 | 59 | }); |
| 53 | 60 | |
| 54 | 61 | describe("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 | 82 | it("registers a bearer connection as one catalog mutation", () => { |
| 56 | 83 | const document = registerConnectionInCatalog( |
| 57 | 84 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| ... | ... | @@ -117,6 +144,65 @@ describe("ConnectionCatalogDocument", () => { |
| 117 | 144 | ); |
| 118 | 145 | }); |
| 119 | 146 | |
| 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 | 206 | it("persists the normalized SSH profile beside its target", () => { |
| 121 | 207 | const target = new SshConnectionTarget({ |
| 122 | 208 | environmentId: ENVIRONMENT_ID, |
packages/client-runtime/src/platform/storageDocument.ts+21| ... | ... | @@ -139,3 +139,24 @@ export function removeConnectionFromCatalog( |
| 139 | 139 | ): ConnectionCatalogDocument { |
| 140 | 140 | return removeConnectionMetadata(document, target, true); |
| 141 | 141 | } |
| 142 | ||
| 143 | export 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 | 62 | const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null); |
| 63 | 63 | const secondListCall = yield* Deferred.make<void>(); |
| 64 | 64 | const clerkToken = yield* Ref.make<string | null>("clerk-token"); |
| 65 | const sessionIdentity = { accountId: "account-1" }; | |
| 65 | 66 | const wakeups = yield* SubscriptionRef.make<{ |
| 66 | 67 | readonly sequence: number; |
| 67 | 68 | readonly reason: "application-active" | "credentials-changed"; |
| ... | ... | @@ -127,6 +128,11 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 127 | 128 | Layer.succeed( |
| 128 | 129 | ClientCapabilities.CloudSession, |
| 129 | 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 | 136 | clerkToken: Ref.get(clerkToken).pipe( |
| 131 | 137 | Effect.flatMap((token) => |
| 132 | 138 | token === null |
| ... | ... | @@ -283,6 +289,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 283 | 289 | Layer.mergeAll( |
| 284 | 290 | Layer.succeed(ManagedRelay.ManagedRelayClient, client), |
| 285 | 291 | Layer.succeed(ClientCapabilities.CloudSession, { |
| 292 | identity: Effect.succeed(Option.some({ accountId: "account-1" })), | |
| 286 | 293 | clerkToken: Effect.succeed("clerk-token"), |
| 287 | 294 | }), |
| 288 | 295 | Layer.succeed(Connectivity.Connectivity, { |
packages/client-runtime/src/state/environmentHttpAuth.test.ts created+531| ... | ... | @@ -0,0 +1,531 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | ProjectId, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | type AuthSessionState, | |
| 8 | type OrchestrationShellSnapshot, | |
| 9 | type OrchestrationThreadDetailSnapshot, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Fiber from "effect/Fiber"; | |
| 14 | import * as Layer from "effect/Layer"; | |
| 15 | import * as Option from "effect/Option"; | |
| 16 | import { TestClock } from "effect/testing"; | |
| 17 | import type { HttpClient } from "effect/unstable/http"; | |
| 18 | ||
| 19 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 20 | import { | |
| 21 | ConnectionTransientError, | |
| 22 | RelayConnectionTarget, | |
| 23 | type PreparedConnection, | |
| 24 | type PreparedHttpAuthorization, | |
| 25 | } from "../connection/model.ts"; | |
| 26 | import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "../relay/managedRelay.ts"; | |
| 27 | import { remoteHttpClientLayer, type RemoteEnvironmentRequestError } from "../rpc/http.ts"; | |
| 28 | import { | |
| 29 | fetchEnvironmentPullRequestDiff, | |
| 30 | type PullRequestDiffCredentialRejectedError, | |
| 31 | PullRequestDiffLoader, | |
| 32 | pullRequestDiffLoaderLayer, | |
| 33 | } from "./pullRequestDiffHttp.ts"; | |
| 34 | import { fetchEnvironmentSessionState } from "./session.ts"; | |
| 35 | import { fetchEnvironmentShellSnapshot } from "./shellSnapshotHttp.ts"; | |
| 36 | import { fetchEnvironmentThreadSnapshot } from "./threadSnapshotHttp.ts"; | |
| 37 | ||
| 38 | const TARGET = new RelayConnectionTarget({ | |
| 39 | environmentId: EnvironmentId.make("environment-1"), | |
| 40 | label: "Remote environment", | |
| 41 | }); | |
| 42 | const 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 | }; | |
| 50 | const CURRENT_ORIGIN = "https://current.example.test"; | |
| 51 | const RENEWED_ORIGIN = "https://renewed.example.test"; | |
| 52 | const DIFF = { | |
| 53 | projectId: ProjectId.make("project-1"), | |
| 54 | repository: "owner/repository", | |
| 55 | number: 42, | |
| 56 | }; | |
| 57 | const DIFF_RESULT = { patch: "diff --git a/file.ts b/file.ts", truncated: false, nextCursor: null }; | |
| 58 | const AUTH = { | |
| 59 | policy: "remote-reachable", | |
| 60 | bootstrapMethods: ["one-time-token"], | |
| 61 | sessionMethods: ["dpop-access-token"], | |
| 62 | sessionCookieName: "t3_session", | |
| 63 | } satisfies AuthSessionState["auth"]; | |
| 64 | const SESSION = { | |
| 65 | authenticated: true, | |
| 66 | auth: AUTH, | |
| 67 | scopes: ["orchestration:read", "orchestration:operate"], | |
| 68 | sessionMethod: "dpop-access-token", | |
| 69 | } satisfies AuthSessionState; | |
| 70 | const UNAUTHENTICATED_SESSION = { authenticated: false, auth: AUTH } satisfies AuthSessionState; | |
| 71 | const SHELL = { | |
| 72 | snapshotSequence: 1, | |
| 73 | projects: [], | |
| 74 | threads: [], | |
| 75 | updatedAt: "2026-09-04T00:00:00.000Z", | |
| 76 | } satisfies OrchestrationShellSnapshot; | |
| 77 | const 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 | ||
| 104 | function 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 | ||
| 116 | function 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 | ||
| 167 | type HttpInput = ReturnType<typeof makeHarness>["input"]; | |
| 168 | const 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 | ||
| 216 | describe("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 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | import { FetchHttpClient, type HttpMethod } from "effect/unstable/http"; | |
| 3 | import * as Result from "effect/Result"; | |
| 4 | import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/http"; | |
| 4 | 5 | |
| 5 | import type { PreparedHttpAuthorization } from "../connection/model.ts"; | |
| 6 | import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 7 | import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; | |
| 6 | 8 | import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 7 | import { RemoteEnvironmentAuthFetchError } from "../rpc/http.ts"; | |
| 9 | import { | |
| 10 | executeEnvironmentHttpRequest, | |
| 11 | makeEnvironmentHttpApiClient, | |
| 12 | RemoteEnvironmentAuthFetchError, | |
| 13 | RemoteEnvironmentAuthTimeoutError, | |
| 14 | type RemoteEnvironmentRequestError, | |
| 15 | } from "../rpc/http.ts"; | |
| 8 | 16 | |
| 9 | 17 | export interface EnvironmentHttpAuthHeaders { |
| 10 | 18 | readonly authorization?: string; |
| ... | ... | @@ -28,8 +36,7 @@ export const withEnvironmentCredentials = <A, E, R>( |
| 28 | 36 | : request; |
| 29 | 37 | |
| 30 | 38 | /** |
| 31 | * Build the authorization headers for an authenticated environment HTTP | |
| 32 | * request, matching the credential the connection was prepared with: | |
| 39 | * Build request-bound headers from the current environment credential: | |
| 33 | 40 | * - primary/local connections carry no credential, |
| 34 | 41 | * - bearer connections send a static `Bearer` token, |
| 35 | 42 | * - relay connections send a `DPoP` access token with a freshly signed proof |
| ... | ... | @@ -71,3 +78,106 @@ export const buildEnvironmentAuthHeaders = ( |
| 71 | 78 | ); |
| 72 | 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 | */ | |
| 87 | export 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 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | import { HttpClient } from "effect/unstable/http"; |
| 12 | 12 | |
| 13 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 13 | 14 | import type { PreparedConnection } from "../connection/model.ts"; |
| 14 | 15 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 15 | 16 | import { |
| 16 | executeEnvironmentHttpRequest, | |
| 17 | makeEnvironmentHttpApiClient, | |
| 18 | 17 | makeEnvironmentHttpApiUrlBuilder, |
| 19 | 18 | type RemoteEnvironmentRequestError, |
| 20 | 19 | } from "../rpc/http.ts"; |
| 21 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | |
| 20 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; | |
| 22 | 21 | |
| 23 | 22 | const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000; |
| 24 | 23 | |
| ... | ... | @@ -46,26 +45,16 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( |
| 46 | 45 | readonly prepared: PreparedConnection; |
| 47 | 46 | readonly diff: PullRequestDiffInput; |
| 48 | 47 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 48 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; | |
| 49 | 49 | readonly timeoutMs?: number; |
| 50 | 50 | }) { |
| 51 | const requestUrl = makeEnvironmentHttpApiUrlBuilder( | |
| 52 | input.prepared.httpBaseUrl, | |
| 53 | ).pullRequests.diff(); | |
| 54 | const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); | |
| 55 | const headers = yield* buildEnvironmentAuthHeaders( | |
| 56 | input.prepared.httpAuthorization, | |
| 57 | "POST", | |
| 58 | requestUrl, | |
| 59 | input.signer, | |
| 60 | ); | |
| 61 | return yield* executeEnvironmentHttpRequest( | |
| 62 | requestUrl, | |
| 63 | input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS, | |
| 64 | withEnvironmentCredentials( | |
| 65 | input.prepared.httpAuthorization, | |
| 66 | client.pullRequests.diff({ payload: input.diff, headers }), | |
| 67 | ), | |
| 68 | ).pipe( | |
| 51 | return yield* executeAuthenticatedEnvironmentHttpRequest({ | |
| 52 | ...input, | |
| 53 | method: "POST", | |
| 54 | url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(), | |
| 55 | timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS, | |
| 56 | request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }), | |
| 57 | }).pipe( | |
| 69 | 58 | Effect.mapError((error) => |
| 70 | 59 | error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" |
| 71 | 60 | ? new PullRequestDiffCredentialRejectedError({ |
| ... | ... | @@ -98,11 +87,15 @@ export const pullRequestDiffLoaderLayer: Layer.Layer< |
| 98 | 87 | Effect.gen(function* () { |
| 99 | 88 | const httpClient = yield* HttpClient.HttpClient; |
| 100 | 89 | const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); |
| 90 | const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); | |
| 101 | 91 | return PullRequestDiffLoader.of({ |
| 102 | 92 | load: (prepared, input) => |
| 103 | fetchEnvironmentPullRequestDiff({ prepared, diff: input, signer }).pipe( | |
| 104 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 105 | ), | |
| 93 | fetchEnvironmentPullRequestDiff({ | |
| 94 | prepared, | |
| 95 | diff: input, | |
| 96 | signer, | |
| 97 | remoteAuthorization, | |
| 98 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), | |
| 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 | 6 | import type { HttpClient } from "effect/unstable/http"; |
| 7 | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 8 | 8 | |
| 9 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 9 | 10 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 10 | 11 | import type { PreparedConnection } from "../connection/model.ts"; |
| 11 | 12 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 12 | 13 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 13 | 14 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 14 | 15 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 15 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | |
| 16 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | |
| 16 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; | |
| 17 | 17 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 18 | 18 | |
| 19 | 19 | function initialConfigOption<E>( |
| ... | ... | @@ -36,29 +36,26 @@ const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | 38 | * Read the granted scopes of this client's session on one environment via its |
| 39 | * `/api/auth/session` endpoint, authenticated with whatever credential the | |
| 40 | * connection was prepared with (cookie, bearer, or DPoP). | |
| 39 | * `/api/auth/session` endpoint, using the connection's authentication method | |
| 40 | * and refreshing relay credentials when needed. | |
| 41 | 41 | */ |
| 42 | 42 | export const fetchEnvironmentSessionState = Effect.fn( |
| 43 | 43 | "clientRuntime.state.fetchEnvironmentSessionState", |
| 44 | 44 | )(function* (input: { |
| 45 | 45 | readonly prepared: PreparedConnection; |
| 46 | 46 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 47 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; | |
| 47 | 48 | readonly timeoutMs?: number; |
| 48 | 49 | }) { |
| 49 | const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); | |
| 50 | const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); | |
| 51 | const headers = yield* buildEnvironmentAuthHeaders( | |
| 52 | input.prepared.httpAuthorization, | |
| 53 | "GET", | |
| 54 | requestUrl, | |
| 55 | input.signer, | |
| 56 | ); | |
| 57 | return yield* executeEnvironmentHttpRequest( | |
| 58 | requestUrl, | |
| 59 | input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, | |
| 60 | withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), | |
| 61 | ); | |
| 50 | return yield* executeAuthenticatedEnvironmentHttpRequest({ | |
| 51 | ...input, | |
| 52 | method: "GET", | |
| 53 | url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"), | |
| 54 | timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, | |
| 55 | request: ({ client, headers }) => client.auth.session({ headers }), | |
| 56 | // This endpoint returns 200 with authenticated:false for expired credentials. | |
| 57 | isUnauthorizedResponse: (response) => !response.authenticated, | |
| 58 | }); | |
| 62 | 59 | }); |
| 63 | 60 | |
| 64 | 61 | export function createEnvironmentSessionAtoms<R, E>( |
| ... | ... | @@ -134,7 +131,8 @@ export function createEnvironmentSessionAtoms<R, E>( |
| 134 | 131 | } |
| 135 | 132 | return Effect.gen(function* () { |
| 136 | 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 | 138 | .pipe( |
packages/client-runtime/src/state/shellSnapshotHttp.ts+12-19| ... | ... | @@ -6,11 +6,11 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import { HttpClient } from "effect/unstable/http"; |
| 8 | 8 | |
| 9 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 9 | 10 | import type { PreparedConnection } from "../connection/model.ts"; |
| 10 | 11 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 11 | 12 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 12 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | |
| 13 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | |
| 13 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; | |
| 14 | 14 | |
| 15 | 15 | // Bounded so a pathologically slow endpoint cannot block the (cheaper) socket |
| 16 | 16 | // fallback for long. The cached shell renders while this runs. |
| ... | ... | @@ -27,24 +27,16 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( |
| 27 | 27 | )(function* (input: { |
| 28 | 28 | readonly prepared: PreparedConnection; |
| 29 | 29 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 30 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; | |
| 30 | 31 | readonly timeoutMs?: number; |
| 31 | 32 | }) { |
| 32 | const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/orchestration/shell"); | |
| 33 | const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); | |
| 34 | const headers = yield* buildEnvironmentAuthHeaders( | |
| 35 | input.prepared.httpAuthorization, | |
| 36 | "GET", | |
| 37 | requestUrl, | |
| 38 | input.signer, | |
| 39 | ); | |
| 40 | return yield* executeEnvironmentHttpRequest( | |
| 41 | requestUrl, | |
| 42 | input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, | |
| 43 | withEnvironmentCredentials( | |
| 44 | input.prepared.httpAuthorization, | |
| 45 | client.orchestration.shellSnapshot({ headers }), | |
| 46 | ), | |
| 47 | ); | |
| 33 | return yield* executeAuthenticatedEnvironmentHttpRequest({ | |
| 34 | ...input, | |
| 35 | method: "GET", | |
| 36 | url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), | |
| 37 | timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, | |
| 38 | request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }), | |
| 39 | }); | |
| 48 | 40 | }); |
| 49 | 41 | |
| 50 | 42 | /** |
| ... | ... | @@ -73,9 +65,10 @@ export const shellSnapshotLoaderLayer: Layer.Layer< |
| 73 | 65 | // Resolve the DPoP signer optionally: it is only needed for relay/DPoP |
| 74 | 66 | // connections, so the loader must not hard-require it. |
| 75 | 67 | const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); |
| 68 | const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); | |
| 76 | 69 | return ShellSnapshotLoader.of({ |
| 77 | 70 | load: (prepared: PreparedConnection) => |
| 78 | fetchEnvironmentShellSnapshot({ prepared, signer }).pipe( | |
| 71 | fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe( | |
| 79 | 72 | Effect.map(Option.some<OrchestrationShellSnapshot>), |
| 80 | 73 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 81 | 74 | Effect.catchCause((cause) => |
packages/client-runtime/src/state/threadSnapshotHttp.ts+14-24| ... | ... | @@ -6,15 +6,12 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import { HttpClient } from "effect/unstable/http"; |
| 8 | 8 | |
| 9 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 9 | 10 | import type { PreparedConnection } from "../connection/model.ts"; |
| 10 | 11 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; |
| 11 | 12 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 12 | import { | |
| 13 | executeEnvironmentHttpRequest, | |
| 14 | makeEnvironmentHttpApiClient, | |
| 15 | type RemoteEnvironmentRequestError, | |
| 16 | } from "../rpc/http.ts"; | |
| 17 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | |
| 13 | import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; | |
| 14 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; | |
| 18 | 15 | |
| 19 | 16 | // Bounded so a pathologically slow endpoint cannot block the (cheaper) socket |
| 20 | 17 | // fallback for long. The cached thread renders while this runs, so the wait only |
| ... | ... | @@ -42,25 +39,17 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( |
| 42 | 39 | readonly prepared: PreparedConnection; |
| 43 | 40 | readonly threadId: ThreadId; |
| 44 | 41 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 42 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; | |
| 45 | 43 | readonly timeoutMs?: number; |
| 46 | 44 | readonly window?: ThreadSnapshotWindow; |
| 47 | 45 | }) { |
| 48 | const requestUrl = environmentEndpointUrl( | |
| 49 | input.prepared.httpBaseUrl, | |
| 50 | `/api/orchestration/threads/${input.threadId}`, | |
| 51 | ); | |
| 52 | const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); | |
| 53 | const headers = yield* buildEnvironmentAuthHeaders( | |
| 54 | input.prepared.httpAuthorization, | |
| 55 | "GET", | |
| 56 | requestUrl, | |
| 57 | input.signer, | |
| 58 | ); | |
| 59 | return yield* executeEnvironmentHttpRequest( | |
| 60 | requestUrl, | |
| 61 | input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, | |
| 62 | withEnvironmentCredentials( | |
| 63 | input.prepared.httpAuthorization, | |
| 46 | return yield* executeAuthenticatedEnvironmentHttpRequest({ | |
| 47 | ...input, | |
| 48 | method: "GET", | |
| 49 | url: (httpBaseUrl) => | |
| 50 | environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), | |
| 51 | timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, | |
| 52 | request: ({ client, headers }) => | |
| 64 | 53 | client.orchestration.threadSnapshot({ |
| 65 | 54 | params: { threadId: input.threadId }, |
| 66 | 55 | payload: { |
| ... | ... | @@ -71,8 +60,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( |
| 71 | 60 | }, |
| 72 | 61 | headers, |
| 73 | 62 | }), |
| 74 | ), | |
| 75 | ); | |
| 63 | }); | |
| 76 | 64 | }); |
| 77 | 65 | |
| 78 | 66 | export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; |
| ... | ... | @@ -106,12 +94,14 @@ export const threadSnapshotLoaderLayer: Layer.Layer< |
| 106 | 94 | // connections, so the loader must not hard-require it (bearer/primary |
| 107 | 95 | // connections work without one). |
| 108 | 96 | const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); |
| 97 | const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); | |
| 109 | 98 | return ThreadSnapshotLoader.of({ |
| 110 | 99 | load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) => |
| 111 | 100 | fetchEnvironmentThreadSnapshot({ |
| 112 | 101 | prepared, |
| 113 | 102 | threadId, |
| 114 | 103 | signer, |
| 104 | remoteAuthorization, | |
| 115 | 105 | ...(window !== undefined ? { window } : {}), |
| 116 | 106 | }).pipe( |
| 117 | 107 | Effect.map(Option.some<OrchestrationThreadDetailSnapshot>), |