authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 03:56:08-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 03:56:08-04:00
log9e646ad84c9d5008187eabf479e85619df9de8ca
treed3f1c50d65483d8ed77fa03dfa826aa0c92abc1e
parenta19f01fc19d209d9962c4bb66372ed9e03e320a9
signature Signed by PGP key B5690EEEBB952194

fix(connect): refresh relay credentials before expiry (#9178)

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";
3030import * as Result from "effect/Result";
3131import * as HttpClient from "effect/unstable/http/HttpClient";
3232
33import type { PreparedHttpAuthorization } from "../connection/model.ts";
33import {
34 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
35 type PreparedHttpAuthorization,
36} from "../connection/model.ts";
3437
3538export interface RelayEnvironmentAuthorization {
3639 readonly environmentId: EnvironmentId;
......@@ -66,7 +69,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
6669 }
6770>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
6871
69const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;
7072const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
7173const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
7274
......@@ -216,7 +218,7 @@ export const make = Effect.gen(function* () {
216218 Option.isSome(cached) &&
217219 cached.value.environmentId === input.expectedEnvironmentId &&
218220 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
220222 ) {
221223 yield* Effect.annotateCurrentSpan({
222224 "connection.remote_token_cache": "hit",
......@@ -234,6 +236,7 @@ export const make = Effect.gen(function* () {
234236 httpAuthorization: {
235237 _tag: "Dpop" as const,
236238 accessToken: cached.value.accessToken,
239 expiresAtEpochMs: cached.value.expiresAtEpochMs,
237240 },
238241 };
239242 }
......@@ -305,6 +308,7 @@ export const make = Effect.gen(function* () {
305308 httpAuthorization: {
306309 _tag: "Dpop" as const,
307310 accessToken: token.accessToken,
311 expiresAtEpochMs: token.expiresAtEpochMs,
308312 },
309313 };
310314 },
packages/client-runtime/src/connection/model.ts+3
......@@ -103,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBl
103103
104104export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;
105105
106export const DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
107
106108export type PreparedHttpAuthorization =
107109 | {
108110 readonly _tag: "Bearer";
......@@ -111,6 +113,7 @@ export type PreparedHttpAuthorization =
111113 | {
112114 readonly _tag: "Dpop";
113115 readonly accessToken: string;
116 readonly expiresAtEpochMs: number;
114117 };
115118
116119export interface PreparedConnection {
packages/client-runtime/src/connection/resolver.test.ts+3
......@@ -140,6 +140,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
140140 httpAuthorization: {
141141 _tag: "Dpop" as const,
142142 accessToken: "dpop-access-token",
143 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
143144 },
144145 }),
145146 )),
......@@ -357,6 +358,7 @@ describe("ConnectionResolver", () => {
357358 httpAuthorization: {
358359 _tag: "Dpop" as const,
359360 accessToken: "dpop-access-token",
361 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
360362 },
361363 }),
362364 ),
......@@ -394,6 +396,7 @@ describe("ConnectionResolver", () => {
394396 httpAuthorization: {
395397 _tag: "Dpop" as const,
396398 accessToken: "dpop-access-token",
399 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
397400 },
398401 }),
399402 Effect.withSpan("test.remote.authorizeDpop"),
packages/client-runtime/src/connection/supervisor.test.ts+38
......@@ -16,6 +16,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";
1616import * as Connectivity from "./connectivity.ts";
1717import * as ConnectionDriver from "./driver.ts";
1818import {
19 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
1920 ConnectionBlockedError,
2021 ConnectionTransientError,
2122 PrimaryConnectionTarget,
......@@ -1096,6 +1097,43 @@ describe("EnvironmentSupervisor", () => {
10961097 }),
10971098 );
10981099
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
10991137 it.effect("interrupts relay setup when credentials change", () =>
11001138 Effect.gen(function* () {
11011139 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";
1818import * as Connectivity from "./connectivity.ts";
1919import * as ConnectionDriver from "./driver.ts";
2020import {
21 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
2122 type ConnectionAttemptError,
2223 type ConnectionTarget,
2324 ConnectionTransientError,
......@@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
487488 }
488489 });
489490
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
490506 const runAttempt = Effect.fnUntraced(function* (
491507 attempt: number,
492508 generation: number,
......@@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
584600 retryAt: null,
585601 });
586602
587 const connectedExit = yield* Effect.raceFirst(
603 const connectedExit = yield* Effect.raceAllFirst([
588604 active.lease.session.closed.pipe(
589605 Effect.mapError(
590606 (error): TracedAttemptFailure => ({
......@@ -601,7 +617,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
601617 }),
602618 ),
603619 ),
604 ).pipe(exitUnlessInterrupted);
620 waitForAuthorizationRefresh(active.lease.prepared),
621 ]).pipe(exitUnlessInterrupted);
605622 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;
606623 if (Exit.isSuccess(connectedExit)) {
607624 return {
packages/client-runtime/src/state/pullRequestDiffHttp.test.ts+41-1
......@@ -5,7 +5,10 @@ import * as Option from "effect/Option";
55
66import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";
77import { remoteHttpClientLayer } from "../rpc/http.ts";
8import { fetchEnvironmentPullRequestDiff } from "./pullRequestDiffHttp.ts";
8import {
9 fetchEnvironmentPullRequestDiff,
10 PullRequestDiffCredentialRejectedError,
11} from "./pullRequestDiffHttp.ts";
912
1013const TARGET = new PrimaryConnectionTarget({
1114 environmentId: EnvironmentId.make("environment-1"),
......@@ -77,4 +80,41 @@ describe("fetchEnvironmentPullRequestDiff", () => {
7780 });
7881 }),
7982 );
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 );
80120});
packages/client-runtime/src/state/pullRequestDiffHttp.ts+36-2
......@@ -1,8 +1,13 @@
1import type { PullRequestDiffInput, PullRequestDiffResult } from "@t3tools/contracts";
1import {
2 EnvironmentAuthInvalidError,
3 type PullRequestDiffInput,
4 type PullRequestDiffResult,
5} from "@t3tools/contracts";
26import * as Context from "effect/Context";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
59import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
611import { HttpClient } from "effect/unstable/http";
712
813import type { PreparedConnection } from "../connection/model.ts";
......@@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir
1722
1823const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;
1924
25export 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
39export type PullRequestDiffLoadError =
40 | RemoteEnvironmentRequestError
41 | PullRequestDiffCredentialRejectedError;
42
2043export const fetchEnvironmentPullRequestDiff = Effect.fn(
2144 "clientRuntime.state.fetchEnvironmentPullRequestDiff",
2245)(function* (input: {
......@@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
4265 input.prepared.httpAuthorization,
4366 client.pullRequests.diff({ payload: input.diff, headers }),
4467 ),
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 ),
4579 );
4680});
4781
......@@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service<
5185 readonly load: (
5286 prepared: PreparedConnection,
5387 input: PullRequestDiffInput,
54 ) => Effect.Effect<PullRequestDiffResult, RemoteEnvironmentRequestError>;
88 ) => Effect.Effect<PullRequestDiffResult, PullRequestDiffLoadError>;
5589 }
5690>()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {}
5791
packages/client-runtime/src/state/pullRequests.ts+6-1
......@@ -20,7 +20,12 @@ import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
2020import type { EnvironmentRegistry } from "../connection/registry.ts";
2121import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2222
23export { PullRequestDiffLoader, pullRequestDiffLoaderLayer } from "./pullRequestDiffHttp.ts";
23export {
24 type PullRequestDiffLoadError,
25 PullRequestDiffCredentialRejectedError,
26 PullRequestDiffLoader,
27 pullRequestDiffLoaderLayer,
28} from "./pullRequestDiffHttp.ts";
2429
2530export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
2631 "EnvironmentHttpConnectionNotReadyError",