@abstractframework/code 0.4.2 → 0.6.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/bin/server.js CHANGED
@@ -1,28 +1,46 @@
1
+ /**
2
+ * The AbstractCode web server: the built app (`dist/`) and the app-origin
3
+ * gateway session proxy, on the shared app-server kit
4
+ * (`@abstractframework/app-server`).
5
+ *
6
+ * - Serves under the gateway's `/apps/code/` as well as at its own port
7
+ * (`createMountedHandler`: base path from `X-Forwarded-Prefix`, the
8
+ * browser's address from a loopback peer's `X-Forwarded-For`, the identity
9
+ * header `X-AbstractFramework-App: code; mount=1` on every response). The
10
+ * shell gets `<base href>` and `base_path` (`injectShell`), so every asset
11
+ * and API call resolves under the base.
12
+ * - `/api/connection/gateway` and `/api/gateway/*` go through the kit's
13
+ * session proxy (HttpOnly session cookie + CSRF twin at `Path=<base>/`,
14
+ * browser `Authorization` and cookies stripped, `X-Forwarded-For` = the
15
+ * browser, server-pinned gateway URL).
16
+ * - What this app adds in front of the proxy: a browser mutation must come
17
+ * from this app's own origin, a sign-in must be JSON, and a sign-in over a
18
+ * loopback socket must name a loopback host (DNS-rebinding guard).
19
+ */
1
20
  import * as http from "node:http";
2
- import * as https from "node:https";
3
21
  import { existsSync, readFileSync, statSync } from "node:fs";
4
22
  import { isIP } from "node:net";
5
23
  import { dirname, extname, join, resolve, sep } from "node:path";
6
24
  import { fileURLToPath } from "node:url";
7
25
 
26
+ import {
27
+ createGatewaySessionProxy,
28
+ createMountedHandler,
29
+ injectShell,
30
+ requestContext,
31
+ socketPeerAddress,
32
+ } from "@abstractframework/app-server";
33
+
34
+ export { socketPeerAddress };
35
+
8
36
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
9
37
  const DEFAULT_DIST_DIR = resolve(BIN_DIR, "..", "dist");
10
- const FALLBACK_GATEWAY_URL = "http://127.0.0.1:8080";
11
- const URL_COOKIE = "abstractcode_gateway_url";
12
- const SESSION_COOKIE = "abstractcode_gateway_session";
13
- const CSRF_COOKIE = "abstractcode_gateway_csrf";
14
- const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on"]);
15
- const HOP_HEADERS = new Set([
16
- "connection",
17
- "keep-alive",
18
- "proxy-authenticate",
19
- "proxy-authorization",
20
- "te",
21
- "trailer",
22
- "trailers",
23
- "transfer-encoding",
24
- "upgrade",
25
- ]);
38
+ /** The gateway's catalog id: `/apps/code/` and the identity header. */
39
+ export const APP_ID = "code";
40
+ /** Cookie / CSRF-header prefix (`abstractcode_gateway_*`, `x-abstractcode-csrf`). */
41
+ export const COOKIE_APP_ID = "abstractcode";
42
+ const CONNECTION_PATH = "/api/connection/gateway";
43
+ const GATEWAY_PREFIX = "/api/gateway/";
26
44
  const MIME_TYPES = {
27
45
  ".html": "text/html",
28
46
  ".js": "application/javascript",
@@ -37,608 +55,151 @@ const MIME_TYPES = {
37
55
  ".webmanifest": "application/manifest+json",
38
56
  };
39
57
 
40
- function normalizeGatewayUrl(value) {
41
- return String(value || "")
42
- .trim()
43
- .replace(/\/+$/, "");
44
- }
45
-
46
- function serverConfig(options = {}) {
47
- const env = options.env || process.env;
48
- const defaultGatewayUrl =
49
- normalizeGatewayUrl(
50
- options.defaultGatewayUrl ||
51
- env.ABSTRACTCODE_GATEWAY_URL ||
52
- env.ABSTRACTGATEWAY_URL ||
53
- FALLBACK_GATEWAY_URL,
54
- ) || FALLBACK_GATEWAY_URL;
55
- return { env, defaultGatewayUrl };
56
- }
57
-
58
- function envBool(env, name) {
59
- const raw = env[name];
60
- return typeof raw === "string" && TRUE_VALUES.has(raw.trim().toLowerCase());
58
+ function sendJson(res, status, payload) {
59
+ if (res.headersSent) {
60
+ res.destroy();
61
+ return;
62
+ }
63
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
64
+ res.end(JSON.stringify(payload));
61
65
  }
62
66
 
63
- function requestHostname(req, config) {
64
- const trusted =
65
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
66
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
67
- const value = trusted
68
- ? req.headers["x-forwarded-host"] || req.headers.host
69
- : req.headers.host;
70
- const raw = String(value || "")
67
+ function firstHeader(value) {
68
+ return String(Array.isArray(value) ? value[0] : value || "")
71
69
  .split(",", 1)[0]
72
70
  .trim();
73
- if (!raw) return "";
74
- if (raw.startsWith("["))
75
- return raw.slice(1).split("]", 1)[0].trim().toLowerCase();
76
- if ((raw.match(/:/g) || []).length === 1)
77
- return raw.split(":")[0].trim().toLowerCase();
78
- return raw.toLowerCase();
79
71
  }
80
72
 
81
- function isLoopbackHostname(hostname) {
82
- const value = String(hostname || "")
83
- .trim()
84
- .toLowerCase();
85
- if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true;
86
- return isIP(value) === 4 && Number(value.split(".", 1)[0]) === 127;
73
+ function mutatingMethod(method) {
74
+ return ["POST", "PUT", "PATCH", "DELETE"].includes(String(method || "GET").toUpperCase());
87
75
  }
88
76
 
89
- function isLoopbackPeer(req) {
90
- let address = String(req.socket?.remoteAddress || "")
91
- .trim()
92
- .toLowerCase();
93
- if (address.startsWith("::ffff:")) address = address.slice("::ffff:".length);
94
- return (
95
- address === "::1" || address === "127.0.0.1" || address.startsWith("127.")
96
- );
77
+ function hostnameOf(host) {
78
+ const raw = String(host || "").trim().toLowerCase();
79
+ if (raw.startsWith("[")) return raw.slice(1).split("]", 1)[0];
80
+ return (raw.match(/:/g) || []).length === 1 ? raw.split(":")[0] : raw;
97
81
  }
98
82
 
99
- function connectionConfigAllowed(req, config) {
100
- if (envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG"))
101
- return true;
102
- if (
103
- envBool(config.env, "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG")
104
- )
105
- return true;
106
- const trusted =
107
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
108
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
109
- // A trusted proxy makes its own socket address meaningless. Hosted
110
- // deployments must explicitly opt in before a browser can choose an SSRF
111
- // destination; direct loopback development remains convenient.
112
- return (
113
- !trusted &&
114
- isLoopbackPeer(req) &&
115
- isLoopbackHostname(requestHostname(req, config))
116
- );
83
+ /** `localhost`, `*.localhost`, `::1` or a 127.x IP LITERAL (never a DNS name starting "127."). */
84
+ function isLoopbackHostname(host) {
85
+ const h = hostnameOf(host);
86
+ if (h === "localhost" || h.endsWith(".localhost") || h === "::1") return true;
87
+ return isIP(h) === 4 && h.startsWith("127.");
117
88
  }
118
89
 
119
- function connectionConfigDenial(req, config) {
120
- const host = requestHostname(req, config) || "unknown host";
90
+ /**
91
+ * The warning printed at start when `--host` is not a loopback address
92
+ * (`0.0.0.0`, `::`, a LAN address or name): the app is then reachable from
93
+ * other machines, and the gateway already serves it at `/apps/code/`.
94
+ * `null` for a loopback host.
95
+ */
96
+ export function bindExposureWarning(host, port) {
97
+ if (isLoopbackHostname(host)) return null;
98
+ const where = host === "0.0.0.0" || host === "::" || host === "[::]" ? "every network interface" : String(host);
121
99
  return (
122
- `Browser-supplied Gateway URL changes are disabled for this non-local Code host (${host}). ` +
123
- "Use the server-configured Gateway URL, or set ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control."
124
- );
125
- }
126
-
127
- function parseCookies(req) {
128
- const out = {};
129
- for (const part of String(req.headers.cookie || "").split(";")) {
130
- const index = part.indexOf("=");
131
- if (index < 0) continue;
132
- const key = part.slice(0, index).trim();
133
- const value = part.slice(index + 1).trim();
134
- if (!key) continue;
135
- try {
136
- out[key] = decodeURIComponent(value);
137
- } catch {
138
- out[key] = value;
139
- }
140
- }
141
- return out;
142
- }
143
-
144
- function cookieSecure(req, config) {
145
- if (req.socket?.encrypted) return "; Secure";
146
- const trusted =
147
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
148
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
149
- return trusted && firstHeader(req.headers["x-forwarded-proto"]).toLowerCase() === "https"
150
- ? "; Secure"
151
- : "";
152
- }
153
-
154
- function setSessionCookies(
155
- res,
156
- req,
157
- gatewayUrl,
158
- sessionId,
159
- csrfToken,
160
- persist,
161
- config,
162
- ) {
163
- const secure = cookieSecure(req, config);
164
- const maxAge = persist ? "; Max-Age=2592000" : "";
165
- const privateAttrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
166
- const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
167
- res.setHeader("Set-Cookie", [
168
- `${URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${privateAttrs}`,
169
- `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}${privateAttrs}`,
170
- `${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
171
- ]);
172
- }
173
-
174
- function clearSessionCookies(res, req, config) {
175
- const secure = cookieSecure(req, config);
176
- res.setHeader("Set-Cookie", [
177
- `${URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
178
- `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
179
- `${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
180
- ]);
181
- }
182
-
183
- function browserSession(req, config) {
184
- const cookies = parseCookies(req);
185
- const cookieUrl = normalizeGatewayUrl(cookies[URL_COOKIE]);
186
- const allowCookieUrl =
187
- envBool(config.env, "ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE") ||
188
- envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG") ||
189
- envBool(
190
- config.env,
191
- "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG",
192
- ) ||
193
- connectionConfigAllowed(req, config);
194
- return {
195
- gatewayUrl:
196
- cookieUrl && (allowCookieUrl || cookieUrl === config.defaultGatewayUrl)
197
- ? cookieUrl
198
- : config.defaultGatewayUrl,
199
- sessionId: String(cookies[SESSION_COOKIE] || "").trim(),
200
- csrfToken: String(cookies[CSRF_COOKIE] || "").trim(),
201
- };
202
- }
203
-
204
- function resolveBackend(gatewayUrl, config) {
205
- const url = new URL(
206
- normalizeGatewayUrl(gatewayUrl) || config.defaultGatewayUrl,
207
- );
208
- if (url.protocol !== "http:" && url.protocol !== "https:")
209
- throw new Error("Gateway URL must use http or https");
210
- if (!url.port) url.port = url.protocol === "https:" ? "443" : "80";
211
- return {
212
- url,
213
- origin: `${url.protocol}//${url.host}`,
214
- client: url.protocol === "https:" ? https : http,
215
- };
216
- }
217
-
218
- function mutatingMethod(method) {
219
- return ["POST", "PUT", "PATCH", "DELETE"].includes(
220
- String(method || "GET").toUpperCase(),
221
- );
222
- }
223
-
224
- function firstHeader(value) {
225
- return String(Array.isArray(value) ? value[0] : value || "")
226
- .split(",", 1)[0]
227
- .trim();
228
- }
229
-
230
- function requestOrigin(req, config) {
231
- const trusted =
232
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
233
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
234
- const host = firstHeader(
235
- trusted
236
- ? req.headers["x-forwarded-host"] || req.headers.host
237
- : req.headers.host,
100
+ `WARNING: --host ${host} exposes AbstractCode Web beyond this machine: it listens on ${where}, ` +
101
+ `so anyone who can reach this computer on port ${port} can open it. ` +
102
+ `The gateway already serves it at /apps/code/; keep the default --host 127.0.0.1 unless you mean this.`
238
103
  );
239
- const protocol =
240
- firstHeader(trusted ? req.headers["x-forwarded-proto"] : "") ||
241
- (req.socket?.encrypted ? "https" : "http");
242
- if (!host || !["http", "https"].includes(protocol.toLowerCase())) return "";
243
- try {
244
- return new URL(`${protocol.toLowerCase()}://${host}`).origin;
245
- } catch {
246
- return "";
247
- }
248
104
  }
249
105
 
250
- function browserMutationAllowed(req, config) {
106
+ /** A browser mutation must come from the origin the browser sees this app at. */
107
+ function browserMutationAllowed(req, ctx) {
251
108
  const origin = firstHeader(req.headers.origin);
252
109
  if (origin) {
253
- let normalized;
254
110
  try {
255
- normalized = new URL(origin).origin;
111
+ return new URL(origin).origin === new URL(`${ctx.proto}://${ctx.host}`).origin;
256
112
  } catch {
257
113
  return false;
258
114
  }
259
- return normalized === requestOrigin(req, config);
260
115
  }
261
- return (
262
- firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site"
263
- );
116
+ return firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site";
264
117
  }
265
118
 
266
119
  function jsonRequest(req) {
267
- return (
268
- firstHeader(req.headers["content-type"])
269
- .split(";", 1)[0]
270
- .trim()
271
- .toLowerCase() === "application/json"
272
- );
120
+ return firstHeader(req.headers["content-type"]).split(";", 1)[0].trim().toLowerCase() === "application/json";
273
121
  }
274
122
 
275
- function sendJson(res, status, payload) {
276
- if (res.headersSent) {
277
- res.destroy();
278
- return;
279
- }
280
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
281
- res.end(JSON.stringify(payload));
123
+ function isLoopbackPeerSocket(req) {
124
+ const peer = socketPeerAddress(req);
125
+ return peer === "::1" || peer.startsWith("127.");
282
126
  }
283
127
 
284
- function readRequestJson(req) {
285
- return new Promise((resolveValue) => {
286
- const chunks = [];
287
- req.on("data", (chunk) => chunks.push(chunk));
288
- req.on("end", () => {
289
- try {
290
- const raw = Buffer.concat(chunks).toString("utf8");
291
- resolveValue(raw ? JSON.parse(raw) : {});
292
- } catch {
293
- resolveValue({});
294
- }
295
- });
296
- req.on("error", () => resolveValue({}));
297
- });
128
+ /** The operator declared a reverse proxy in front of this app (legacy env switches, read by the kit too). */
129
+ function trustProxyDeclared() {
130
+ return ["ABSTRACTCODE_TRUST_PROXY_HEADERS", "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS"].some((name) =>
131
+ ["1", "true", "yes", "y", "on"].includes(String(process.env[name] || "").trim().toLowerCase()),
132
+ );
298
133
  }
299
134
 
300
- function gatewayRequest(gatewayUrl, options, body, config) {
301
- return new Promise((resolveValue) => {
302
- let backend;
303
- try {
304
- backend = resolveBackend(gatewayUrl, config);
305
- } catch (error) {
306
- resolveValue({
307
- ok: false,
308
- status: 0,
309
- payload: {
310
- detail: `Invalid gateway URL: ${String(error?.message || error)}`,
311
- },
312
- });
313
- return;
314
- }
315
- const request = backend.client.request(
316
- {
317
- protocol: backend.url.protocol,
318
- hostname: backend.url.hostname,
319
- port: backend.url.port,
320
- timeout: options.timeout || 4000,
321
- ...options,
322
- },
323
- (response) => {
324
- const chunks = [];
325
- response.on("data", (chunk) => chunks.push(chunk));
326
- response.on("end", () => {
327
- const raw = Buffer.concat(chunks).toString("utf8");
328
- let payload;
329
- try {
330
- payload = raw ? JSON.parse(raw) : {};
331
- } catch {
332
- payload = { detail: raw };
333
- }
334
- const status = response.statusCode || 0;
335
- resolveValue({
336
- ok: status >= 200 && status < 300,
337
- status,
338
- payload,
339
- headers: response.headers,
340
- origin: backend.origin,
341
- });
342
- });
343
- },
344
- );
345
- request.on("timeout", () => {
346
- request.destroy();
347
- resolveValue({
348
- ok: false,
349
- status: 0,
350
- payload: { detail: "Gateway request timed out" },
351
- origin: backend.origin,
352
- });
353
- });
354
- request.on("error", (error) =>
355
- resolveValue({
356
- ok: false,
357
- status: 0,
358
- payload: { detail: String(error?.message || error) },
359
- origin: backend.origin,
360
- }),
361
- );
362
- if (body) request.write(body);
363
- request.end();
364
- });
135
+ function pathnameOf(req) {
136
+ return new URL(req.url || "/", "http://abstractcode.local").pathname;
365
137
  }
366
138
 
367
- function cookieValueFromSetCookie(rawHeaders, name) {
368
- const headers = Array.isArray(rawHeaders)
369
- ? rawHeaders
370
- : rawHeaders
371
- ? [rawHeaders]
372
- : [];
373
- for (const header of headers) {
374
- for (const candidate of String(header || "").split(/,(?=\s*[^;,=]+=)/)) {
375
- const first = candidate.split(";", 1)[0];
376
- const index = first.indexOf("=");
377
- if (index < 0 || first.slice(0, index).trim() !== name) continue;
378
- const raw = first.slice(index + 1).trim();
379
- try {
380
- return decodeURIComponent(raw);
381
- } catch {
382
- return raw;
383
- }
139
+ /**
140
+ * The gateway half: connection API + `/api/gateway/*`, with this app's
141
+ * guards in front of the kit proxy. Connect-compatible (`next` for every
142
+ * other path), so the Vite dev server mounts the SAME code.
143
+ */
144
+ export function createGatewayMiddleware(options = {}) {
145
+ const proxy = createGatewaySessionProxy({
146
+ appId: COOKIE_APP_ID,
147
+ ...(options.defaultGatewayUrl ? { defaultGatewayUrl: options.defaultGatewayUrl } : {}),
148
+ connectionPath: CONNECTION_PATH,
149
+ proxyPrefix: GATEWAY_PREFIX,
150
+ });
151
+ function middleware(req, res, next) {
152
+ let pathname;
153
+ try {
154
+ pathname = pathnameOf(req);
155
+ } catch {
156
+ sendJson(res, 400, { detail: "Invalid request URL" });
157
+ return;
384
158
  }
385
- }
386
- return "";
387
- }
388
-
389
- async function handleConnectionApi(req, res, config) {
390
- if (req.method === "GET") {
391
- const session = browserSession(req, config);
392
- if (!session.sessionId) {
393
- sendJson(res, 200, {
394
- ok: false,
395
- gateway_url: session.gatewayUrl,
396
- has_session: false,
397
- gateway: { ok: false, error: "Gateway sign-in required" },
398
- });
159
+ const owned = pathname === CONNECTION_PATH || pathname.startsWith(GATEWAY_PREFIX);
160
+ if (!owned) {
161
+ if (typeof next === "function") next();
162
+ else sendJson(res, 404, { detail: "Not found" });
399
163
  return;
400
164
  }
401
- const checked = await gatewayRequest(
402
- session.gatewayUrl,
403
- {
404
- method: "GET",
405
- path: "/api/gateway/me",
406
- headers: {
407
- Accept: "application/json",
408
- "X-AbstractGateway-Session": session.sessionId,
409
- },
410
- },
411
- undefined,
412
- config,
413
- );
414
- sendJson(res, 200, {
415
- ok: checked.ok,
416
- gateway_url: session.gatewayUrl,
417
- has_session: true,
418
- gateway: checked.payload,
419
- });
420
- return;
421
- }
422
- if (req.method === "POST") {
423
- if (!jsonRequest(req)) {
424
- sendJson(res, 415, {
425
- detail:
426
- "Gateway connection requests require Content-Type: application/json",
427
- });
165
+ let ctx;
166
+ try {
167
+ ctx = requestContext(req);
168
+ } catch (error) {
169
+ sendJson(res, Number(error?.status) || 400, { detail: String(error?.message || error) });
428
170
  return;
429
171
  }
430
- const payload = await readRequestJson(req);
431
- const gatewayUrl =
432
- normalizeGatewayUrl(payload.gateway_url || config.defaultGatewayUrl) ||
433
- config.defaultGatewayUrl;
172
+ // DNS rebinding: a page at a hostile name resolving to 127.0.0.1 reaches
173
+ // this loopback server with its own Host; as a same-origin script it can
174
+ // also add X-Forwarded-* headers, which a loopback peer is believed for.
175
+ // So a sign-in over a loopback socket must name a loopback host (the
176
+ // gateway's /apps/code/ proxy and a local browser both do), unless the
177
+ // operator declared a reverse proxy in front of this app.
434
178
  if (
435
- !connectionConfigAllowed(req, config) &&
436
- gatewayUrl !== config.defaultGatewayUrl
179
+ pathname === CONNECTION_PATH &&
180
+ mutatingMethod(req.method) &&
181
+ isLoopbackPeerSocket(req) &&
182
+ !isLoopbackHostname(req.headers.host) &&
183
+ !trustProxyDeclared()
437
184
  ) {
438
- sendJson(res, 403, { detail: connectionConfigDenial(req, config) });
439
- return;
440
- }
441
- const body = Buffer.from(
442
- JSON.stringify({
443
- user_id: String(payload.gateway_user_id || "").trim(),
444
- token: String(payload.gateway_token || "").trim(),
445
- remember: payload.persist === true,
446
- }),
447
- );
448
- const login = await gatewayRequest(
449
- gatewayUrl,
450
- {
451
- method: "POST",
452
- path: "/api/gateway/session/login",
453
- headers: {
454
- Accept: "application/json",
455
- "Content-Type": "application/json",
456
- "Content-Length": String(body.length),
457
- },
458
- },
459
- body,
460
- config,
461
- );
462
- const session =
463
- login.payload && typeof login.payload.session === "object"
464
- ? login.payload.session
465
- : {};
466
- const setCookie = login.headers?.["set-cookie"];
467
- const sessionId =
468
- cookieValueFromSetCookie(setCookie, "abstractgateway_session") ||
469
- String(session.session_id || "").trim();
470
- const csrfToken =
471
- cookieValueFromSetCookie(setCookie, "abstractgateway_csrf") ||
472
- String(session.csrf_token || "").trim();
473
- if (!login.ok || !sessionId || !csrfToken) {
474
- sendJson(res, login.status || 401, {
475
- ok: false,
476
- detail: login.payload?.detail || "Gateway browser session failed",
477
- gateway: login.payload,
478
- });
479
- return;
480
- }
481
- setSessionCookies(
482
- res,
483
- req,
484
- gatewayUrl,
485
- sessionId,
486
- csrfToken,
487
- payload.persist === true,
488
- config,
489
- );
490
- sendJson(res, 200, {
491
- ok: true,
492
- gateway_url: gatewayUrl,
493
- has_session: true,
494
- gateway: login.payload,
495
- });
496
- return;
497
- }
498
- if (req.method === "DELETE") {
499
- const session = browserSession(req, config);
500
- if (session.sessionId) {
501
- const body = Buffer.from("{}");
502
- await gatewayRequest(
503
- session.gatewayUrl,
504
- {
505
- method: "POST",
506
- path: "/api/gateway/session/logout",
507
- timeout: 2000,
508
- headers: {
509
- Accept: "application/json",
510
- "Content-Type": "application/json",
511
- "Content-Length": String(body.length),
512
- "X-AbstractGateway-Session": session.sessionId,
513
- "X-AbstractGateway-CSRF": session.csrfToken,
514
- },
515
- },
516
- body,
517
- config,
518
- );
519
- }
520
- clearSessionCookies(res, req, config);
521
- sendJson(res, 200, { ok: true });
522
- return;
523
- }
524
- sendJson(res, 405, { detail: "Method not allowed" });
525
- }
526
-
527
- function proxyHeaders(headers) {
528
- const out = {};
529
- for (const [key, value] of Object.entries(headers || {})) {
530
- const normalized = String(key).toLowerCase();
531
- if (HOP_HEADERS.has(normalized) || normalized === "content-length")
532
- continue;
533
- out[key] = value;
534
- }
535
- return out;
536
- }
537
-
538
- function proxyGatewayRequest(req, res, config) {
539
- const session = browserSession(req, config);
540
- if (!session.sessionId) {
541
- sendJson(res, 401, { detail: "Gateway sign-in required" });
542
- return;
543
- }
544
- if (mutatingMethod(req.method)) {
545
- const presented = String(req.headers["x-abstractcode-csrf"] || "").trim();
546
- if (!session.csrfToken || presented !== session.csrfToken) {
547
185
  sendJson(res, 403, {
548
- detail: "Gateway browser session CSRF token missing or invalid",
549
- reason_code: "csrf_required",
186
+ detail: `Sign-in refused: this local app was reached under the host name ${hostnameOf(req.headers.host) || "(none)"}, not a loopback address. Browser-supplied Gateway URL changes are disabled for it.`,
187
+ reason_code: "host_not_allowed",
550
188
  });
551
189
  return;
552
190
  }
553
- }
554
- let backend;
555
- try {
556
- backend = resolveBackend(session.gatewayUrl, config);
557
- } catch (error) {
558
- sendJson(res, 500, {
559
- detail: `Invalid gateway URL: ${String(error?.message || error)}`,
560
- });
561
- return;
562
- }
563
- const headers = { ...req.headers, host: backend.url.host };
564
- delete headers.cookie;
565
- delete headers.authorization;
566
- delete headers["x-forwarded-for"];
567
- delete headers["x-forwarded-host"];
568
- delete headers["x-forwarded-proto"];
569
- delete headers["x-abstractcode-csrf"];
570
- headers["x-abstractgateway-session"] = session.sessionId;
571
- if (mutatingMethod(req.method))
572
- headers["x-abstractgateway-csrf"] = session.csrfToken;
573
- const proxyRequest = backend.client.request(
574
- {
575
- protocol: backend.url.protocol,
576
- hostname: backend.url.hostname,
577
- port: backend.url.port,
578
- method: req.method,
579
- path: req.url,
580
- headers,
581
- },
582
- (proxyResponse) => {
583
- res.writeHead(
584
- proxyResponse.statusCode || 502,
585
- proxyHeaders(proxyResponse.headers),
586
- );
587
- proxyResponse.pipe(res);
588
- },
589
- );
590
- proxyRequest.on("error", (error) =>
591
- sendJson(res, 502, {
592
- detail: `Backend not reachable at ${backend.origin} (${String(error?.message || error)})`,
593
- }),
594
- );
595
- req.pipe(proxyRequest);
596
- }
597
-
598
- function requestPathname(req) {
599
- return new URL(req.url || "/", "http://abstractcode.local").pathname;
600
- }
601
-
602
- /** Connect-compatible middleware shared by Vite and the packaged server. */
603
- export function createGatewayMiddleware(options = {}) {
604
- const config = serverConfig(options);
605
- return function codeGatewayMiddleware(req, res, next) {
606
- let pathname;
607
- try {
608
- pathname = requestPathname(req);
609
- } catch {
610
- sendJson(res, 400, { detail: "Invalid request URL" });
191
+ if (mutatingMethod(req.method) && !browserMutationAllowed(req, ctx)) {
192
+ sendJson(res, 403, { detail: "Cross-origin browser requests are not allowed", reason_code: "origin_required" });
611
193
  return;
612
194
  }
613
- if (pathname === "/api/connection/gateway") {
614
- if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
615
- sendJson(res, 403, {
616
- detail: "Cross-origin browser requests are not allowed",
617
- reason_code: "origin_required",
618
- });
619
- return;
620
- }
621
- void handleConnectionApi(req, res, config).catch((error) => {
622
- sendJson(res, 500, {
623
- detail: `Gateway connection request failed (${String(error?.message || error)})`,
624
- });
625
- });
195
+ if (pathname === CONNECTION_PATH && req.method === "POST" && !jsonRequest(req)) {
196
+ sendJson(res, 415, { detail: "Gateway connection requests require Content-Type: application/json" });
626
197
  return;
627
198
  }
628
- if (pathname === "/api/gateway" || pathname.startsWith("/api/gateway/")) {
629
- if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
630
- sendJson(res, 403, {
631
- detail: "Cross-origin browser requests are not allowed",
632
- reason_code: "origin_required",
633
- });
634
- return;
635
- }
636
- proxyGatewayRequest(req, res, config);
637
- return;
638
- }
639
- if (typeof next === "function") next();
640
- else sendJson(res, 404, { detail: "Not found" });
641
- };
199
+ proxy.handle(req, res, pathname);
200
+ }
201
+ middleware.proxy = proxy;
202
+ return middleware;
642
203
  }
643
204
 
644
205
  function unsafeStaticPath(req) {
@@ -652,48 +213,60 @@ function unsafeStaticPath(req) {
652
213
  return decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..");
653
214
  }
654
215
 
655
- function serveFile(res, filePath) {
216
+ function isFile(filePath) {
656
217
  try {
657
- if (!existsSync(filePath) || !statSync(filePath).isFile()) return false;
658
- res.writeHead(200, {
659
- "Content-Type":
660
- MIME_TYPES[extname(filePath).toLowerCase()] ||
661
- "application/octet-stream",
662
- "Cache-Control": "no-cache",
663
- });
664
- res.end(readFileSync(filePath));
665
- return true;
218
+ return existsSync(filePath) && statSync(filePath).isFile();
666
219
  } catch {
667
220
  return false;
668
221
  }
669
222
  }
670
223
 
224
+ function serveFile(res, filePath) {
225
+ if (!isFile(filePath)) return false;
226
+ res.writeHead(200, {
227
+ "Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream",
228
+ "Cache-Control": "no-cache",
229
+ });
230
+ res.end(readFileSync(filePath));
231
+ return true;
232
+ }
233
+
234
+ /** The app shell with `<base href="<basePath>/">` and `base_path`. */
235
+ function serveShell(res, distDir, basePath, gatewayUrl) {
236
+ const index = join(distDir, "index.html");
237
+ if (!isFile(index)) return false;
238
+ const html = injectShell(readFileSync(index, "utf8"), { basePath, config: { gateway_url: gatewayUrl } });
239
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
240
+ res.end(html);
241
+ return true;
242
+ }
243
+
671
244
  export function createCodeRequestHandler(options = {}) {
672
245
  const distDir = resolve(options.distDir || DEFAULT_DIST_DIR);
673
246
  const gatewayMiddleware = createGatewayMiddleware(options);
674
- return function codeRequestHandler(req, res) {
247
+ return createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
675
248
  gatewayMiddleware(req, res, () => {
676
249
  let pathname;
677
250
  try {
678
- pathname = decodeURIComponent(requestPathname(req));
251
+ pathname = decodeURIComponent(pathnameOf(req));
679
252
  } catch {
680
253
  res.writeHead(400);
681
254
  res.end("Bad Request");
682
255
  return;
683
256
  }
684
- if (
685
- pathname === "/api" ||
686
- pathname.startsWith("/api/") ||
687
- unsafeStaticPath(req)
688
- ) {
689
- if (pathname === "/api" || pathname.startsWith("/api/"))
690
- sendJson(res, 404, { detail: "Not found" });
691
- else {
692
- res.writeHead(400);
693
- res.end("Bad Request");
694
- }
257
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
258
+ sendJson(res, 404, { detail: "Not found" });
695
259
  return;
696
260
  }
261
+ if (unsafeStaticPath(req)) {
262
+ res.writeHead(400);
263
+ res.end("Bad Request");
264
+ return;
265
+ }
266
+ const gatewayUrl = gatewayMiddleware.proxy.defaultGatewayUrl;
267
+ if (pathname === "/" || pathname === "/index.html") {
268
+ if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
269
+ }
697
270
  const filePath = resolve(distDir, pathname.replace(/^\/+/, ""));
698
271
  if (filePath !== distDir && !filePath.startsWith(`${distDir}${sep}`)) {
699
272
  res.writeHead(400);
@@ -702,12 +275,12 @@ export function createCodeRequestHandler(options = {}) {
702
275
  }
703
276
  if (serveFile(res, filePath)) return;
704
277
  if (serveFile(res, `${filePath}.html`)) return;
705
- if (serveFile(res, join(filePath, "index.html"))) return;
706
- if (serveFile(res, join(distDir, "index.html"))) return;
278
+ // Single-page app: any other path is the shell.
279
+ if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
707
280
  res.writeHead(404);
708
281
  res.end("Not Found");
709
282
  });
710
- };
283
+ });
711
284
  }
712
285
 
713
286
  export function createCodeServer(options = {}) {