1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_HEADER,
5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
6 ProjectId,
7 ThreadId,
8 type AuthSessionState,
9 type OrchestrationV2ShellSnapshot,
10 OrchestrationV2ThreadDetailSnapshot,
11 OrchestrationV2ThreadBoundedSnapshot,
12 OrchestrationV2ThreadTranscript,
13 type OrchestrationV2ThreadHistoryPage,
14} from "@t3tools/contracts";
15import * as Deferred from "effect/Deferred";
16import * as Effect from "effect/Effect";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Schema from "effect/Schema";
21import { TestClock } from "effect/testing";
22import type { HttpClient } from "effect/http";
23
24import { HostedAuthAccess } from "../platform/capabilities.ts";
25import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
26import {
27 ConnectionTransientError,
28 RelayConnectionTarget,
29 type PreparedConnection,
30 type PreparedHttpAuthorization,
31} from "../connection/model.ts";
32import * as ManagedRelay from "../relay/managedRelay.ts";
33import { type RemoteEnvironmentRequestError } from "../rpc/http.ts";
34import * as RpcHttp from "../rpc/http.ts";
35import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts";
36import { withOrchestrationProtocolHeader } from "./environmentHttpAuth.ts";
37import { fetchEnvironmentSessionState } from "./session.ts";
38import { fetchEnvironmentShellSnapshot } from "./shellSnapshotHttp.ts";
39import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
40import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts";
41import { fetchEnvironmentBoundedThreadSnapshot } from "./boundedThreadSnapshotHttp.ts";
42import * as BoundedThreadSnapshotHttp from "./boundedThreadSnapshotHttp.ts";
43import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts";
44import { v2Projection } from "./orchestrationV2TestFixtures.ts";
45
46const encodeThreadSnapshot = Schema.encodeSync(OrchestrationV2ThreadDetailSnapshot);
47const encodeBoundedSnapshot = Schema.encodeSync(OrchestrationV2ThreadBoundedSnapshot);
48const encodeThreadTranscript = Schema.encodeSync(OrchestrationV2ThreadTranscript);
49
50const TARGET = new RelayConnectionTarget({
51 environmentId: EnvironmentId.make("environment-1"),
52 label: "Remote environment",
53});
54const PREPARED: PreparedConnection = {
55 environmentId: TARGET.environmentId,
56 label: TARGET.label,
57 httpBaseUrl: "https://previous.example.test",
58 socketUrl: "wss://previous.example.test/ws",
59 httpAuthorization: { _tag: "Dpop", accessToken: "expired-token", expiresAtEpochMs: 0 },
60 target: TARGET,
61};
62const CURRENT_ORIGIN = "https://current.example.test";
63const RENEWED_ORIGIN = "https://renewed.example.test";
64const DIFF = {
65 projectId: ProjectId.make("project-1"),
66 repository: "owner/repository",
67 number: 42,
68};
69const DIFF_RESULT = { patch: "diff --git a/file.ts b/file.ts", truncated: false, nextCursor: null };
70const AUTH = {
71 policy: "remote-reachable",
72 bootstrapMethods: ["one-time-token"],
73 sessionMethods: ["dpop-access-token"],
74 sessionCookieName: "t3_session",
75} satisfies AuthSessionState["auth"];
76const SESSION = {
77 authenticated: true,
78 auth: AUTH,
79 scopes: ["orchestration:read", "orchestration:operate"],
80 sessionMethod: "dpop-access-token",
81} satisfies AuthSessionState;
82const UNAUTHENTICATED_SESSION = { authenticated: false, auth: AUTH } satisfies AuthSessionState;
83const SHELL = {
84 schemaVersion: 1,
85 snapshotSequence: 1,
86 projects: [],
87 threads: [],
88 archivedThreads: [],
89} satisfies OrchestrationV2ShellSnapshot;
90const THREAD = {
91 snapshotSequence: 2,
92 projection: v2Projection,
93} satisfies OrchestrationV2ThreadDetailSnapshot;
94const TRANSCRIPT = {
95 threadId: v2Projection.thread.id,
96 title: v2Projection.thread.title,
97 updatedAt: v2Projection.updatedAt,
98 items: v2Projection.visibleTurnItems,
99} satisfies OrchestrationV2ThreadTranscript;
100const BOUNDED_THREAD = {
101 ...THREAD,
102 historyCursor: "older-page",
103 hasMoreHistory: true,
104 latestLocalTurnOrdinal: 3,
105} satisfies OrchestrationV2ThreadBoundedSnapshot;
106const THREAD_HISTORY = {
107 snapshotSequence: 2,
108 items: [],
109 nextCursor: null,
110 hasMoreHistory: false,
111} satisfies OrchestrationV2ThreadHistoryPage;
112
113function credentialRejectedResponse(reason = "invalid_credential") {
114 return Response.json(
115 {
116 _tag: "EnvironmentAuthInvalidError",
117 code: "auth_invalid",
118 reason,
119 traceId: "trace-rejected",
120 },
121 { status: 401 },
122 );
123}
124
125function makeHarness(reply: (requestNumber: number) => Response | Promise<Response>) {
126 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
127 const authorizations: Array<
128 Parameters<
129 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]
130 >[0]
131 > = [];
132 const proofs: Array<ManagedRelay.ManagedRelayDpopProofInput> = [];
133 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
134 authorizeBearer: () => Effect.die("Unexpected bearer connection preparation."),
135 authorizeDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."),
136 authorizeDpopHttp: (input) =>
137 Effect.sync(() => {
138 authorizations.push(input);
139 const rejected = input.rejectedAccessToken !== undefined;
140 return {
141 environmentId: TARGET.environmentId,
142 label: TARGET.label,
143 httpBaseUrl: rejected ? RENEWED_ORIGIN : CURRENT_ORIGIN,
144 httpAuthorization: {
145 _tag: "Dpop" as const,
146 accessToken: rejected ? "renewed-token" : "current-token",
147 expiresAtEpochMs: 3_600_000,
148 },
149 };
150 }),
151 });
152 const signer = ManagedRelay.ManagedRelayDpopSigner.of({
153 thumbprint: Effect.succeed("test-thumbprint"),
154 createProof: (input) =>
155 Effect.sync(() => {
156 proofs.push(input);
157 return `proof-${proofs.length}`;
158 }),
159 });
160 const fetchFn: typeof fetch = async (request, init) => {
161 calls.push({ url: String(request), init: init ?? {} });
162 return reply(calls.length);
163 };
164 return {
165 calls,
166 authorizations,
167 proofs,
168 remoteAuthorization,
169 input: {
170 prepared: PREPARED,
171 signer: Option.some(signer),
172 remoteAuthorization: Option.some(remoteAuthorization),
173 },
174 httpLayer: RpcHttp.layerRemoteHttpClient(fetchFn),
175 };
176}
177
178type HttpInput = ReturnType<typeof makeHarness>["input"];
179const LOADERS: ReadonlyArray<{
180 readonly name: string;
181 readonly method: string;
182 readonly path: string;
183 readonly response: unknown;
184 readonly expected: unknown;
185 readonly load: (
186 input: HttpInput,
187 ) => Effect.Effect<
188 unknown,
189 RemoteEnvironmentRequestError | PullRequestDiffLoader.PullRequestDiffCredentialRejectedError,
190 HttpClient.HttpClient
191 >;
192}> = [
193 {
194 name: "PR diff",
195 method: "POST",
196 path: "/api/pull-requests/diff",
197 response: DIFF_RESULT,
198 expected: DIFF_RESULT,
199 load: (input: HttpInput) =>
200 PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({ ...input, diff: DIFF }),
201 },
202 {
203 name: "session permissions",
204 method: "GET",
205 path: "/api/auth/session",
206 response: SESSION,
207 expected: SESSION,
208 load: fetchEnvironmentSessionState,
209 },
210 {
211 name: "shell snapshot",
212 method: "GET",
213 path: "/api/orchestration/shell",
214 response: SHELL,
215 expected: SHELL,
216 // Pull request links decode separately; the rows match the response on their own.
217 load: (input: HttpInput) =>
218 fetchEnvironmentShellSnapshot(input).pipe(
219 Effect.map(({ loadPullRequests: _links, ...snapshot }) => snapshot),
220 ),
221 },
222 {
223 name: "thread snapshot",
224 method: "GET",
225 path: `/api/orchestration/threads/${THREAD.projection.thread.id}`,
226 response: encodeThreadSnapshot(THREAD),
227 expected: THREAD,
228 load: (input: HttpInput) =>
229 ThreadSnapshotLoader.fetchEnvironmentThreadSnapshot({
230 ...input,
231 threadId: THREAD.projection.thread.id,
232 }),
233 },
234 {
235 name: "thread transcript",
236 method: "GET",
237 path: `/api/orchestration/threads/${TRANSCRIPT.threadId}/transcript`,
238 response: encodeThreadTranscript(TRANSCRIPT),
239 expected: TRANSCRIPT,
240 load: (input: HttpInput) =>
241 fetchEnvironmentThreadTranscript({ ...input, threadId: TRANSCRIPT.threadId }),
242 },
243 {
244 name: "bounded thread snapshot",
245 method: "GET",
246 path: `/api/orchestration/threads/${THREAD.projection.thread.id}/bounded`,
247 response: encodeBoundedSnapshot(BOUNDED_THREAD),
248 expected: BOUNDED_THREAD,
249 load: (input: HttpInput) =>
250 fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: THREAD.projection.thread.id }),
251 },
252 {
253 name: "older thread history",
254 method: "GET",
255 path: `/api/orchestration/threads/${THREAD.projection.thread.id}/history`,
256 response: THREAD_HISTORY,
257 expected: THREAD_HISTORY,
258 load: (input: HttpInput) =>
259 fetchEnvironmentThreadHistoryPage({
260 ...input,
261 threadId: THREAD.projection.thread.id,
262 cursor: "older-page",
263 }),
264 },
265];
266
267describe("authenticated environment HTTP requests", () => {
268 it.effect.each(LOADERS)("rejects an invalid $name response", (loader) =>
269 Effect.gen(function* () {
270 const harness = makeHarness(() => Response.json({}));
271 const result = yield* loader
272 .load(harness.input)
273 .pipe(Effect.provide(harness.httpLayer), Effect.asVoid, Effect.flip);
274 expect(result._tag).toBe("RemoteEnvironmentAuthInvalidJsonError");
275 expect(harness.calls).toHaveLength(1);
276 }),
277 );
278
279 it.effect("keeps the status of a shell snapshot error that is not a declared error", () =>
280 Effect.gen(function* () {
281 const harness = makeHarness(() => Response.json({ error: "bad_gateway" }, { status: 502 }));
282 const error = yield* fetchEnvironmentShellSnapshot(harness.input).pipe(
283 Effect.provide(harness.httpLayer),
284 Effect.flip,
285 );
286 expect(error).toMatchObject({
287 _tag: "RemoteEnvironmentAuthUndeclaredStatusError",
288 status: 502,
289 });
290 }),
291 );
292
293 it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) =>
294 Effect.gen(function* () {
295 const harness = makeHarness(() => Response.json(loader.response));
296 const result = yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
297
298 expect(result).toEqual(loader.expected);
299 expect(harness.calls).toHaveLength(1);
300 const call = harness.calls[0]!;
301 const url = new URL(call.url);
302 expect(url.origin).toBe(CURRENT_ORIGIN);
303 expect(url.pathname).toBe(loader.path);
304 expect(call.init.method).toBe(loader.method);
305 expect(new Headers(call.init.headers).get("authorization")).toBe("DPoP current-token");
306 expect(new Headers(call.init.headers).get("dpop")).toBe("proof-1");
307 expect(call.init.credentials).toBeUndefined();
308 if (loader.path.startsWith("/api/orchestration/")) {
309 expect(new Headers(call.init.headers).get(ORCHESTRATION_PROTOCOL_HEADER)).toBe(
310 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
311 );
312 }
313 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
314 // The proof signs exactly the URL sent; signers drop the query for `htu`.
315 expect(harness.proofs).toEqual([
316 { method: loader.method, url: call.url, accessToken: "current-token" },
317 ]);
318 if (loader.name === "older thread history") {
319 expect(url.searchParams.get("cursor")).toBe("older-page");
320 }
321 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });
322 }),
323 );
324
325 // MCP-created thread ids contain ":", which the request path percent-encodes.
326 // The DPoP proof must sign the URL that is actually sent, or the environment
327 // rejects it as a URL mismatch.
328 const MCP_THREAD_ID = ThreadId.make("mcp:3534bc83-1c17-4a1e-9118-601c2766d355");
329 const MCP_THREAD_LOADERS: ReadonlyArray<
330 Pick<(typeof LOADERS)[number], "name" | "response" | "load">
331 > = [
332 {
333 name: "thread transcript",
334 response: encodeThreadTranscript(TRANSCRIPT),
335 load: (input: HttpInput) =>
336 fetchEnvironmentThreadTranscript({ ...input, threadId: MCP_THREAD_ID }),
337 },
338 {
339 name: "thread snapshot",
340 response: encodeThreadSnapshot(THREAD),
341 load: (input: HttpInput) =>
342 ThreadSnapshotLoader.fetchEnvironmentThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }),
343 },
344 {
345 name: "bounded thread snapshot",
346 response: encodeBoundedSnapshot(BOUNDED_THREAD),
347 load: (input: HttpInput) =>
348 fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }),
349 },
350 {
351 name: "older thread history",
352 response: THREAD_HISTORY,
353 load: (input: HttpInput) =>
354 fetchEnvironmentThreadHistoryPage({
355 ...input,
356 threadId: MCP_THREAD_ID,
357 cursor: "older-page",
358 }),
359 },
360 ];
361 it.effect.each(MCP_THREAD_LOADERS)(
362 "signs the sent URL for a $name of a thread id that needs encoding",
363 (loader) =>
364 Effect.gen(function* () {
365 const harness = makeHarness(() => Response.json(loader.response));
366 yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
367
368 const sent = harness.calls[0]!.url;
369 expect(new URL(sent).pathname).toContain("/mcp%3A3534bc83-");
370 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent]);
371 }),
372 );
373
374 it.effect("retries a rejected diff once with a new token, endpoint, and proof", () =>
375 Effect.gen(function* () {
376 const harness = makeHarness((requestNumber) =>
377 requestNumber === 1 ? credentialRejectedResponse() : Response.json(DIFF_RESULT),
378 );
379 const result = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
380 ...harness.input,
381 diff: DIFF,
382 }).pipe(Effect.provide(harness.httpLayer));
383
384 expect(result).toEqual(DIFF_RESULT);
385 expect(harness.authorizations).toEqual([
386 { expectedEnvironmentId: TARGET.environmentId },
387 { expectedEnvironmentId: TARGET.environmentId, rejectedAccessToken: "current-token" },
388 ]);
389 expect(harness.calls.map((call) => call.url)).toEqual([
390 `${CURRENT_ORIGIN}/api/pull-requests/diff`,
391 `${RENEWED_ORIGIN}/api/pull-requests/diff`,
392 ]);
393 expect(
394 harness.calls.map((call) => new Headers(call.init.headers).get("authorization")),
395 ).toEqual(["DPoP current-token", "DPoP renewed-token"]);
396 expect(harness.calls.map((call) => new Headers(call.init.headers).get("dpop"))).toEqual([
397 "proof-1",
398 "proof-2",
399 ]);
400 expect(harness.proofs[1]).toEqual({
401 method: "POST",
402 url: `${RENEWED_ORIGIN}/api/pull-requests/diff`,
403 accessToken: "renewed-token",
404 });
405 expect(harness.calls[1]!.init.body).toEqual(harness.calls[0]!.init.body);
406 }),
407 );
408
409 it.effect.each(LOADERS.filter((loader) => loader.path.startsWith("/api/orchestration/")))(
410 "renews a rejected credential without losing the v2 protocol or query for $name",
411 (loader) =>
412 Effect.gen(function* () {
413 const harness = makeHarness((requestNumber) =>
414 requestNumber === 1 ? credentialRejectedResponse() : Response.json(loader.response),
415 );
416 const result = yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
417 expect(result).toEqual(loader.expected);
418 expect(harness.calls).toHaveLength(2);
419 const retried = harness.calls[1]!;
420 const url = new URL(retried.url);
421 expect(url.origin).toBe(RENEWED_ORIGIN);
422 expect(url.pathname).toBe(loader.path);
423 expect(new Headers(retried.init.headers).get("authorization")).toBe("DPoP renewed-token");
424 expect(new Headers(retried.init.headers).get("dpop")).toBe("proof-2");
425 expect(new Headers(retried.init.headers).get(ORCHESTRATION_PROTOCOL_HEADER)).toBe(
426 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
427 );
428 expect(harness.proofs[1]).toEqual({
429 method: "GET",
430 url: retried.url,
431 accessToken: "renewed-token",
432 });
433 if (loader.name === "older thread history") {
434 expect(url.searchParams.get("cursor")).toBe("older-page");
435 }
436 }),
437 );
438
439 it.effect("renews credentials captured by the bounded snapshot loader", () =>
440 Effect.gen(function* () {
441 const harness = makeHarness((requestNumber) =>
442 requestNumber === 1
443 ? credentialRejectedResponse()
444 : Response.json(encodeBoundedSnapshot(BOUNDED_THREAD)),
445 );
446 const layerLoader = BoundedThreadSnapshotHttp.layer.pipe(
447 Layer.provide(
448 Layer.mergeAll(
449 harness.httpLayer,
450 Layer.succeed(
451 ManagedRelay.ManagedRelayDpopSigner,
452 Option.getOrThrow(harness.input.signer),
453 ),
454 Layer.succeed(
455 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
456 harness.remoteAuthorization,
457 ),
458 ),
459 ),
460 );
461 const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader.pipe(
462 Effect.provide(layerLoader),
463 );
464 const result = yield* loader.load(PREPARED, THREAD.projection.thread.id);
465 expect(result).toEqual({
466 _tag: "present",
467 snapshot: { ...THREAD, latestLocalTurnOrdinal: BOUNDED_THREAD.latestLocalTurnOrdinal },
468 history: {
469 historyCursor: BOUNDED_THREAD.historyCursor,
470 hasMoreHistory: true,
471 latestLocalTurnOrdinal: BOUNDED_THREAD.latestLocalTurnOrdinal,
472 },
473 });
474 expect(harness.calls).toHaveLength(2);
475 expect(new Headers(harness.calls[1]!.init.headers).get("authorization")).toBe(
476 "DPoP renewed-token",
477 );
478 }),
479 );
480
481 it.effect("uses the authorization service captured by the diff loader layer", () =>
482 Effect.gen(function* () {
483 const harness = makeHarness(() => Response.json(DIFF_RESULT));
484 const layerLoader = PullRequestDiffLoader.layer.pipe(
485 Layer.provide(
486 Layer.mergeAll(
487 harness.httpLayer,
488 Layer.succeed(
489 ManagedRelay.ManagedRelayDpopSigner,
490 Option.getOrThrow(harness.input.signer),
491 ),
492 Layer.succeed(
493 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
494 harness.remoteAuthorization,
495 ),
496 ),
497 ),
498 );
499 const loader = yield* PullRequestDiffLoader.PullRequestDiffLoader.pipe(
500 Effect.provide(layerLoader),
501 );
502 const result = yield* loader.load(PREPARED, DIFF);
503
504 expect(result).toEqual(DIFF_RESULT);
505 expect(new Headers(harness.calls[0]!.init.headers).get("authorization")).toBe(
506 "DPoP current-token",
507 );
508 }),
509 );
510
511 it.effect("preserves the credential rejection after the one recovery attempt fails", () =>
512 Effect.gen(function* () {
513 const harness = makeHarness(() => credentialRejectedResponse());
514 const error = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
515 ...harness.input,
516 diff: DIFF,
517 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
518
519 expect(error).toMatchObject({
520 _tag: "PullRequestDiffCredentialRejectedError",
521 traceId: "trace-rejected",
522 });
523 expect(harness.calls).toHaveLength(2);
524 expect(harness.authorizations).toHaveLength(2);
525 }),
526 );
527
528 it.effect.each([
529 {
530 name: "insufficient scope",
531 reply: () =>
532 Response.json(
533 {
534 _tag: "EnvironmentScopeRequiredError",
535 code: "insufficient_scope",
536 requiredScope: "orchestration:read",
537 traceId: "trace-scope",
538 },
539 { status: 403 },
540 ),
541 errorTag: "EnvironmentScopeRequiredError",
542 },
543 {
544 name: "missing credential",
545 reply: () => credentialRejectedResponse("missing_credential"),
546 errorTag: "EnvironmentAuthInvalidError",
547 },
548 {
549 name: "Cloudflare 530",
550 reply: () => new Response("error code: 1033", { status: 530 }),
551 errorTag: "RemoteEnvironmentAuthUndeclaredStatusError",
552 },
553 {
554 name: "network failure",
555 reply: () => Promise.reject(new Error("Network unreachable")),
556 errorTag: "RemoteEnvironmentAuthFetchError",
557 },
558 ])("does not renew or retry on $name", ({ reply, errorTag }) =>
559 Effect.gen(function* () {
560 const harness = makeHarness(reply);
561 const error = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
562 ...harness.input,
563 diff: DIFF,
564 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
565
566 expect(error._tag).toBe(errorTag);
567 expect(harness.calls).toHaveLength(1);
568 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
569 }),
570 );
571
572 it.effect("recovers a session's unauthenticated 200 response before checking permissions", () =>
573 Effect.gen(function* () {
574 const harness = makeHarness((requestNumber) =>
575 Response.json(requestNumber === 1 ? UNAUTHENTICATED_SESSION : SESSION),
576 );
577 const result = yield* fetchEnvironmentSessionState(harness.input).pipe(
578 Effect.provide(harness.httpLayer),
579 );
580
581 expect(result).toEqual(SESSION);
582 expect(harness.authorizations[1]).toEqual({
583 expectedEnvironmentId: TARGET.environmentId,
584 rejectedAccessToken: "current-token",
585 });
586 expect(harness.calls).toHaveLength(2);
587 }),
588 );
589
590 it.effect("reports persistent session rejection instead of showing missing permissions", () =>
591 Effect.gen(function* () {
592 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
593 const error = yield* fetchEnvironmentSessionState(harness.input).pipe(
594 Effect.provide(harness.httpLayer),
595 Effect.flip,
596 );
597
598 expect(error).toMatchObject({
599 _tag: "RemoteEnvironmentAuthFetchError",
600 message: "The environment rejected the renewed session authorization.",
601 });
602 expect(harness.calls).toHaveLength(2);
603 }),
604 );
605
606 it.effect.each([
607 { name: "cookie", authorization: null },
608 { name: "bearer", authorization: { _tag: "Bearer", token: "bearer-token" } },
609 ] satisfies ReadonlyArray<{ name: string; authorization: PreparedHttpAuthorization | null }>)(
610 "leaves $name sessions unchanged without relay services",
611 ({ authorization }) =>
612 Effect.gen(function* () {
613 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
614 const result = yield* fetchEnvironmentSessionState({
615 prepared: { ...PREPARED, httpAuthorization: authorization },
616 signer: Option.none(),
617 }).pipe(Effect.provide(harness.httpLayer));
618
619 expect(result).toEqual(UNAUTHENTICATED_SESSION);
620 expect(harness.calls).toHaveLength(1);
621 expect(harness.authorizations).toEqual([]);
622 expect(new Headers(harness.calls[0]!.init.headers).get("authorization")).toBe(
623 authorization === null ? null : "Bearer bearer-token",
624 );
625 expect(harness.calls[0]!.init.credentials).toBe(
626 authorization === null ? "include" : undefined,
627 );
628 }),
629 );
630
631 it.effect("adds current hosted CSRF to cookie-authenticated environment requests", () =>
632 Effect.gen(function* () {
633 const harness = makeHarness(() => Response.json(SESSION));
634 const servers: string[] = [];
635 yield* fetchEnvironmentSessionState({
636 prepared: { ...PREPARED, httpAuthorization: null },
637 signer: Option.none(),
638 }).pipe(
639 Effect.provide(harness.httpLayer),
640 Effect.provideService(HostedAuthAccess, {
641 headers: (server) =>
642 Effect.sync(() => {
643 servers.push(server);
644 return { "x-csrf-token": "current-cookie-proof" };
645 }),
646 }),
647 );
648 expect(servers).toEqual([PREPARED.httpBaseUrl]);
649 expect(new Headers(harness.calls[0]!.init.headers).get("x-csrf-token")).toBe(
650 "current-cookie-proof",
651 );
652 expect(harness.calls[0]!.init.credentials).toBe("include");
653 }),
654 );
655
656 it.effect("keeps the caller's timeout while waiting for renewal", () =>
657 Effect.gen(function* () {
658 const harness = makeHarness(() => Response.json(SESSION));
659 const authorizing = yield* Deferred.make<void>();
660 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
661 ...harness.remoteAuthorization,
662 authorizeDpopHttp: () =>
663 Deferred.succeed(authorizing, undefined).pipe(Effect.andThen(Effect.never)),
664 });
665 const pending = yield* fetchEnvironmentSessionState({
666 ...harness.input,
667 remoteAuthorization: Option.some(remoteAuthorization),
668 timeoutMs: 100,
669 }).pipe(Effect.provide(harness.httpLayer), Effect.flip, Effect.forkChild);
670 yield* Deferred.await(authorizing);
671 yield* TestClock.adjust(100);
672
673 expect(yield* Fiber.join(pending)).toMatchObject({
674 _tag: "RemoteEnvironmentAuthTimeoutError",
675 requestUrl: `${PREPARED.httpBaseUrl}/api/auth/session`,
676 timeoutMs: 100,
677 });
678 expect(harness.calls).toEqual([]);
679 }),
680 );
681
682 it.effect("reports the current endpoint when the total timeout expires during HTTP", () =>
683 Effect.gen(function* () {
684 const requested = Promise.withResolvers<void>();
685 const response = Promise.withResolvers<Response>();
686 const harness = makeHarness(() => {
687 requested.resolve();
688 return response.promise;
689 });
690 const authorizing = yield* Deferred.make<void>();
691 const authorize = yield* Deferred.make<void>();
692 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
693 ...harness.remoteAuthorization,
694 authorizeDpopHttp: (input) =>
695 Deferred.succeed(authorizing, undefined).pipe(
696 Effect.andThen(Deferred.await(authorize)),
697 Effect.andThen(harness.remoteAuthorization.authorizeDpopHttp(input)),
698 ),
699 });
700 const pending = yield* fetchEnvironmentSessionState({
701 ...harness.input,
702 remoteAuthorization: Option.some(remoteAuthorization),
703 timeoutMs: 100,
704 }).pipe(Effect.provide(harness.httpLayer), Effect.flip, Effect.forkChild);
705 yield* Deferred.await(authorizing);
706 yield* TestClock.adjust(25);
707 yield* Deferred.succeed(authorize, undefined);
708 yield* Effect.promise(() => requested.promise);
709 yield* TestClock.adjust(75);
710
711 expect(yield* Fiber.join(pending)).toMatchObject({
712 _tag: "RemoteEnvironmentAuthTimeoutError",
713 requestUrl: `${CURRENT_ORIGIN}/api/auth/session`,
714 timeoutMs: 100,
715 });
716 expect(harness.calls.map((call) => call.url)).toEqual([`${CURRENT_ORIGIN}/api/auth/session`]);
717 expect(harness.authorizations).toHaveLength(1);
718 response.resolve(Response.json(SESSION));
719 }),
720 );
721
722 it.effect("reports renewal failure without sending the expired prepared token", () =>
723 Effect.gen(function* () {
724 const harness = makeHarness(() => Response.json(SESSION));
725 const failure = new ConnectionTransientError({
726 reason: "transport",
727 detail: "Relay unavailable",
728 });
729 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
730 ...harness.remoteAuthorization,
731 authorizeDpopHttp: () => Effect.fail(failure),
732 });
733 const error = yield* fetchEnvironmentSessionState({
734 ...harness.input,
735 remoteAuthorization: Option.some(remoteAuthorization),
736 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
737
738 expect(error).toMatchObject({
739 _tag: "RemoteEnvironmentAuthFetchError",
740 message: "Could not authorize the environment request.",
741 cause: failure,
742 });
743 expect(harness.calls).toEqual([]);
744 expect(harness.proofs).toEqual([]);
745 }),
746 );
747
748 it.effect("does not fall back to a captured DPoP token when authorization is unavailable", () =>
749 Effect.gen(function* () {
750 const harness = makeHarness(() => Response.json(SESSION));
751 const error = yield* fetchEnvironmentSessionState({
752 prepared: PREPARED,
753 signer: harness.input.signer,
754 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
755
756 expect(error).toMatchObject({
757 _tag: "RemoteEnvironmentAuthFetchError",
758 message: "No relay authorization service is available for the environment request.",
759 });
760 expect(harness.calls).toEqual([]);
761 }),
762 );
763});
764
765describe("orchestration HTTP compatibility header", () => {
766 it("announces the current protocol without replacing bearer or DPoP authentication", () => {
767 expect(
768 withOrchestrationProtocolHeader({
769 authorization: "DPoP access-token",
770 dpop: "signed-proof",
771 }),
772 ).toEqual({
773 authorization: "DPoP access-token",
774 dpop: "signed-proof",
775 [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT,
776 });
777 });
778});