Skip to content

Commit d528877

Browse files
committed
🤖 feat: make server Coder app-proxy aware
Support Coder app-proxy base paths across HTTP routing, WebSocket upgrades, generated SPA base hrefs, Scalar docs, session cookie paths, and OAuth URLs. Validation: - bun test src/node/orpc/server.test.ts src/browser/utils/backendBaseUrl.test.ts src/common/appProxyBasePath.test.ts - make typecheck - make lint
1 parent ab86767 commit d528877

2 files changed

Lines changed: 600 additions & 130 deletions

File tree

src/node/orpc/server.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,62 @@ async function withProxyUriTemplateEnv<T>(
148148
}
149149
}
150150

151+
const APP_PROXY_BASE_PATH = "/@u/ws/apps/mux";
152+
const APP_PROXY_BASE_PATH_ALT = "/@alice/dev/apps/mux";
153+
154+
function countOccurrences(value: string, needle: string): number {
155+
return value.split(needle).length - 1;
156+
}
157+
158+
async function createStaticTestServer(
159+
options: {
160+
files?: Record<string, string>;
161+
context?: Partial<ORPCContext>;
162+
authToken?: string;
163+
} = {}
164+
): Promise<{
165+
server: Awaited<ReturnType<typeof createOrpcServer>>;
166+
tempDir: string;
167+
close: () => Promise<void>;
168+
}> {
169+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-static-app-proxy-"));
170+
const files = {
171+
"index.html":
172+
"<!doctype html><html><head><title>mux</title></head><body><div>ok</div></body></html>",
173+
...options.files,
174+
};
175+
176+
for (const [filePath, contents] of Object.entries(files)) {
177+
const absolutePath = path.join(tempDir, filePath);
178+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
179+
await fs.writeFile(absolutePath, contents, "utf-8");
180+
}
181+
182+
let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;
183+
try {
184+
server = await createOrpcServer({
185+
host: "127.0.0.1",
186+
port: 0,
187+
context: (options.context ?? {}) as ORPCContext,
188+
authToken: options.authToken,
189+
serveStatic: true,
190+
staticDir: tempDir,
191+
});
192+
} catch (error) {
193+
await fs.rm(tempDir, { recursive: true, force: true });
194+
throw error;
195+
}
196+
197+
return {
198+
server,
199+
tempDir,
200+
close: async () => {
201+
await server?.close();
202+
await fs.rm(tempDir, { recursive: true, force: true });
203+
},
204+
};
205+
}
206+
151207
describe("createOrpcServer", () => {
152208
test("serveStatic fallback does not swallow /api routes", async () => {
153209
// Minimal context stub - router won't be exercised by this test.
@@ -185,6 +241,212 @@ describe("createOrpcServer", () => {
185241
}
186242
});
187243

244+
test("serves SPA base href from the detected public base path per request", async () => {
245+
const { server, close } = await createStaticTestServer();
246+
247+
try {
248+
const rootRes = await fetch(`${server.baseUrl}/`);
249+
expect(rootRes.status).toBe(200);
250+
const rootHtml = await rootRes.text();
251+
expect(countOccurrences(rootHtml, '<base href="/" />')).toBe(1);
252+
253+
const forwardedPrefixRes = await fetch(`${server.baseUrl}/some/spa/route`, {
254+
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
255+
});
256+
expect(forwardedPrefixRes.status).toBe(200);
257+
const forwardedPrefixHtml = await forwardedPrefixRes.text();
258+
expect(forwardedPrefixHtml).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
259+
260+
const originalUriRes = await fetch(`${server.baseUrl}/`, {
261+
headers: { "X-Original-Uri": `${APP_PROXY_BASE_PATH}/` },
262+
});
263+
expect(originalUriRes.status).toBe(200);
264+
const originalUriHtml = await originalUriRes.text();
265+
expect(originalUriHtml).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
266+
267+
const firstPrefixRes = await fetch(`${server.baseUrl}/one`, {
268+
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
269+
});
270+
const secondPrefixRes = await fetch(`${server.baseUrl}/two`, {
271+
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH_ALT },
272+
});
273+
expect(await firstPrefixRes.text()).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
274+
expect(await secondPrefixRes.text()).toContain(`<base href="${APP_PROXY_BASE_PATH_ALT}/" />`);
275+
} finally {
276+
await close();
277+
}
278+
});
279+
280+
test("routes direct app-proxy HTTP requests to root-mounted handlers", async () => {
281+
const mainJs = "console.log('prefixed asset');";
282+
const authContext: Partial<ORPCContext> = {
283+
serverAuthService: {
284+
isGithubDeviceFlowEnabled: () => true,
285+
} as unknown as ORPCContext["serverAuthService"],
286+
};
287+
const { server, close } = await createStaticTestServer({
288+
files: { "assets/main.js": mainJs },
289+
context: authContext,
290+
});
291+
292+
try {
293+
const rootAssetRes = await fetch(`${server.baseUrl}/assets/main.js`);
294+
const prefixedAssetRes = await fetch(
295+
`${server.baseUrl}${APP_PROXY_BASE_PATH}/assets/main.js`
296+
);
297+
expect(prefixedAssetRes.status).toBe(200);
298+
expect(await prefixedAssetRes.text()).toBe(await rootAssetRes.text());
299+
300+
const prefixedSpaRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/settings`);
301+
expect(prefixedSpaRes.status).toBe(200);
302+
expect(await prefixedSpaRes.text()).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
303+
304+
const specRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/api/spec.json`);
305+
expect(specRes.status).toBe(200);
306+
expect(specRes.headers.get("content-type")).toContain("application/json");
307+
const spec = (await specRes.json()) as { servers?: Array<{ url?: string }> };
308+
expect(spec.servers?.[0]?.url).toBe(`${APP_PROXY_BASE_PATH}/api`);
309+
310+
const docsRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/api/docs`);
311+
expect(docsRes.status).toBe(200);
312+
expect(await docsRes.text()).toContain(`${APP_PROXY_BASE_PATH}/api/spec.json`);
313+
314+
const authRes = await fetch(
315+
`${server.baseUrl}${APP_PROXY_BASE_PATH}/auth/server-login/options`
316+
);
317+
expect(authRes.status).toBe(200);
318+
expect(await authRes.json()).toEqual({ githubDeviceFlowEnabled: true });
319+
320+
const client = createHttpClient(`${server.baseUrl}${APP_PROXY_BASE_PATH}`);
321+
const pingResult = await Promise.resolve(client.general.ping("app-proxy"));
322+
expect(pingResult).toBe("Pong: app-proxy");
323+
324+
const falsePositiveRes = await fetch(`${server.baseUrl}/projects/apps/other`);
325+
expect(falsePositiveRes.status).toBe(200);
326+
expect(await falsePositiveRes.text()).toContain('<base href="/" />');
327+
} finally {
328+
await close();
329+
}
330+
});
331+
332+
test("keeps origin validation active after direct app-proxy prefix stripping", async () => {
333+
const stubContext: Partial<ORPCContext> = {};
334+
let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;
335+
336+
try {
337+
server = await createOrpcServer({
338+
host: "127.0.0.1",
339+
port: 0,
340+
context: stubContext as ORPCContext,
341+
});
342+
343+
const rootResponse = await fetch(`${server.baseUrl}/orpc`, {
344+
headers: { Origin: "https://evil.example.com" },
345+
});
346+
const prefixedResponse = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/orpc`, {
347+
headers: { Origin: "https://evil.example.com" },
348+
});
349+
350+
expect(rootResponse.status).toBe(403);
351+
expect(prefixedResponse.status).toBe(rootResponse.status);
352+
} finally {
353+
await server?.close();
354+
}
355+
});
356+
357+
test("serves Scalar docs with request-specific spec URLs", async () => {
358+
const { server, close } = await createStaticTestServer();
359+
360+
try {
361+
const rootDocsRes = await fetch(`${server.baseUrl}/api/docs`);
362+
expect(rootDocsRes.status).toBe(200);
363+
expect(await rootDocsRes.text()).toContain('url: "/api/spec.json"');
364+
365+
const forwardedDocsRes = await fetch(`${server.baseUrl}/api/docs`, {
366+
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
367+
});
368+
expect(forwardedDocsRes.status).toBe(200);
369+
expect(await forwardedDocsRes.text()).toContain(
370+
`url: "${APP_PROXY_BASE_PATH}/api/spec.json"`
371+
);
372+
373+
const directDocsRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/api/docs`);
374+
expect(directDocsRes.status).toBe(200);
375+
expect(await directDocsRes.text()).toContain(`url: "${APP_PROXY_BASE_PATH}/api/spec.json"`);
376+
} finally {
377+
await close();
378+
}
379+
});
380+
381+
test("accepts direct app-proxy WebSocket upgrades", async () => {
382+
const stubContext: Partial<ORPCContext> = {};
383+
let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;
384+
let ws: WebSocket | null = null;
385+
386+
try {
387+
server = await createOrpcServer({
388+
host: "127.0.0.1",
389+
port: 0,
390+
context: stubContext as ORPCContext,
391+
});
392+
393+
ws = new WebSocket(
394+
`${server.baseUrl.replace(/^http/, "ws")}${APP_PROXY_BASE_PATH}/orpc/ws?token=test-token`
395+
);
396+
397+
await waitForWebSocketOpen(ws);
398+
await closeWebSocket(ws);
399+
ws = null;
400+
} finally {
401+
ws?.terminate();
402+
await server?.close();
403+
}
404+
});
405+
406+
test("includes app-proxy base paths in OAuth redirect and callback return URLs", async () => {
407+
let muxGatewayRedirectUri = "";
408+
const stubContext: Partial<ORPCContext> = {
409+
muxGatewayOauthService: {
410+
startServerFlow: (input: { redirectUri: string }) => {
411+
muxGatewayRedirectUri = input.redirectUri;
412+
return { authorizeUrl: "https://gateway.example.com/auth", state: "state-gateway" };
413+
},
414+
handleServerCallbackAndExchange: () => Promise.resolve({ success: true, data: undefined }),
415+
} as unknown as ORPCContext["muxGatewayOauthService"],
416+
};
417+
let server: Awaited<ReturnType<typeof createOrpcServer>> | null = null;
418+
419+
try {
420+
server = await createOrpcServer({
421+
host: "127.0.0.1",
422+
port: 0,
423+
context: stubContext as ORPCContext,
424+
authToken: "test-token",
425+
});
426+
427+
const startResponse = await fetch(`${server.baseUrl}/auth/mux-gateway/start`, {
428+
headers: {
429+
Authorization: "Bearer test-token",
430+
"X-Forwarded-Prefix": APP_PROXY_BASE_PATH,
431+
},
432+
});
433+
expect(startResponse.status).toBe(200);
434+
expect(muxGatewayRedirectUri).toBe(
435+
`${server.baseUrl}${APP_PROXY_BASE_PATH}/auth/mux-gateway/callback`
436+
);
437+
438+
const callbackResponse = await fetch(
439+
`${server.baseUrl}${APP_PROXY_BASE_PATH}/auth/mux-gateway/callback?state=test&code=test`
440+
);
441+
expect(callbackResponse.status).toBe(200);
442+
const callbackHtml = await callbackResponse.text();
443+
expect(callbackHtml).toContain(`href="${APP_PROXY_BASE_PATH}/"`);
444+
expect(callbackHtml).toContain(`window.location.replace("${APP_PROXY_BASE_PATH}/")`);
445+
} finally {
446+
await server?.close();
447+
}
448+
});
449+
188450
test("injects proxy URI template into SPA fallback HTML when env is set", async () => {
189451
const stubContext: Partial<ORPCContext> = {};
190452

0 commit comments

Comments
 (0)