1import { describe, expect, it } from "@effect/vitest";
2import {
3 AuthOrchestrationReadScope,
4 AuthOrchestrationOperateScope,
5 AuthSessionId,
6 EnvironmentId,
7 ORCHESTRATION_PROTOCOL_VERSION,
8 type ExecutionEnvironmentDescriptor,
9} from "@t3tools/contracts";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Stream from "effect/Stream";
14import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http";
15import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
16import * as SessionStore from "../auth/SessionStore.ts";
17import * as Hub from "./Hub.ts";
18import * as HubHttp from "./HubHttp.ts";
19import * as HubProxy from "./HubProxy.ts";
20
21const descriptor: ExecutionEnvironmentDescriptor = {
22 environmentId: EnvironmentId.make("leaf"),
23 label: "Laptop",
24 platform: { os: "linux", arch: "x64" },
25 serverVersion: "test",
26 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
27 capabilities: { repositoryIdentity: true },
28};
29const authDescriptor = {
30 policy: "remote-reachable" as const,
31 bootstrapMethods: ["one-time-token" as const],
32 sessionMethods: ["bearer-access-token" as const],
33 sessionCookieName: "hub-session",
34};
35const session = {
36 sessionId: AuthSessionId.make("phone"),
37 subject: "phone",
38 method: "bearer-access-token" as const,
39 scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope],
40};
41
42const fixture = (
43 responseFactory: (
44 request: import("effect/http/HttpClientRequest").HttpClientRequest,
45 ) => Response = () => new Response("leaf"),
46) =>
47 Effect.gen(function* () {
48 const removed = yield* Deferred.make<void>();
49 const revoked = yield* Deferred.make<void>();
50 const requests: Array<{
51 url: string;
52 headers: Readonly<Record<string, string>>;
53 body: string;
54 }> = [];
55 let finalized = 0;
56 const client = HttpClient.make((request, _url, signal) =>
57 Effect.gen(function* () {
58 signal.addEventListener("abort", () => {
59 finalized++;
60 });
61 const body =
62 request.body._tag === "Stream"
63 ? yield* request.body.stream.pipe(
64 Stream.runCollect,
65 Effect.orDie,
66 Effect.map((chunks) =>
67 chunks.map((chunk) => new TextDecoder().decode(chunk)).join(""),
68 ),
69 )
70 : "";
71 requests.push({ url: request.url, headers: request.headers, body });
72 return HttpClientResponse.fromWeb(request, responseFactory(request));
73 }),
74 );
75 const hub = Hub.Hub.of({
76 authorize: () => Effect.succeed([AuthOrchestrationReadScope]),
77 descriptor: () => Effect.succeed(descriptor),
78 directory: () =>
79 Effect.succeed({
80 hubEnvironmentId: EnvironmentId.make("hub"),
81 environments: [
82 {
83 ...descriptor,
84 descriptor,
85 httpBaseUrl: "https://hub.test/environments/leaf/",
86 scopes: [AuthOrchestrationReadScope],
87 },
88 ],
89 }),
90 manage: () => Effect.die("unused"),
91 connect: () =>
92 Effect.succeed({
93 url: "http://leaf.private/base/",
94 token: "private-leaf-token",
95 invalidated: Deferred.await(removed),
96 }),
97 });
98 const auth = {
99 authenticateWebSocketUpgrade: (
100 request: import("effect/http/HttpServerRequest").HttpServerRequest,
101 ) =>
102 request.headers.authorization || request.url.includes("wsTicket=hub-ticket")
103 ? Effect.succeed(session)
104 : Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError({})),
105 issueWebSocketTicket: () =>
106 Effect.succeed({ ticket: "hub-ticket", expiresAt: "2030-01-01T00:00:00.000Z" }),
107 getDescriptor: () => Effect.succeed(authDescriptor),
108 } as unknown as EnvironmentAuth.EnvironmentAuth["Service"];
109 const dependencies = Layer.mergeAll(
110 Layer.succeed(Hub.Hub, hub),
111 Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth),
112 Layer.succeed(SessionStore.SessionStore, {
113 awaitInvalidation: () => Deferred.await(revoked),
114 } as unknown as SessionStore.SessionStore["Service"]),
115 Layer.succeed(HttpClient.HttpClient, client),
116 );
117 const { handler, dispose } = HttpRouter.toWebHandler(
118 HubHttp.layer.pipe(
119 Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))),
120 Layer.provideMerge(dependencies),
121 ),
122 { disableLogger: true },
123 );
124 yield* Effect.addFinalizer(() => Effect.promise(dispose));
125 const request = (path: string, init: RequestInit = {}) =>
126 handler(
127 new Request(`http://hub.test${path}`, {
128 ...init,
129 headers: { authorization: "Bearer hub-token", ...init.headers },
130 }),
131 );
132 return {
133 handler,
134 request,
135 requests,
136 revoke: Deferred.succeed(revoked, undefined),
137 remove: Deferred.succeed(removed, undefined),
138 finalized: () => finalized,
139 };
140 });
141
142describe("self-hosted server proxy", () => {
143 it.effect("does not advertise a compressed upstream size for a decoded response", () =>
144 Effect.gen(function* () {
145 const f = yield* fixture(
146 () =>
147 new Response("expanded", {
148 headers: { "content-encoding": "gzip", "content-length": "1" },
149 }),
150 );
151 const response = yield* Effect.promise(() => f.request("/environments/leaf/api/example"));
152 expect(response.headers.get("content-encoding")).toBeNull();
153 expect(response.headers.get("content-length")).toBeNull();
154 expect(yield* Effect.promise(() => response.text())).toBe("expanded");
155 }),
156 );
157
158 it.effect("requires hub authentication and management permission", () =>
159 Effect.gen(function* () {
160 const f = yield* fixture();
161 yield* Effect.promise(async () => {
162 expect((await f.handler(new Request("http://hub.test/api/hub/environments"))).status).toBe(
163 401,
164 );
165 expect((await f.request("/api/hub/manage", { method: "POST", body: "{}" })).status).toBe(
166 403,
167 );
168 expect((await f.request("/api/hub/environments")).status).toBe(200);
169 });
170 }),
171 );
172
173 it.effect(
174 "replaces hub credentials, preserves byte ranges, streams bodies, and releases upstream resources",
175 () =>
176 Effect.gen(function* () {
177 const f = yield* fixture(
178 () =>
179 new Response("bytes", {
180 status: 206,
181 headers: {
182 "content-range": "bytes 1-5/10",
183 "set-cookie": "leaf=private",
184 "x-private-secret": "private",
185 location: "/base/api/next",
186 },
187 }),
188 );
189 yield* Effect.promise(async () => {
190 const response = await f.request(
191 "/environments/leaf/api/assets?wsTicket=hub-ticket&asset=one",
192 {
193 method: "PUT",
194 headers: {
195 dpop: "hub-proof",
196 cookie: "hub-session=secret",
197 range: "bytes=1-5",
198 "x-forwarded-host": "evil.test",
199 },
200 body: "upload",
201 },
202 );
203 expect(response.status).toBe(206);
204 expect(await response.text()).toBe("bytes");
205 expect(response.headers.get("content-range")).toBe("bytes 1-5/10");
206 expect(response.headers.get("set-cookie")).toBeNull();
207 expect(response.headers.get("x-private-secret")).toBeNull();
208 expect(response.headers.get("location")).toBe("/environments/leaf/api/next");
209 expect(f.requests).toHaveLength(1);
210 expect(f.requests[0]).toMatchObject({
211 url: "http://leaf.private/base/api/assets?asset=one",
212 body: "upload",
213 headers: { authorization: "Bearer private-leaf-token", range: "bytes=1-5" },
214 });
215 expect(f.requests[0]?.headers).not.toHaveProperty("dpop");
216 expect(f.requests[0]?.headers).not.toHaveProperty("cookie");
217 expect(f.requests[0]?.headers).not.toHaveProperty("x-forwarded-host");
218 expect(f.finalized()).toBe(1);
219 });
220 }),
221 );
222
223 it.effect.each([
224 "/api/auth/pairing-token",
225 "/api/auth/delegated-session",
226 "/api/auth/%64elegated-session",
227 "/api/auth/browser-session",
228 "/api/auth/clients",
229 "/oauth/token",
230 "/.well-known/oauth-authorization-server",
231 "/%61pi/auth/pairing-token",
232 "/api%2fauth/pairing-token",
233 "/%6fauth/token",
234 ])("does not expose leaf credentials through %s", (path) =>
235 Effect.gen(function* () {
236 const f = yield* fixture();
237 const response = yield* Effect.promise(() =>
238 f.request(`/environments/leaf${path}`, { method: "POST" }),
239 );
240 expect(response.status).toBe(404);
241 expect(f.requests).toHaveLength(0);
242 }),
243 );
244
245 it.effect.each(["/%61pi/auth/websocket-ticket", "/api%2fauth/websocket-ticket"])(
246 "keeps encoded %s ticket requests on the hub",
247 (path) =>
248 Effect.gen(function* () {
249 const f = yield* fixture();
250 const response = yield* Effect.promise(() =>
251 f.request(`/environments/leaf${path}`, { method: "POST" }),
252 );
253 expect(response.status).toBe(200);
254 expect(yield* Effect.promise(() => response.json())).toMatchObject({
255 ticket: "hub-ticket",
256 });
257 expect(f.requests).toHaveLength(0);
258 }),
259 );
260
261 it.effect(
262 "serves bounded hub session and ticket responses locally and preserves public leaf identity",
263 () =>
264 Effect.gen(function* () {
265 const f = yield* fixture();
266 yield* Effect.promise(async () => {
267 const state = await (await f.request("/environments/leaf/api/auth/session")).json();
268 expect(state).toMatchObject({
269 authenticated: true,
270 auth: authDescriptor,
271 scopes: [AuthOrchestrationReadScope],
272 });
273 expect(
274 await (
275 await f.request("/environments/leaf/api/auth/websocket-ticket", { method: "POST" })
276 ).json(),
277 ).toMatchObject({ ticket: "hub-ticket" });
278 expect(
279 await (
280 await f.handler(
281 new Request("http://hub.test/environments/leaf/.well-known/t3/environment"),
282 )
283 ).json(),
284 ).toMatchObject({ environmentId: "leaf" });
285 expect(f.requests).toHaveLength(0);
286 });
287 }),
288 );
289
290 it.effect("never turns a wildcard path into an arbitrary upstream origin", () =>
291 Effect.gen(function* () {
292 const f = yield* fixture();
293 yield* Effect.promise(async () => {
294 await (await f.request("/environments/leaf/https://evil.test/steal")).text();
295 });
296 expect(new URL(f.requests[0]?.url ?? "").origin).toBe("http://leaf.private");
297 }),
298 );
299
300 it.effect.each(["revoke", "remove"] as const)(
301 "interrupts an active HTTP stream on %s",
302 (action) =>
303 Effect.gen(function* () {
304 const f = yield* fixture(
305 () =>
306 new Response(
307 new ReadableStream({
308 start(controller) {
309 controller.enqueue(new TextEncoder().encode("first"));
310 },
311 }),
312 ),
313 );
314 const response = yield* Effect.promise(() => f.request("/environments/leaf/api/stream"));
315 if (!response.body) return yield* Effect.die("Missing response stream");
316 const reader = response.body.getReader();
317 const first = yield* Effect.promise(() => reader.read());
318 expect(new TextDecoder().decode(first.value)).toBe("first");
319 yield* f[action];
320 expect((yield* Effect.promise(() => reader.read())).done).toBe(true);
321 expect(f.finalized()).toBe(1);
322 }),
323 );
324});