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";...@@ -30,7 +30,10 @@ import * as Ref from "effect/Ref";
30import * as Result from "effect/Result";30import * as Result from "effect/Result";
31import * as HttpClient from "effect/unstable/http/HttpClient";31import * 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
35export interface RelayEnvironmentAuthorization {38export interface RelayEnvironmentAuthorization {
36 readonly environmentId: EnvironmentId;39 readonly environmentId: EnvironmentId;
...@@ -66,7 +69,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service<...@@ -66,7 +69,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
66 }69 }
67>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}70>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
6871
69const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;
70const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;72const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
71const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;73const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
7274
...@@ -216,7 +218,7 @@ export const make = Effect.gen(function* () {...@@ -216,7 +218,7 @@ export const make = Effect.gen(function* () {
216 Option.isSome(cached) &&218 Option.isSome(cached) &&
217 cached.value.environmentId === input.expectedEnvironmentId &&219 cached.value.environmentId === input.expectedEnvironmentId &&
218 cached.value.dpopThumbprint === thumbprint &&220 cached.value.dpopThumbprint === thumbprint &&
219 cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS221 cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS
220 ) {222 ) {
221 yield* Effect.annotateCurrentSpan({223 yield* Effect.annotateCurrentSpan({
222 "connection.remote_token_cache": "hit",224 "connection.remote_token_cache": "hit",
...@@ -234,6 +236,7 @@ export const make = Effect.gen(function* () {...@@ -234,6 +236,7 @@ export const make = Effect.gen(function* () {
234 httpAuthorization: {236 httpAuthorization: {
235 _tag: "Dpop" as const,237 _tag: "Dpop" as const,
236 accessToken: cached.value.accessToken,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,6 +308,7 @@ export const make = Effect.gen(function* () {
305 httpAuthorization: {308 httpAuthorization: {
306 _tag: "Dpop" as const,309 _tag: "Dpop" as const,
307 accessToken: token.accessToken,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,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBl
103103
104export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;104export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;
105105
106export const DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
107
106export type PreparedHttpAuthorization =108export type PreparedHttpAuthorization =
107 | {109 | {
108 readonly _tag: "Bearer";110 readonly _tag: "Bearer";
...@@ -111,6 +113,7 @@ export type PreparedHttpAuthorization =...@@ -111,6 +113,7 @@ export type PreparedHttpAuthorization =
111 | {113 | {
112 readonly _tag: "Dpop";114 readonly _tag: "Dpop";
113 readonly accessToken: string;115 readonly accessToken: string;
116 readonly expiresAtEpochMs: number;
114 };117 };
115118
116export interface PreparedConnection {119export interface PreparedConnection {
packages/client-runtime/src/connection/resolver.test.ts+3
...@@ -140,6 +140,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o...@@ -140,6 +140,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
140 httpAuthorization: {140 httpAuthorization: {
141 _tag: "Dpop" as const,141 _tag: "Dpop" as const,
142 accessToken: "dpop-access-token",142 accessToken: "dpop-access-token",
143 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
143 },144 },
144 }),145 }),
145 )),146 )),
...@@ -357,6 +358,7 @@ describe("ConnectionResolver", () => {...@@ -357,6 +358,7 @@ describe("ConnectionResolver", () => {
357 httpAuthorization: {358 httpAuthorization: {
358 _tag: "Dpop" as const,359 _tag: "Dpop" as const,
359 accessToken: "dpop-access-token",360 accessToken: "dpop-access-token",
361 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
360 },362 },
361 }),363 }),
362 ),364 ),
...@@ -394,6 +396,7 @@ describe("ConnectionResolver", () => {...@@ -394,6 +396,7 @@ describe("ConnectionResolver", () => {
394 httpAuthorization: {396 httpAuthorization: {
395 _tag: "Dpop" as const,397 _tag: "Dpop" as const,
396 accessToken: "dpop-access-token",398 accessToken: "dpop-access-token",
399 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
397 },400 },
398 }),401 }),
399 Effect.withSpan("test.remote.authorizeDpop"),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,6 +16,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";
16import * as Connectivity from "./connectivity.ts";16import * as Connectivity from "./connectivity.ts";
17import * as ConnectionDriver from "./driver.ts";17import * as ConnectionDriver from "./driver.ts";
18import {18import {
19 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
19 ConnectionBlockedError,20 ConnectionBlockedError,
20 ConnectionTransientError,21 ConnectionTransientError,
21 PrimaryConnectionTarget,22 PrimaryConnectionTarget,
...@@ -1096,6 +1097,43 @@ describe("EnvironmentSupervisor", () => {...@@ -1096,6 +1097,43 @@ describe("EnvironmentSupervisor", () => {
1096 }),1097 }),
1097 );1098 );
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
1099 it.effect("interrupts relay setup when credentials change", () =>1137 it.effect("interrupts relay setup when credentials change", () =>
1100 Effect.gen(function* () {1138 Effect.gen(function* () {
1101 const firstAttemptStarted = yield* Deferred.make<void>();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,6 +18,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts";
18import * as Connectivity from "./connectivity.ts";18import * as Connectivity from "./connectivity.ts";
19import * as ConnectionDriver from "./driver.ts";19import * as ConnectionDriver from "./driver.ts";
20import {20import {
21 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
21 type ConnectionAttemptError,22 type ConnectionAttemptError,
22 type ConnectionTarget,23 type ConnectionTarget,
23 ConnectionTransientError,24 ConnectionTransientError,
...@@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
487 }488 }
488 });489 });
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
490 const runAttempt = Effect.fnUntraced(function* (506 const runAttempt = Effect.fnUntraced(function* (
491 attempt: number,507 attempt: number,
492 generation: number,508 generation: number,
...@@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
584 retryAt: null,600 retryAt: null,
585 });601 });
586602
587 const connectedExit = yield* Effect.raceFirst(603 const connectedExit = yield* Effect.raceAllFirst([
588 active.lease.session.closed.pipe(604 active.lease.session.closed.pipe(
589 Effect.mapError(605 Effect.mapError(
590 (error): TracedAttemptFailure => ({606 (error): TracedAttemptFailure => ({
...@@ -601,7 +617,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (...@@ -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 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;622 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;
606 if (Exit.isSuccess(connectedExit)) {623 if (Exit.isSuccess(connectedExit)) {
607 return {624 return {
packages/client-runtime/src/state/pullRequestDiffHttp.test.ts+41-1
...@@ -5,7 +5,10 @@ import * as Option from "effect/Option";...@@ -5,7 +5,10 @@ import * as Option from "effect/Option";
55
6import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";6import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";
7import { remoteHttpClientLayer } from "../rpc/http.ts";7import { remoteHttpClientLayer } from "../rpc/http.ts";
8import { fetchEnvironmentPullRequestDiff } from "./pullRequestDiffHttp.ts";8import {
9 fetchEnvironmentPullRequestDiff,
10 PullRequestDiffCredentialRejectedError,
11} from "./pullRequestDiffHttp.ts";
912
10const TARGET = new PrimaryConnectionTarget({13const TARGET = new PrimaryConnectionTarget({
11 environmentId: EnvironmentId.make("environment-1"),14 environmentId: EnvironmentId.make("environment-1"),
...@@ -77,4 +80,41 @@ describe("fetchEnvironmentPullRequestDiff", () => {...@@ -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,8 +1,13 @@
1import type { PullRequestDiffInput, PullRequestDiffResult } from "@t3tools/contracts";1import {
2 EnvironmentAuthInvalidError,
3 type PullRequestDiffInput,
4 type PullRequestDiffResult,
5} from "@t3tools/contracts";
2import * as Context from "effect/Context";6import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
6import { HttpClient } from "effect/unstable/http";11import { HttpClient } from "effect/unstable/http";
712
8import type { PreparedConnection } from "../connection/model.ts";13import type { PreparedConnection } from "../connection/model.ts";
...@@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir...@@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir
1722
18const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000;23const 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
20export const fetchEnvironmentPullRequestDiff = Effect.fn(43export const fetchEnvironmentPullRequestDiff = Effect.fn(
21 "clientRuntime.state.fetchEnvironmentPullRequestDiff",44 "clientRuntime.state.fetchEnvironmentPullRequestDiff",
22)(function* (input: {45)(function* (input: {
...@@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(...@@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn(
42 input.prepared.httpAuthorization,65 input.prepared.httpAuthorization,
43 client.pullRequests.diff({ payload: input.diff, headers }),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});
4781
...@@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service<...@@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service<
51 readonly load: (85 readonly load: (
52 prepared: PreparedConnection,86 prepared: PreparedConnection,
53 input: PullRequestDiffInput,87 input: PullRequestDiffInput,
54 ) => Effect.Effect<PullRequestDiffResult, RemoteEnvironmentRequestError>;88 ) => Effect.Effect<PullRequestDiffResult, PullRequestDiffLoadError>;
55 }89 }
56>()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {}90>()("@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";...@@ -20,7 +20,12 @@ import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
20import type { EnvironmentRegistry } from "../connection/registry.ts";20import type { EnvironmentRegistry } from "../connection/registry.ts";
21import { EnvironmentSupervisor } from "../connection/supervisor.ts";21import { 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
25export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(30export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
26 "EnvironmentHttpConnectionNotReadyError",31 "EnvironmentHttpConnectionNotReadyError",