@jxsuite/server 2.0.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 +19 -16
- package/src/ai-api.ts +30 -13
- package/src/code-api.ts +2 -1
- package/src/collab.ts +35 -4
- package/src/data-api.ts +8 -1
- package/src/import-api.ts +5 -4
- package/src/jx-mounts.ts +11 -0
- package/src/net-guard.ts +188 -28
- package/src/oauth-loopback.ts +373 -0
- package/src/packages.ts +13 -2
- package/src/problem.ts +89 -0
- package/src/project-server.ts +84 -11
- package/src/refactor/apply.ts +3 -57
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/index.ts +7 -0
- package/src/refactor/refs.ts +103 -63
- package/src/refactor/scan.ts +70 -0
- package/src/refactor/watcher.ts +4 -0
- package/src/resolve.ts +9 -10
- package/src/server.ts +36 -6
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +198 -89
- package/src/watch.ts +54 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "2.
|
|
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
|
-
"
|
|
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.
|
|
39
|
-
"@jxsuite/compiler": "^
|
|
40
|
-
"@jxsuite/create": "^1.
|
|
41
|
-
"@jxsuite/import": "^0.
|
|
42
|
-
"@jxsuite/protocol": "^1.
|
|
43
|
-
"@jxsuite/runtime": "^
|
|
44
|
-
"@jxsuite/schema": "^1.
|
|
45
|
-
"@jxsuite/starters": "^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.
|
|
50
|
+
"kysely": "^0.29.5",
|
|
48
51
|
"zod": "^4.4.3"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
51
|
-
"@jxsuite/auth": "^0.
|
|
52
|
-
"@jxsuite/connector": "^0.
|
|
53
|
-
"@jxsuite/parser": "^1.
|
|
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
|
-
/**
|
|
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
|
|
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, {
|
|
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/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
|
|
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: {
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
85
|
+
return problem("invalidRequest", "Invalid JSON body");
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
const { url, directory } = body;
|
|
88
89
|
if (!url || !directory) {
|
|
89
|
-
return
|
|
90
|
+
return problem("invalidRequest", "url and directory are required");
|
|
90
91
|
}
|
|
91
92
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
92
|
-
return
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* token in
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
382
|
+
return { reject: problem("invalidRequest", "Bad request") };
|
|
223
383
|
}
|
|
224
384
|
if (/%2e|%2f/i.test(path)) {
|
|
225
|
-
return { reject:
|
|
385
|
+
return { reject: problem("notFound", "Not found") };
|
|
226
386
|
}
|
|
227
387
|
const normPath = path.replace(/^\/{2,}/, "/");
|
|
228
388
|
return { path, normPath };
|