@jxsuite/server 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "1.3.0",
3
+ "version": "2.1.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,8 @@
20
20
  "./import-api": "./src/import-api.ts",
21
21
  "./packages": "./src/packages.ts",
22
22
  "./project-server": "./src/project-server.ts",
23
+ "./oauth-loopback": "./src/oauth-loopback.ts",
24
+ "./site-preview": "./src/site-preview.ts",
23
25
  "./refactor": "./src/refactor/index.ts",
24
26
  "./resolve": "./src/resolve.ts",
25
27
  "./types": "./src/types.ts",
@@ -30,26 +32,27 @@
30
32
  "provenance": true
31
33
  },
32
34
  "scripts": {
33
- "upgrade": "bunx npm-check-updates -u && bun install",
34
- "test": "bun test --isolate",
35
- "test:coverage": "bun test --isolate --coverage"
35
+ "lint:errors": "bun run scripts/check-error-shapes.ts",
36
+ "test": "bun run lint:errors && bun test --isolate",
37
+ "test:coverage": "bun run lint:errors && bun test --isolate --coverage",
38
+ "upgrade": "bunx npm-check-updates -u && bun install"
36
39
  },
37
40
  "dependencies": {
38
- "@jxsuite/collab": "^0.5.1",
39
- "@jxsuite/compiler": "^1.4.0",
40
- "@jxsuite/create": "^1.2.1",
41
- "@jxsuite/import": "^0.38.1",
42
- "@jxsuite/protocol": "^0.6.1",
43
- "@jxsuite/runtime": "^1.3.1",
44
- "@jxsuite/schema": "^1.4.0",
45
- "@jxsuite/starters": "^1.2.1",
41
+ "@jxsuite/collab": "^0.7.0",
42
+ "@jxsuite/compiler": "^2.0.0",
43
+ "@jxsuite/create": "^1.3.0",
44
+ "@jxsuite/import": "^0.39.0",
45
+ "@jxsuite/protocol": "^1.1.0",
46
+ "@jxsuite/runtime": "^2.0.0",
47
+ "@jxsuite/schema": "^1.6.0",
48
+ "@jxsuite/starters": "^1.3.0",
46
49
  "chokidar": "^5.0.0",
47
- "kysely": "^0.29.4",
50
+ "kysely": "^0.29.5",
48
51
  "zod": "^4.4.3"
49
52
  },
50
53
  "devDependencies": {
51
- "@jxsuite/auth": "^0.4.1",
52
- "@jxsuite/connector": "^0.4.1",
53
- "@jxsuite/parser": "^1.4.0"
54
+ "@jxsuite/auth": "^0.5.0",
55
+ "@jxsuite/connector": "^0.5.0",
56
+ "@jxsuite/parser": "^1.5.0"
54
57
  }
55
58
  }
package/src/ai-api.ts CHANGED
@@ -19,6 +19,8 @@
19
19
  *
20
20
  * @license MIT
21
21
  */
22
+ import { problem, problemTypeForStatus } from "./problem.ts";
23
+ import { problemDetails } from "@jxsuite/protocol";
22
24
 
23
25
  // ─── Configuration ───────────────────────────────────────────────────────────
24
26
 
@@ -141,15 +143,15 @@ function writeSSE(controller: ReadableStreamDefaultController, event: unknown):
141
143
  controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
142
144
  }
143
145
 
144
- /** Write a JSON error response (for non-streaming endpoints and error cases). */
146
+ /**
147
+ * Write a failure response for the non-streaming endpoints.
148
+ *
149
+ * The status is chosen by the caller here rather than by the type, because this file's callers
150
+ * forward an upstream provider's status — which is information, and collapsing it to the type's own
151
+ * would throw it away.
152
+ */
145
153
  function jsonError(status: number, message: string): Response {
146
- return Response.json(
147
- { error: message },
148
- {
149
- status,
150
- headers: { "Content-Type": "application/json" },
151
- },
152
- );
154
+ return problem(problemTypeForStatus(status), message);
153
155
  }
154
156
 
155
157
  // ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
@@ -233,9 +235,16 @@ export async function handleChat(req: Request): Promise<Response> {
233
235
  if ((error as Error).name === "AbortError") {
234
236
  writeSSE(controller, { type: "done", stopReason: "cancelled" });
235
237
  } else {
238
+ const message = `Network error: ${(error as Error).message}`;
236
239
  writeSSE(controller, {
240
+ message,
241
+ /*
242
+ * The frame carries a problem rather than being one: the response began with a 200
243
+ * long before this failed, so nothing can change the status now. `message` stays for
244
+ * the readers that already show it (server.md §4.3).
245
+ */
246
+ problem: problemDetails("upstreamFailure", message),
237
247
  type: "error",
238
- message: `Network error: ${(error as Error).message}`,
239
248
  });
240
249
  }
241
250
  controller.close();
@@ -264,9 +273,11 @@ export async function handleChat(req: Request): Promise<Response> {
264
273
  /* Not JSON — use the raw body. */
265
274
  }
266
275
  writeSSE(controller, {
267
- type: "error",
268
- message: cleanMessage,
269
276
  code: String(response.status),
277
+ message: cleanMessage,
278
+ // The upstream's own status is preserved in `code`; the problem names the KIND.
279
+ problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
280
+ type: "error",
270
281
  });
271
282
  controller.close();
272
283
  return;
@@ -274,7 +285,11 @@ export async function handleChat(req: Request): Promise<Response> {
274
285
 
275
286
  const reader = response.body?.getReader();
276
287
  if (!reader) {
277
- writeSSE(controller, { type: "error", message: "No response body from upstream" });
288
+ writeSSE(controller, {
289
+ message: "No response body from upstream",
290
+ problem: problemDetails("upstreamFailure", "No response body from upstream"),
291
+ type: "error",
292
+ });
278
293
  controller.close();
279
294
  return;
280
295
  }
@@ -407,9 +422,11 @@ export async function handleChat(req: Request): Promise<Response> {
407
422
  controller.close();
408
423
  return;
409
424
  }
425
+ const message = `Stream error: ${(error as Error).message}`;
410
426
  writeSSE(controller, {
427
+ message,
428
+ problem: problemDetails("upstreamFailure", message),
411
429
  type: "error",
412
- message: `Stream error: ${(error as Error).message}`,
413
430
  });
414
431
  controller.close();
415
432
  }
package/src/build.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  /** Build.js — Configurable Bun.build pipeline */
2
2
 
3
+ import type { BuildConfig } from "bun";
4
+
5
+ /**
6
+ * One watched build entry.
7
+ *
8
+ * `match` and `label` belong to the watcher; every other key is forwarded verbatim to `Bun.build`,
9
+ * so a consumer can supply `plugins`, `splitting`, `naming`, `define` and the rest. That forwarding
10
+ * was always the behaviour (the implementation spreads `...opts`) but was not in the type, so a
11
+ * caller had no way to know it could keep a watched rebuild byte-identical to its release build —
12
+ * and the repo's own dev server silently did not.
13
+ */
14
+ export interface BuildEntry extends Partial<BuildConfig> {
15
+ entrypoints: string[];
16
+ outdir: string;
17
+ match?: ((path: string) => boolean) | RegExp;
18
+ label?: string;
19
+ }
20
+
3
21
  /**
4
22
  * Build all entries with sensible defaults (browser target, ESM, linked sourcemaps).
5
23
  *
@@ -10,14 +28,7 @@
10
28
  * label?: string;
11
29
  * }[]} builds
12
30
  */
13
- export async function buildAll(
14
- builds: {
15
- entrypoints: string[];
16
- outdir: string;
17
- match?: ((path: string) => boolean) | RegExp;
18
- label?: string;
19
- }[],
20
- ) {
31
+ export async function buildAll(builds: BuildEntry[]) {
21
32
  for (const entry of builds) {
22
33
  const { match: _match, label, ...opts } = entry;
23
34
  const result = await Bun.build({
@@ -48,15 +59,7 @@ export async function buildAll(
48
59
  * @param {string} changedFile
49
60
  * @returns {Promise<{ rebuilt: string[]; success: boolean }>}
50
61
  */
51
- export async function rebuild(
52
- builds: {
53
- entrypoints: string[];
54
- outdir: string;
55
- match?: ((path: string) => boolean) | RegExp;
56
- label?: string;
57
- }[],
58
- changedFile: string,
59
- ) {
62
+ export async function rebuild(builds: BuildEntry[], changedFile: string) {
60
63
  const rebuilt = [];
61
64
  let ok = true;
62
65
  for (const entry of builds) {
package/src/code-api.ts CHANGED
@@ -11,6 +11,7 @@ import { dirname, join } from "node:path";
11
11
  import { existsSync } from "node:fs";
12
12
  import { unlink } from "node:fs/promises";
13
13
  import { format } from "oxfmt";
14
+ import { problem } from "./problem.ts";
14
15
 
15
16
  interface OxcLabel {
16
17
  span: { offset: number; line: number; [key: string]: unknown };
@@ -121,7 +122,7 @@ export async function handleCodeApi(req: Request, url: URL) {
121
122
  try {
122
123
  body = (await req.json()) as CodeApiBody;
123
124
  } catch {
124
- return new Response("Invalid JSON", { status: 400 });
125
+ return problem("invalidRequest", "Invalid JSON");
125
126
  }
126
127
 
127
128
  const action = path.slice("/__studio/code/".length);
package/src/collab.ts CHANGED
@@ -17,9 +17,15 @@ import { resolve } from "node:path";
17
17
  import { readFile, writeFile } from "node:fs/promises";
18
18
  import { colorForKey } from "@jxsuite/collab/awareness-types";
19
19
  import { createCollabHost } from "@jxsuite/collab/room";
20
+ import {
21
+ COLLAB_SUBPROTOCOL,
22
+ offeredSubprotocols,
23
+ selectSubprotocol,
24
+ } from "@jxsuite/collab/negotiate";
20
25
  import { assertAccessible } from "./studio-api";
21
26
  import type { ServerWebSocket, WebSocketHandler } from "bun";
22
27
  import type { HostConnection } from "@jxsuite/collab/room";
28
+ import { problem } from "./problem.ts";
23
29
 
24
30
  const EMPTY_ROOM_GRACE_MS = 30_000;
25
31
 
@@ -64,7 +70,12 @@ export interface CollabRegistry {
64
70
  */
65
71
  handleRequest: (
66
72
  req: Request,
67
- server: { upgrade: (req: Request, opts: { data: CollabSocketData }) => boolean },
73
+ server: {
74
+ upgrade: (
75
+ req: Request,
76
+ opts: { data: CollabSocketData; headers?: Record<string, string> },
77
+ ) => boolean;
78
+ },
68
79
  ) => Response | undefined;
69
80
  /** Bun.serve websocket handlers backing the upgraded connections. */
70
81
  websocket: WebSocketHandler<CollabSocketData>;
@@ -192,10 +203,30 @@ export function createCollabRegistry(opts: {
192
203
 
193
204
  handleRequest(req, server) {
194
205
  if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
195
- const upgraded = server.upgrade(req, { data: { connection: null } });
196
- return upgraded ? undefined : new Response("Upgrade failed", { status: 400 });
206
+ /*
207
+ * RFC 6455 §4.2.2: echo one of the tokens the client offered, or none if it offered none.
208
+ * An offer we cannot satisfy is refused outright rather than upgraded — two peers whose
209
+ * envelopes disagree about merge granularity must not share a room, and a 400 here is a
210
+ * better outcome than the divergent-history merge that would follow.
211
+ */
212
+ const answer = selectSubprotocol(
213
+ offeredSubprotocols(req.headers.get("sec-websocket-protocol")),
214
+ );
215
+ if (answer.reject !== null) {
216
+ return problem("invalidRequest", answer.reject);
217
+ }
218
+ const upgraded = server.upgrade(req, {
219
+ data: { connection: null },
220
+ ...(answer.echo === null ? {} : { headers: { "Sec-WebSocket-Protocol": answer.echo } }),
221
+ });
222
+ return upgraded ? undefined : problem("invalidRequest", "Upgrade failed");
197
223
  }
198
- return Response.json({ collab: true, version: 1 });
224
+ /*
225
+ * The capability probe, which is also the negotiation input: a client learns here what this
226
+ * server speaks, because offering a subprotocol the server cannot echo would fail the
227
+ * handshake (RFC 6455 §4.1) rather than degrade. `version` stays for older clients.
228
+ */
229
+ return Response.json({ collab: true, protocols: [COLLAB_SUBPROTOCOL], version: 1 });
199
230
  },
200
231
 
201
232
  async stop() {
package/src/data-api.ts CHANGED
@@ -45,6 +45,7 @@ import type {
45
45
  SecretsSetRequest,
46
46
  SecretsSetResponse,
47
47
  } from "@jxsuite/protocol";
48
+ import { problem, problemTypeForStatus } from "./problem.ts";
48
49
 
49
50
  // ─── Local section shapes (structural — the connector owns the full types) ───
50
51
 
@@ -868,7 +869,13 @@ export async function handleDataApi(
868
869
  }
869
870
  }
870
871
  } catch (error) {
872
+ /*
873
+ * The one place a status is computed rather than named, so the TYPE is derived from it here
874
+ * instead of at each throw. `ApiError` predates the registry and carries only a status; mapping
875
+ * it once keeps the alternative — a `ProblemTypeName` threaded through every throw site — out
876
+ * of a file whose failures are all shapes of "the request was wrong".
877
+ */
871
878
  const status = error instanceof ApiError ? error.status : 500;
872
- return Response.json({ error: errorMessage(error) }, { status });
879
+ return problem(problemTypeForStatus(status), errorMessage(error));
873
880
  }
874
881
  }
package/src/import-api.ts CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { readFile } from "node:fs/promises";
24
24
  import { resolve } from "node:path";
25
+ import { problem } from "./problem.ts";
25
26
 
26
27
  export interface ImportApiOptions {
27
28
  /**
@@ -81,15 +82,15 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
81
82
  try {
82
83
  body = (await req.json()) as ImportRequestBody;
83
84
  } catch {
84
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
85
+ return problem("invalidRequest", "Invalid JSON body");
85
86
  }
86
87
 
87
88
  const { url, directory } = body;
88
89
  if (!url || !directory) {
89
- return Response.json({ error: "url and directory are required" }, { status: 400 });
90
+ return problem("invalidRequest", "url and directory are required");
90
91
  }
91
92
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
92
- return Response.json({ error: "url must start with http:// or https://" }, { status: 400 });
93
+ return problem("invalidRequest", "url must start with http:// or https://");
93
94
  }
94
95
 
95
96
  let destPath: string;
@@ -97,7 +98,7 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
97
98
  destPath = opts.resolveDest(directory);
98
99
  } catch (error) {
99
100
  const message = error instanceof Error ? error.message : String(error);
100
- return Response.json({ error: message }, { status: 400 });
101
+ return problem("invalidRequest", message);
101
102
  }
102
103
 
103
104
  const depth = clamp(body.depth ?? 1, 0, MAX_DEPTH);
package/src/jx-mounts.ts CHANGED
@@ -59,6 +59,17 @@ export async function handleJxMounts(
59
59
  if (!runtime) {
60
60
  return null;
61
61
  }
62
+ /*
63
+ * Pin the dev server's own origin, once, unless the project already set one.
64
+ *
65
+ * This is what makes "is this deployment served over HTTPS" answerable rather than guessed. The
66
+ * auth mount derives its cookie prefix and `Secure` flag from the scheme and defaults to secure,
67
+ * because on Cloudflare Workers there is no other signal — so the plain-HTTP case has to say so,
68
+ * and the dev server is the only host that is one. Setting it here rather than in `buildRuntime`
69
+ * is deliberate: the port is not known until a request arrives. It also pins social-provider
70
+ * callbacks to the right origin in dev, which they were previously deriving per request.
71
+ */
72
+ runtime.env["BETTER_AUTH_URL"] ??= url.origin;
62
73
  for (const { basePath, handler } of runtime.handlers) {
63
74
  if (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`)) {
64
75
  return handler(req, runtime.env);
package/src/net-guard.ts CHANGED
@@ -20,26 +20,47 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
20
20
  import { realpathSync } from "node:fs";
21
21
  import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
22
22
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
23
+ import { problem } from "./problem.ts";
23
24
 
24
25
  export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
25
26
 
27
+ /**
28
+ * The whole loopback block, not just `127.0.0.1`.
29
+ *
30
+ * IANA reserves `127.0.0.0/8` (RFC 1122 §3.2.1.3), and every address in it is the same machine —
31
+ * `127.0.0.2` reaches this process exactly as `127.0.0.1` does. A gate that recognized only the
32
+ * canonical spelling would reject a client that used any other, while granting nothing: an attacker
33
+ * who can send from `127.0.0.53` is already on the host.
34
+ */
35
+ const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
36
+
37
+ /**
38
+ * The unspecified address, accepted as a **Host** and never as an **Origin**.
39
+ *
40
+ * As a Host it is ordinary: a server bound to `0.0.0.0` inside a container is reached at that
41
+ * literal, and the request is as local as any other. As an Origin it is meaningless — no page is
42
+ * ever served from `http://0.0.0.0` — so a request claiming it is either confused or probing, and
43
+ * treating the two positions alike would widen the gate for nothing.
44
+ */
45
+ const UNSPECIFIED_HOST = "0.0.0.0";
46
+
47
+ /** Strip a trailing `:port`, keeping a bracketed IPv6 literal intact. */
48
+ function bareHost(host: string): string {
49
+ if (host.startsWith("[")) {
50
+ const end = host.indexOf("]");
51
+ return end === -1 ? host : host.slice(0, end + 1);
52
+ }
53
+ const colon = host.indexOf(":");
54
+ return colon === -1 ? host : host.slice(0, colon);
55
+ }
56
+
26
57
  /** True when a hostname (optionally with :port) is a loopback host. */
27
58
  export function isLoopbackHost(host: string | null | undefined): boolean {
28
59
  if (!host) {
29
60
  return false;
30
61
  }
31
- // Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
32
- let h = host;
33
- if (h.startsWith("[")) {
34
- const end = h.indexOf("]");
35
- h = end === -1 ? h : h.slice(0, end + 1);
36
- } else {
37
- const colon = h.indexOf(":");
38
- if (colon !== -1) {
39
- h = h.slice(0, colon);
40
- }
41
- }
42
- return LOOPBACK_HOSTS.has(h.toLowerCase());
62
+ const h = bareHost(host).toLowerCase();
63
+ return LOOPBACK_HOSTS.has(h) || IPV4_LOOPBACK.test(h);
43
64
  }
44
65
 
45
66
  /**
@@ -53,6 +74,7 @@ export function originIsLoopbackOrAbsent(req: Request): boolean {
53
74
  return true;
54
75
  }
55
76
  try {
77
+ // Deliberately NOT accepting the unspecified address here; see UNSPECIFIED_HOST.
56
78
  return isLoopbackHost(new URL(origin).hostname);
57
79
  } catch {
58
80
  return false;
@@ -68,36 +90,169 @@ export function hostIsLoopbackOrAbsent(req: Request): boolean {
68
90
  if (!host) {
69
91
  return true;
70
92
  }
71
- return isLoopbackHost(host);
93
+ return isLoopbackHost(host) || bareHost(host) === UNSPECIFIED_HOST;
72
94
  }
73
95
 
74
96
  /**
75
- * Origin/Host-only request gate (no token). Returns a 403 Response when the request is not
76
- * loopback-safe, else null. Suitable for a same-origin dev server: the browser sends a loopback
77
- * Origin on cross-origin POSTs, so this closes CSRF + DNS-rebinding without a shared token.
97
+ * How much cross-origin traffic a surface tolerates.
98
+ *
99
+ * `strict` is the default and what every privileged route uses. `embeddable` exists for exactly one
100
+ * reason: the desktop canvas renders the project inside an iframe on a **different origin**, so the
101
+ * subresources that page requests — its images, its stylesheets, its modules — legitimately arrive
102
+ * `cross-site`. Refusing those would break the canvas; accepting them for a route that can write a
103
+ * file or run an `import()` would hand away the containment. So the difference is a property of the
104
+ * surface, named at the call site.
78
105
  */
79
- export function originHostGate(req: Request): Response | null {
106
+ export type FetchPolicy = "strict" | "embeddable";
107
+
108
+ /**
109
+ * Whether `Sec-Fetch-*` says this request is one the surface accepts (Fetch Metadata Request
110
+ * Headers).
111
+ *
112
+ * The header states the requester's intent directly, which is what `Origin` cannot do: a
113
+ * same-origin GET omits `Origin` entirely, so the gate has to accept an absent one — a hole
114
+ * `Sec-Fetch-Site` does not have.
115
+ *
116
+ * **Absent means accept, and that is a hard requirement rather than a concession.** The header is
117
+ * browser-supplied. curl omits it, Bun-native clients omit it, the desktop RPC bridge omits it, and
118
+ * `packages/server/tests/**` builds well over a hundred bare `Request`s. Requiring it would refuse
119
+ * every non-browser client on the machine while stopping no attacker, because the threat model here
120
+ * is a _page_ — and a page always sends it. `fetchMetadataAbsentIsAccepted` in
121
+ * `tests/net-guard.test.ts` is the test that makes deleting this loud.
122
+ *
123
+ * Under `strict`:
124
+ *
125
+ * - `same-origin` and `none` — the served page and a typed URL. Allowed.
126
+ * - `cross-site` — allowed **only** for a top-level document navigation, which is a person following
127
+ * a link. A cross-site _subresource_ or form POST is the CSRF this gate exists for.
128
+ * - `same-site` — **denied**, which is stricter than the standard's Resource Isolation Policy and
129
+ * deliberate: on `127.0.0.1` there is no meaningful "site" wider than the origin, so `same-site`
130
+ * means _a different port on this machine_ — precisely the other-local-process threat a loopback
131
+ * bind cannot address.
132
+ *
133
+ * @param {Request} req
134
+ * @param {FetchPolicy} policy
135
+ * @returns {boolean}
136
+ */
137
+ export function fetchMetadataAllows(req: Request, policy: FetchPolicy = "strict"): boolean {
138
+ const site = req.headers.get("sec-fetch-site");
139
+ if (!site) {
140
+ return true;
141
+ }
142
+ if (site === "same-origin" || site === "none") {
143
+ return true;
144
+ }
145
+ if (site === "cross-site") {
146
+ if (policy === "embeddable") {
147
+ return true;
148
+ }
149
+ // A person following a link, and nothing else.
150
+ return (
151
+ req.headers.get("sec-fetch-mode") === "navigate" &&
152
+ req.headers.get("sec-fetch-dest") === "document" &&
153
+ req.method === "GET"
154
+ );
155
+ }
156
+ // `same-site` and anything unrecognized.
157
+ return policy === "embeddable" && site === "same-site";
158
+ }
159
+
160
+ /**
161
+ * Origin/Host + Fetch Metadata request gate (no token). Returns a 403 Response when the request is
162
+ * not loopback-safe, else null.
163
+ *
164
+ * Suitable for a same-origin dev server: the browser sends a loopback Origin on cross-origin POSTs,
165
+ * so this closes CSRF + DNS-rebinding without a shared token. The Fetch Metadata check is folded in
166
+ * here rather than added at each call site, which is what makes it reach every gated surface at
167
+ * once — there are no new call sites to remember.
168
+ */
169
+ export function originHostGate(req: Request, policy: FetchPolicy = "strict"): Response | null {
80
170
  if (!originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
81
- return new Response("Forbidden", { status: 403 });
171
+ return problem("forbidden", "Forbidden");
172
+ }
173
+ if (!fetchMetadataAllows(req, policy)) {
174
+ return problem("forbidden", "Forbidden");
82
175
  }
83
176
  return null;
84
177
  }
85
178
 
86
179
  /**
87
- * Token + Origin/Host gate. Returns a 403 Response when the token mismatches OR the request is not
88
- * loopback-safe, else null. Used by the cross-origin desktop server whose canvas iframe carries the
89
- * token in the URL. Pass `token: null` to skip the token check (Origin/Host only).
180
+ * Compare two secrets without leaking their common prefix through timing.
181
+ *
182
+ * The token is in a URL that a local process may be able to guess at but not read, so a
183
+ * character-by-character early return is a real oracle: an attacker who can time responses recovers
184
+ * it one character at a time. Length is compared first and separately, because a length mismatch is
185
+ * not secret — the token's length is fixed and public.
186
+ *
187
+ * @param {string} a
188
+ * @param {string} b
189
+ * @returns {boolean}
190
+ */
191
+ export function secretsMatch(a: string, b: string): boolean {
192
+ if (a.length !== b.length) {
193
+ return false;
194
+ }
195
+ let diff = 0;
196
+ for (let i = 0; i < a.length; i++) {
197
+ // oxlint-disable-next-line no-bitwise -- constant-time accumulation; a branch here is the leak.
198
+ diff |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0);
199
+ }
200
+ return diff === 0;
201
+ }
202
+
203
+ /**
204
+ * The token a request presents, from either place it may carry one.
205
+ *
206
+ * The query parameter is what the canvas iframe uses, because an iframe's `src` is the only place
207
+ * it can put one. `Authorization: Bearer` is accepted **additively** for everything else — a fetch,
208
+ * a curl, a desktop bridge — since a secret in a URL is logged, referred and shoulder-surfable, and
209
+ * offering a header costs nothing.
210
+ *
211
+ * @param {Request} req
212
+ * @param {URL} url
213
+ * @returns {string | null}
90
214
  */
91
- export function loopbackGate(req: Request, url: URL, token: string | null): Response | null {
92
- if (token !== null && url.searchParams.get("token") !== token) {
93
- return new Response("Forbidden", { status: 403 });
215
+ export function presentedToken(req: Request, url: URL): string | null {
216
+ const bearer = req.headers.get("authorization");
217
+ if (bearer?.toLowerCase().startsWith("bearer ")) {
218
+ const value = bearer.slice("bearer ".length).trim();
219
+ if (value !== "") {
220
+ return value;
221
+ }
94
222
  }
95
- return originHostGate(req);
223
+ return url.searchParams.get("token");
224
+ }
225
+
226
+ /**
227
+ * Token + Origin/Host + Fetch Metadata gate. Returns a 403 Response when the token mismatches OR
228
+ * the request is not loopback-safe, else null. Used by the cross-origin desktop server whose canvas
229
+ * iframe carries the token in the URL. Pass `token: null` to skip the token check.
230
+ */
231
+ export function loopbackGate(
232
+ req: Request,
233
+ url: URL,
234
+ token: string | null,
235
+ policy: FetchPolicy = "strict",
236
+ ): Response | null {
237
+ if (token !== null) {
238
+ const presented = presentedToken(req, url);
239
+ if (presented === null || !secretsMatch(presented, token)) {
240
+ return problem("forbidden", "Forbidden");
241
+ }
242
+ }
243
+ return originHostGate(req, policy);
96
244
  }
97
245
 
98
246
  /** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
99
247
  export function normalizeForCompare(p: string): string {
100
- const slashed = p.replaceAll("\\", "/");
248
+ /*
249
+ * NFC, and this is not cosmetic. `readdir` on macOS returns a decomposed filename while a path
250
+ * from a file picker, a config file or a URL arrives precomposed — two different strings for one
251
+ * file. Containment is a string comparison, so without normalizing, every path holding an accent
252
+ * silently failed to be contained: the file existed, the check said it was outside the root, and
253
+ * the request 404'd with nothing to search for.
254
+ */
255
+ const slashed = p.replaceAll("\\", "/").normalize("NFC");
101
256
  return process.platform === "win32" ? slashed.toLowerCase() : slashed;
102
257
  }
103
258
 
@@ -159,6 +314,11 @@ export async function serveContained(absPath: string, root: string): Promise<Res
159
314
  *
160
315
  * Mounts come first and are contained against their own directory rather than the project root:
161
316
  * they exist precisely to publish directories that may sit outside it (extensions.md §8.5).
317
+ *
318
+ * The project's BUILT OUTPUT is deliberately NOT here, nor anywhere else in an editing server's
319
+ * chain: these paths mean the project's SOURCES, and a built page addresses the same paths meaning
320
+ * its own output. The built site is served on its own origin instead — see
321
+ * {@link file://./site-preview.ts}.
162
322
  */
163
323
  export async function serveProjectFile(
164
324
  decodedPath: string,
@@ -219,10 +379,10 @@ export function decodeAndNormalizePath(url: URL): DecodedPath {
219
379
  try {
220
380
  path = decodeURIComponent(url.pathname);
221
381
  } catch {
222
- return { reject: new Response("Bad request", { status: 400 }) };
382
+ return { reject: problem("invalidRequest", "Bad request") };
223
383
  }
224
384
  if (/%2e|%2f/i.test(path)) {
225
- return { reject: new Response("Not found", { status: 404 }) };
385
+ return { reject: problem("notFound", "Not found") };
226
386
  }
227
387
  const normPath = path.replace(/^\/{2,}/, "/");
228
388
  return { path, normPath };