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