| author | |
| committer | |
| log | 9e646ad84c9d5008187eabf479e85619df9de8ca |
| tree | d3f1c50d65483d8ed77fa03dfa826aa0c92abc1e |
| parent | a19f01fc19d209d9962c4bb66372ed9e03e320a9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>8 files changed, 153 insertions(+), 9 deletions(-)
packages/client-runtime/src/authorization/service.ts+7-3| ... | ... | @@ -30,7 +30,10 @@ import * as Ref from "effect/Ref"; |
| 30 | 30 | import * as Result from "effect/Result"; |
| 31 | 31 | import * as HttpClient from "effect/unstable/http/HttpClient"; |
| 32 | 32 | |
| 33 | import type { PreparedHttpAuthorization } from "../connection/model.ts"; | |
| 33 | import { | |
| 34 | DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, | |
| 35 | type PreparedHttpAuthorization, | |
| 36 | } from "../connection/model.ts"; | |
| 34 | 37 | |
| 35 | 38 | export interface RelayEnvironmentAuthorization { |
| 36 | 39 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -66,7 +69,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service< |
| 66 | 69 | } |
| 67 | 70 | >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} |
| 68 | 71 | |
| 69 | const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; | |
| 70 | 72 | const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; |
| 71 | 73 | const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; |
| 72 | 74 | |
| ... | ... | @@ -216,7 +218,7 @@ export const make = Effect.gen(function* () { |
| 216 | 218 | Option.isSome(cached) && |
| 217 | 219 | cached.value.environmentId === input.expectedEnvironmentId && |
| 218 | 220 | cached.value.dpopThumbprint === thumbprint && |
| 219 | cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS | |
| 221 | cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS | |
| 220 | 222 | ) { |
| 221 | 223 | yield* Effect.annotateCurrentSpan({ |
| 222 | 224 | "connection.remote_token_cache": "hit", |
| ... | ... | @@ -234,6 +236,7 @@ export const make = Effect.gen(function* () { |
| 234 | 236 | httpAuthorization: { |
| 235 | 237 | _tag: "Dpop" as const, |
| 236 | 238 | accessToken: cached.value.accessToken, |
| 239 | expiresAtEpochMs: cached.value.expiresAtEpochMs, | |
| 237 | 240 | }, |
| 238 | 241 | }; |
| 239 | 242 | } |
| ... | ... | @@ -305,6 +308,7 @@ export const make = Effect.gen(function* () { |
| 305 | 308 | httpAuthorization: { |
| 306 | 309 | _tag: "Dpop" as const, |
| 307 | 310 | accessToken: token.accessToken, |
| 311 | expiresAtEpochMs: token.expiresAtEpochMs, | |
| 308 | 312 | }, |
| 309 | 313 | }; |
| 310 | 314 | }, |
packages/client-runtime/src/connection/model.ts+3| ... | ... | @@ -103,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBl |
| 103 | 103 | |
| 104 | 104 | export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; |
| 105 | 105 | |
| 106 | export const DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000; | |
| 107 | ||
| 106 | 108 | export type PreparedHttpAuthorization = |
| 107 | 109 | | { |
| 108 | 110 | readonly _tag: "Bearer"; |
| ... | ... | @@ -111,6 +113,7 @@ export type PreparedHttpAuthorization = |
| 111 | 113 | | { |
| 112 | 114 | readonly _tag: "Dpop"; |
| 113 | 115 | readonly accessToken: string; |
| 116 | readonly expiresAtEpochMs: number; | |
| 114 | 117 | }; |
| 115 | 118 | |
| 116 | 119 | export interface PreparedConnection { |
packages/client-runtime/src/connection/resolver.test.ts+3| ... | ... | @@ -140,6 +140,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 140 | 140 | httpAuthorization: { |
| 141 | 141 | _tag: "Dpop" as const, |
| 142 | 142 | accessToken: "dpop-access-token", |
| 143 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 143 | 144 | }, |
| 144 | 145 | }), |
| 145 | 146 | )), |
| ... | ... | @@ -357,6 +358,7 @@ describe("ConnectionResolver", () => { |
| 357 | 358 | httpAuthorization: { |
| 358 | 359 | _tag: "Dpop" as const, |
| 359 | 360 | accessToken: "dpop-access-token", |
| 361 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 360 | 362 | }, |
| 361 | 363 | }), |
| 362 | 364 | ), |
| ... | ... | @@ -394,6 +396,7 @@ describe("ConnectionResolver", () => { |
| 394 | 396 | httpAuthorization: { |
| 395 | 397 | _tag: "Dpop" as const, |
| 396 | 398 | accessToken: "dpop-access-token", |
| 399 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | |
| 397 | 400 | }, |
| 398 | 401 | }), |
| 399 | 402 | Effect.withSpan("test.remote.authorizeDpop"), |
packages/client-runtime/src/connection/supervisor.test.ts+38| ... | ... | @@ -16,6 +16,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 16 | 16 | import * as Connectivity from "./connectivity.ts"; |
| 17 | 17 | import * as ConnectionDriver from "./driver.ts"; |
| 18 | 18 | import { |
| 19 | DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, | |
| 19 | 20 | ConnectionBlockedError, |
| 20 | 21 | ConnectionTransientError, |
| 21 | 22 | PrimaryConnectionTarget, |
| ... | ... | @@ -1096,6 +1097,43 @@ describe("EnvironmentSupervisor", () => { |
| 1096 | 1097 | }), |
| 1097 | 1098 | ); |
| 1098 | 1099 | |
| 1100 | it.effect("renews a relay connection before its DPoP access token expires", () => | |
| 1101 | Effect.gen(function* () { | |
| 1102 | const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; | |
| 1103 | const harness = yield* makeHarness({ | |
| 1104 | prepare: (attempt) => | |
| 1105 | Effect.succeed({ | |
| 1106 | ...PREPARED_CONNECTION, | |
| 1107 | target: RELAY_TARGET, | |
| 1108 | httpAuthorization: { | |
| 1109 | _tag: "Dpop", | |
| 1110 | accessToken: `access-token-${attempt}`, | |
| 1111 | expiresAtEpochMs: tokenLifetimeMs * attempt, | |
| 1112 | }, | |
| 1113 | }), | |
| 1114 | }); | |
| 1115 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { | |
| 1116 | initiallyDesired: true, | |
| 1117 | }).pipe(Effect.provide(harness.dependencies)); | |
| 1118 | ||
| 1119 | yield* awaitState(supervisor.state, (state) => state.phase === "connected"); | |
| 1120 | yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1); | |
| 1121 | expect(yield* Ref.get(harness.sessionCount)).toBe(1); | |
| 1122 | ||
| 1123 | yield* TestClock.adjust(1); | |
| 1124 | yield* awaitState( | |
| 1125 | supervisor.state, | |
| 1126 | (state) => state.phase === "connected" && state.generation === 2, | |
| 1127 | ); | |
| 1128 | ||
| 1129 | expect(yield* Ref.get(harness.sessionCount)).toBe(2); | |
| 1130 | expect(yield* Ref.get(harness.releaseCount)).toBe(1); | |
| 1131 | expect( | |
| 1132 | Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).httpAuthorization, | |
| 1133 | ).toMatchObject({ accessToken: "access-token-2" }); | |
| 1134 | }).pipe(Effect.provide(TestClock.layer())), | |
| 1135 | ); | |
| 1136 | ||
| 1099 | 1137 | it.effect("interrupts relay setup when credentials change", () => |
| 1100 | 1138 | Effect.gen(function* () { |
| 1101 | 1139 | const firstAttemptStarted = yield* Deferred.make<void>(); |
packages/client-runtime/src/connection/supervisor.ts+19-2| ... | ... | @@ -18,6 +18,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 18 | 18 | import * as Connectivity from "./connectivity.ts"; |
| 19 | 19 | import * as ConnectionDriver from "./driver.ts"; |
| 20 | 20 | import { |
| 21 | DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, | |
| 21 | 22 | type ConnectionAttemptError, |
| 22 | 23 | type ConnectionTarget, |
| 23 | 24 | ConnectionTransientError, |
| ... | ... | @@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 487 | 488 | } |
| 488 | 489 | }); |
| 489 | 490 | |
| 491 | const waitForAuthorizationRefresh = Effect.fnUntraced(function* ( | |
| 492 | preparedConnection: PreparedConnection, | |
| 493 | ) { | |
| 494 | const authorization = preparedConnection.httpAuthorization; | |
| 495 | if (authorization?._tag !== "Dpop") { | |
| 496 | return yield* Effect.never; | |
| 497 | } | |
| 498 | const now = yield* Clock.currentTimeMillis; | |
| 499 | yield* Effect.sleep( | |
| 500 | Math.max(0, authorization.expiresAtEpochMs - now - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS), | |
| 501 | ); | |
| 502 | yield* Effect.logDebug("Refreshing the environment connection before its DPoP token expires."); | |
| 503 | return true; | |
| 504 | }); | |
| 505 | ||
| 490 | 506 | const runAttempt = Effect.fnUntraced(function* ( |
| 491 | 507 | attempt: number, |
| 492 | 508 | generation: number, |
| ... | ... | @@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 584 | 600 | retryAt: null, |
| 585 | 601 | }); |
| 586 | 602 | |
| 587 | const connectedExit = yield* Effect.raceFirst( | |
| 603 | const connectedExit = yield* Effect.raceAllFirst([ | |
| 588 | 604 | active.lease.session.closed.pipe( |
| 589 | 605 | Effect.mapError( |
| 590 | 606 | (error): TracedAttemptFailure => ({ |
| ... | ... | @@ -601,7 +617,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 601 | 617 | }), |
| 602 | 618 | ), |
| 603 | 619 | ), |
| 604 | ).pipe(exitUnlessInterrupted); | |
| 620 | waitForAuthorizationRefresh(active.lease.prepared), | |
| 621 | ]).pipe(exitUnlessInterrupted); | |
| 605 | 622 | const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; |
| 606 | 623 | if (Exit.isSuccess(connectedExit)) { |
| 607 | 624 | return { |
packages/client-runtime/src/state/pullRequestDiffHttp.test.ts+41-1| ... | ... | @@ -5,7 +5,10 @@ import * as Option from "effect/Option"; |
| 5 | 5 | |
| 6 | 6 | import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; |
| 7 | 7 | import { remoteHttpClientLayer } from "../rpc/http.ts"; |
| 8 | import { fetchEnvironmentPullRequestDiff } from "./pullRequestDiffHttp.ts"; | |
| 8 | import { | |
| 9 | fetchEnvironmentPullRequestDiff, | |
| 10 | PullRequestDiffCredentialRejectedError, | |
| 11 | } from "./pullRequestDiffHttp.ts"; | |
| 9 | 12 | |
| 10 | 13 | const TARGET = new PrimaryConnectionTarget({ |
| 11 | 14 | environmentId: EnvironmentId.make("environment-1"), |
| ... | ... | @@ -77,4 +80,41 @@ describe("fetchEnvironmentPullRequestDiff", () => { |
| 77 | 80 | }); |
| 78 | 81 | }), |
| 79 | 82 | ); |
| 83 | ||
| 84 | it.effect("gives rejected diff sessions a recovery action", () => | |
| 85 | Effect.gen(function* () { | |
| 86 | const fetchFn = (() => | |
| 87 | Promise.resolve( | |
| 88 | Response.json( | |
| 89 | { | |
| 90 | _tag: "EnvironmentAuthInvalidError", | |
| 91 | code: "auth_invalid", | |
| 92 | reason: "invalid_credential", | |
| 93 | traceId: "trace-auth-test", | |
| 94 | }, | |
| 95 | { status: 401 }, | |
| 96 | ), | |
| 97 | )) satisfies typeof fetch; | |
| 98 | ||
| 99 | const error = yield* fetchEnvironmentPullRequestDiff({ | |
| 100 | prepared: PREPARED, | |
| 101 | signer: Option.none(), | |
| 102 | diff: { | |
| 103 | projectId: ProjectId.make("project-1"), | |
| 104 | repository: "owner/repository", | |
| 105 | number: 42, | |
| 106 | }, | |
| 107 | }).pipe(Effect.provide(remoteHttpClientLayer(fetchFn)), Effect.flip); | |
| 108 | ||
| 109 | expect(error).toBeInstanceOf(PullRequestDiffCredentialRejectedError); | |
| 110 | expect(error).toMatchObject({ | |
| 111 | repository: "owner/repository", | |
| 112 | number: 42, | |
| 113 | traceId: "trace-auth-test", | |
| 114 | }); | |
| 115 | expect(error.message).toBe( | |
| 116 | "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code.", | |
| 117 | ); | |
| 118 | }), | |
| 119 | ); | |
| 80 | 120 | }); |
packages/client-runtime/src/state/pullRequestDiffHttp.ts+36-2| ... | ... | @@ -1,8 +1,13 @@ |
| 1 | import type { PullRequestDiffInput, PullRequestDiffResult } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | EnvironmentAuthInvalidError, | |
| 3 | type PullRequestDiffInput, | |
| 4 | type PullRequestDiffResult, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import * as Context from "effect/Context"; |
| 3 | 7 | import * as Effect from "effect/Effect"; |
| 4 | 8 | import * as Layer from "effect/Layer"; |
| 5 | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; | |
| 6 | 11 | import { HttpClient } from "effect/unstable/http"; |
| 7 | 12 | |
| 8 | 13 | import type { PreparedConnection } from "../connection/model.ts"; |
| ... | ... | @@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir |
| 17 | 22 | |
| 18 | 23 | const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000; |
| 19 | 24 | |
| 25 | export class PullRequestDiffCredentialRejectedError extends Schema.TaggedErrorClass<PullRequestDiffCredentialRejectedError>()( | |
| 26 | "PullRequestDiffCredentialRejectedError", | |
| 27 | { | |
| 28 | repository: Schema.String, | |
| 29 | number: Schema.Number, | |
| 30 | traceId: Schema.String, | |
| 31 | cause: EnvironmentAuthInvalidError, | |
| 32 | }, | |
| 33 | ) { | |
| 34 | override get message(): string { | |
| 35 | return "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code."; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export type PullRequestDiffLoadError = | |
| 40 | | RemoteEnvironmentRequestError | |
| 41 | | PullRequestDiffCredentialRejectedError; | |
| 42 | ||
| 20 | 43 | export const fetchEnvironmentPullRequestDiff = Effect.fn( |
| 21 | 44 | "clientRuntime.state.fetchEnvironmentPullRequestDiff", |
| 22 | 45 | )(function* (input: { |
| ... | ... | @@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( |
| 42 | 65 | input.prepared.httpAuthorization, |
| 43 | 66 | client.pullRequests.diff({ payload: input.diff, headers }), |
| 44 | 67 | ), |
| 68 | ).pipe( | |
| 69 | Effect.mapError((error) => | |
| 70 | error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" | |
| 71 | ? new PullRequestDiffCredentialRejectedError({ | |
| 72 | repository: input.diff.repository, | |
| 73 | number: input.diff.number, | |
| 74 | traceId: error.traceId, | |
| 75 | cause: error, | |
| 76 | }) | |
| 77 | : error, | |
| 78 | ), | |
| 45 | 79 | ); |
| 46 | 80 | }); |
| 47 | 81 | |
| ... | ... | @@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service< |
| 51 | 85 | readonly load: ( |
| 52 | 86 | prepared: PreparedConnection, |
| 53 | 87 | input: PullRequestDiffInput, |
| 54 | ) => Effect.Effect<PullRequestDiffResult, RemoteEnvironmentRequestError>; | |
| 88 | ) => Effect.Effect<PullRequestDiffResult, PullRequestDiffLoadError>; | |
| 55 | 89 | } |
| 56 | 90 | >()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {} |
| 57 | 91 |
packages/client-runtime/src/state/pullRequests.ts+6-1| ... | ... | @@ -20,7 +20,12 @@ import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| 20 | 20 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 21 | 21 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 22 | 22 | |
| 23 | export { PullRequestDiffLoader, pullRequestDiffLoaderLayer } from "./pullRequestDiffHttp.ts"; | |
| 23 | export { | |
| 24 | type PullRequestDiffLoadError, | |
| 25 | PullRequestDiffCredentialRejectedError, | |
| 26 | PullRequestDiffLoader, | |
| 27 | pullRequestDiffLoaderLayer, | |
| 28 | } from "./pullRequestDiffHttp.ts"; | |
| 24 | 29 | |
| 25 | 30 | export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( |
| 26 | 31 | "EnvironmentHttpConnectionNotReadyError", |