@abstractframework/code 0.5.0 → 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,656 +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
- /**
100
- * The browser connection's real transport peer (req.socket.remoteAddress),
101
- * IPv4-mapped IPv6 unwrapped, or "" when unknown. Headers are never read.
102
- * Every request this server sends to the gateway on a browser's behalf
103
- * carries it as `X-Forwarded-For`, OVERWRITING any client value (contract
104
- * A-2; same rule as abstractuic's app-server). An unknown peer is refused.
105
- */
106
- export function socketPeerAddress(req) {
107
- let address = String(req?.socket?.remoteAddress || "").trim().toLowerCase();
108
- if (address.startsWith("::ffff:") && address.includes(".")) address = address.slice(7);
109
- return address;
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.");
110
88
  }
111
89
 
112
- const UNKNOWN_PEER = { detail: "Cannot determine the client address of this connection" };
113
- /** Marks every request this server sends to the gateway as coming through
114
- * the Code app proxy (the gateway's same-machine fail-safe keys on it). A
115
- * browser-supplied value is always dropped and replaced. */
116
- const APP_PROXY_HEADER = "X-AbstractFramework-App-Proxy";
117
- const APP_PROXY_NAME = "code";
118
- const FORWARDING_HEADERS = new Set([
119
- "x-abstractframework-app-proxy",
120
- "x-forwarded-for",
121
- "x-forwarded-host",
122
- "x-forwarded-proto",
123
- "x-real-ip",
124
- "forwarded",
125
- ]);
126
-
127
- function connectionConfigAllowed(req, config) {
128
- if (envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG"))
129
- return true;
130
- if (
131
- envBool(config.env, "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG")
132
- )
133
- return true;
134
- const trusted =
135
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
136
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
137
- // A trusted proxy makes its own socket address meaningless. Hosted
138
- // deployments must explicitly opt in before a browser can choose an SSRF
139
- // destination; direct loopback development remains convenient.
140
- return (
141
- !trusted &&
142
- isLoopbackPeer(req) &&
143
- isLoopbackHostname(requestHostname(req, config))
144
- );
145
- }
146
-
147
- function connectionConfigDenial(req, config) {
148
- 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);
149
99
  return (
150
- `Browser-supplied Gateway URL changes are disabled for this non-local Code host (${host}). ` +
151
- "Use the server-configured Gateway URL, or set ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control."
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.`
152
103
  );
153
104
  }
154
105
 
155
- function parseCookies(req) {
156
- const out = {};
157
- for (const part of String(req.headers.cookie || "").split(";")) {
158
- const index = part.indexOf("=");
159
- if (index < 0) continue;
160
- const key = part.slice(0, index).trim();
161
- const value = part.slice(index + 1).trim();
162
- if (!key) continue;
163
- try {
164
- out[key] = decodeURIComponent(value);
165
- } catch {
166
- out[key] = value;
167
- }
168
- }
169
- return out;
170
- }
171
-
172
- function cookieSecure(req, config) {
173
- if (req.socket?.encrypted) return "; Secure";
174
- const trusted =
175
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
176
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
177
- return trusted && firstHeader(req.headers["x-forwarded-proto"]).toLowerCase() === "https"
178
- ? "; Secure"
179
- : "";
180
- }
181
-
182
- function setSessionCookies(
183
- res,
184
- req,
185
- gatewayUrl,
186
- sessionId,
187
- csrfToken,
188
- persist,
189
- config,
190
- ) {
191
- const secure = cookieSecure(req, config);
192
- const maxAge = persist ? "; Max-Age=2592000" : "";
193
- const privateAttrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
194
- const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
195
- res.setHeader("Set-Cookie", [
196
- `${URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${privateAttrs}`,
197
- `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}${privateAttrs}`,
198
- `${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
199
- ]);
200
- }
201
-
202
- function clearSessionCookies(res, req, config) {
203
- const secure = cookieSecure(req, config);
204
- res.setHeader("Set-Cookie", [
205
- `${URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
206
- `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
207
- `${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
208
- ]);
209
- }
210
-
211
- function browserSession(req, config) {
212
- const cookies = parseCookies(req);
213
- const cookieUrl = normalizeGatewayUrl(cookies[URL_COOKIE]);
214
- const allowCookieUrl =
215
- envBool(config.env, "ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE") ||
216
- envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG") ||
217
- envBool(
218
- config.env,
219
- "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG",
220
- ) ||
221
- connectionConfigAllowed(req, config);
222
- return {
223
- gatewayUrl:
224
- cookieUrl && (allowCookieUrl || cookieUrl === config.defaultGatewayUrl)
225
- ? cookieUrl
226
- : config.defaultGatewayUrl,
227
- sessionId: String(cookies[SESSION_COOKIE] || "").trim(),
228
- csrfToken: String(cookies[CSRF_COOKIE] || "").trim(),
229
- };
230
- }
231
-
232
- function resolveBackend(gatewayUrl, config) {
233
- const url = new URL(
234
- normalizeGatewayUrl(gatewayUrl) || config.defaultGatewayUrl,
235
- );
236
- if (url.protocol !== "http:" && url.protocol !== "https:")
237
- throw new Error("Gateway URL must use http or https");
238
- if (!url.port) url.port = url.protocol === "https:" ? "443" : "80";
239
- return {
240
- url,
241
- origin: `${url.protocol}//${url.host}`,
242
- client: url.protocol === "https:" ? https : http,
243
- };
244
- }
245
-
246
- function mutatingMethod(method) {
247
- return ["POST", "PUT", "PATCH", "DELETE"].includes(
248
- String(method || "GET").toUpperCase(),
249
- );
250
- }
251
-
252
- function firstHeader(value) {
253
- return String(Array.isArray(value) ? value[0] : value || "")
254
- .split(",", 1)[0]
255
- .trim();
256
- }
257
-
258
- function requestOrigin(req, config) {
259
- const trusted =
260
- envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
261
- envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
262
- const host = firstHeader(
263
- trusted
264
- ? req.headers["x-forwarded-host"] || req.headers.host
265
- : req.headers.host,
266
- );
267
- const protocol =
268
- firstHeader(trusted ? req.headers["x-forwarded-proto"] : "") ||
269
- (req.socket?.encrypted ? "https" : "http");
270
- if (!host || !["http", "https"].includes(protocol.toLowerCase())) return "";
271
- try {
272
- return new URL(`${protocol.toLowerCase()}://${host}`).origin;
273
- } catch {
274
- return "";
275
- }
276
- }
277
-
278
- function browserMutationAllowed(req, config) {
106
+ /** A browser mutation must come from the origin the browser sees this app at. */
107
+ function browserMutationAllowed(req, ctx) {
279
108
  const origin = firstHeader(req.headers.origin);
280
109
  if (origin) {
281
- let normalized;
282
110
  try {
283
- normalized = new URL(origin).origin;
111
+ return new URL(origin).origin === new URL(`${ctx.proto}://${ctx.host}`).origin;
284
112
  } catch {
285
113
  return false;
286
114
  }
287
- return normalized === requestOrigin(req, config);
288
115
  }
289
- return (
290
- firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site"
291
- );
116
+ return firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site";
292
117
  }
293
118
 
294
119
  function jsonRequest(req) {
295
- return (
296
- firstHeader(req.headers["content-type"])
297
- .split(";", 1)[0]
298
- .trim()
299
- .toLowerCase() === "application/json"
300
- );
301
- }
302
-
303
- function sendJson(res, status, payload) {
304
- if (res.headersSent) {
305
- res.destroy();
306
- return;
307
- }
308
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
309
- res.end(JSON.stringify(payload));
310
- }
311
-
312
- function readRequestJson(req) {
313
- return new Promise((resolveValue) => {
314
- const chunks = [];
315
- req.on("data", (chunk) => chunks.push(chunk));
316
- req.on("end", () => {
317
- try {
318
- const raw = Buffer.concat(chunks).toString("utf8");
319
- resolveValue(raw ? JSON.parse(raw) : {});
320
- } catch {
321
- resolveValue({});
322
- }
323
- });
324
- req.on("error", () => resolveValue({}));
325
- });
120
+ return firstHeader(req.headers["content-type"]).split(";", 1)[0].trim().toLowerCase() === "application/json";
326
121
  }
327
122
 
328
- function gatewayRequest(gatewayUrl, options, body, config) {
329
- return new Promise((resolveValue) => {
330
- let backend;
331
- try {
332
- backend = resolveBackend(gatewayUrl, config);
333
- } catch (error) {
334
- resolveValue({
335
- ok: false,
336
- status: 0,
337
- payload: {
338
- detail: `Invalid gateway URL: ${String(error?.message || error)}`,
339
- },
340
- });
341
- return;
342
- }
343
- const request = backend.client.request(
344
- {
345
- protocol: backend.url.protocol,
346
- hostname: backend.url.hostname,
347
- port: backend.url.port,
348
- timeout: options.timeout || 4000,
349
- ...options,
350
- },
351
- (response) => {
352
- const chunks = [];
353
- response.on("data", (chunk) => chunks.push(chunk));
354
- response.on("end", () => {
355
- const raw = Buffer.concat(chunks).toString("utf8");
356
- let payload;
357
- try {
358
- payload = raw ? JSON.parse(raw) : {};
359
- } catch {
360
- payload = { detail: raw };
361
- }
362
- const status = response.statusCode || 0;
363
- resolveValue({
364
- ok: status >= 200 && status < 300,
365
- status,
366
- payload,
367
- headers: response.headers,
368
- origin: backend.origin,
369
- });
370
- });
371
- },
372
- );
373
- request.on("timeout", () => {
374
- request.destroy();
375
- resolveValue({
376
- ok: false,
377
- status: 0,
378
- payload: { detail: "Gateway request timed out" },
379
- origin: backend.origin,
380
- });
381
- });
382
- request.on("error", (error) =>
383
- resolveValue({
384
- ok: false,
385
- status: 0,
386
- payload: { detail: String(error?.message || error) },
387
- origin: backend.origin,
388
- }),
389
- );
390
- if (body) request.write(body);
391
- request.end();
392
- });
393
- }
394
-
395
- function cookieValueFromSetCookie(rawHeaders, name) {
396
- const headers = Array.isArray(rawHeaders)
397
- ? rawHeaders
398
- : rawHeaders
399
- ? [rawHeaders]
400
- : [];
401
- for (const header of headers) {
402
- for (const candidate of String(header || "").split(/,(?=\s*[^;,=]+=)/)) {
403
- const first = candidate.split(";", 1)[0];
404
- const index = first.indexOf("=");
405
- if (index < 0 || first.slice(0, index).trim() !== name) continue;
406
- const raw = first.slice(index + 1).trim();
407
- try {
408
- return decodeURIComponent(raw);
409
- } catch {
410
- return raw;
411
- }
412
- }
413
- }
414
- return "";
415
- }
416
-
417
- async function handleConnectionApi(req, res, config) {
123
+ function isLoopbackPeerSocket(req) {
418
124
  const peer = socketPeerAddress(req);
419
- if (!peer) {
420
- sendJson(res, 400, UNKNOWN_PEER);
421
- return;
422
- }
423
- if (req.method === "GET") {
424
- const session = browserSession(req, config);
425
- if (!session.sessionId) {
426
- sendJson(res, 200, {
427
- ok: false,
428
- gateway_url: session.gatewayUrl,
429
- has_session: false,
430
- gateway: { ok: false, error: "Gateway sign-in required" },
431
- });
432
- return;
433
- }
434
- const checked = await gatewayRequest(
435
- session.gatewayUrl,
436
- {
437
- method: "GET",
438
- path: "/api/gateway/me",
439
- headers: {
440
- Accept: "application/json",
441
- "X-AbstractGateway-Session": session.sessionId,
442
- "X-Forwarded-For": peer,
443
- [APP_PROXY_HEADER]: APP_PROXY_NAME,
444
- },
445
- },
446
- undefined,
447
- config,
448
- );
449
- sendJson(res, 200, {
450
- ok: checked.ok,
451
- gateway_url: session.gatewayUrl,
452
- has_session: true,
453
- gateway: checked.payload,
454
- });
455
- return;
456
- }
457
- if (req.method === "POST") {
458
- if (!jsonRequest(req)) {
459
- sendJson(res, 415, {
460
- detail:
461
- "Gateway connection requests require Content-Type: application/json",
462
- });
463
- return;
464
- }
465
- const payload = await readRequestJson(req);
466
- const gatewayUrl =
467
- normalizeGatewayUrl(payload.gateway_url || config.defaultGatewayUrl) ||
468
- config.defaultGatewayUrl;
469
- if (
470
- !connectionConfigAllowed(req, config) &&
471
- gatewayUrl !== config.defaultGatewayUrl
472
- ) {
473
- sendJson(res, 403, { detail: connectionConfigDenial(req, config) });
474
- return;
475
- }
476
- const body = Buffer.from(
477
- JSON.stringify({
478
- user_id: String(payload.gateway_user_id || "").trim(),
479
- token: String(payload.gateway_token || "").trim(),
480
- remember: payload.persist === true,
481
- }),
482
- );
483
- const login = await gatewayRequest(
484
- gatewayUrl,
485
- {
486
- method: "POST",
487
- path: "/api/gateway/session/login",
488
- headers: {
489
- Accept: "application/json",
490
- "Content-Type": "application/json",
491
- "Content-Length": String(body.length),
492
- "X-Forwarded-For": peer,
493
- [APP_PROXY_HEADER]: APP_PROXY_NAME,
494
- },
495
- },
496
- body,
497
- config,
498
- );
499
- const session =
500
- login.payload && typeof login.payload.session === "object"
501
- ? login.payload.session
502
- : {};
503
- const setCookie = login.headers?.["set-cookie"];
504
- const sessionId =
505
- cookieValueFromSetCookie(setCookie, "abstractgateway_session") ||
506
- String(session.session_id || "").trim();
507
- const csrfToken =
508
- cookieValueFromSetCookie(setCookie, "abstractgateway_csrf") ||
509
- String(session.csrf_token || "").trim();
510
- if (!login.ok || !sessionId || !csrfToken) {
511
- sendJson(res, login.status || 401, {
512
- ok: false,
513
- detail: login.payload?.detail || "Gateway browser session failed",
514
- gateway: login.payload,
515
- });
516
- return;
517
- }
518
- setSessionCookies(
519
- res,
520
- req,
521
- gatewayUrl,
522
- sessionId,
523
- csrfToken,
524
- payload.persist === true,
525
- config,
526
- );
527
- sendJson(res, 200, {
528
- ok: true,
529
- gateway_url: gatewayUrl,
530
- has_session: true,
531
- gateway: login.payload,
532
- });
533
- return;
534
- }
535
- if (req.method === "DELETE") {
536
- const session = browserSession(req, config);
537
- if (session.sessionId) {
538
- const body = Buffer.from("{}");
539
- await gatewayRequest(
540
- session.gatewayUrl,
541
- {
542
- method: "POST",
543
- path: "/api/gateway/session/logout",
544
- timeout: 2000,
545
- headers: {
546
- Accept: "application/json",
547
- "Content-Type": "application/json",
548
- "Content-Length": String(body.length),
549
- "X-AbstractGateway-Session": session.sessionId,
550
- "X-AbstractGateway-CSRF": session.csrfToken,
551
- "X-Forwarded-For": peer,
552
- [APP_PROXY_HEADER]: APP_PROXY_NAME,
553
- },
554
- },
555
- body,
556
- config,
557
- );
558
- }
559
- clearSessionCookies(res, req, config);
560
- sendJson(res, 200, { ok: true });
561
- return;
562
- }
563
- sendJson(res, 405, { detail: "Method not allowed" });
564
- }
565
-
566
- function proxyHeaders(headers) {
567
- const out = {};
568
- for (const [key, value] of Object.entries(headers || {})) {
569
- const normalized = String(key).toLowerCase();
570
- if (HOP_HEADERS.has(normalized) || normalized === "content-length")
571
- continue;
572
- out[key] = value;
573
- }
574
- return out;
125
+ return peer === "::1" || peer.startsWith("127.");
575
126
  }
576
127
 
577
- function proxyGatewayRequest(req, res, config) {
578
- const session = browserSession(req, config);
579
- if (!session.sessionId) {
580
- sendJson(res, 401, { detail: "Gateway sign-in required" });
581
- return;
582
- }
583
- const peer = socketPeerAddress(req);
584
- if (!peer) {
585
- sendJson(res, 400, UNKNOWN_PEER);
586
- return;
587
- }
588
- if (mutatingMethod(req.method)) {
589
- const presented = String(req.headers["x-abstractcode-csrf"] || "").trim();
590
- if (!session.csrfToken || presented !== session.csrfToken) {
591
- sendJson(res, 403, {
592
- detail: "Gateway browser session CSRF token missing or invalid",
593
- reason_code: "csrf_required",
594
- });
595
- return;
596
- }
597
- }
598
- let backend;
599
- try {
600
- backend = resolveBackend(session.gatewayUrl, config);
601
- } catch (error) {
602
- sendJson(res, 500, {
603
- detail: `Invalid gateway URL: ${String(error?.message || error)}`,
604
- });
605
- return;
606
- }
607
- const headers = { ...req.headers, host: backend.url.host };
608
- delete headers.cookie;
609
- delete headers.authorization;
610
- // Forwarding headers: drop every client-supplied spelling (any case, plus
611
- // RFC 7239 `Forwarded` and `X-Real-IP`), then X-Forwarded-For = the socket
612
- // peer, so the gateway can tell whether the browser is on its machine.
613
- for (const key of Object.keys(headers))
614
- if (FORWARDING_HEADERS.has(key.toLowerCase())) delete headers[key];
615
- headers["x-forwarded-for"] = peer;
616
- headers["x-abstractframework-app-proxy"] = APP_PROXY_NAME;
617
- delete headers["x-abstractcode-csrf"];
618
- headers["x-abstractgateway-session"] = session.sessionId;
619
- if (mutatingMethod(req.method))
620
- headers["x-abstractgateway-csrf"] = session.csrfToken;
621
- const proxyRequest = backend.client.request(
622
- {
623
- protocol: backend.url.protocol,
624
- hostname: backend.url.hostname,
625
- port: backend.url.port,
626
- method: req.method,
627
- path: req.url,
628
- headers,
629
- },
630
- (proxyResponse) => {
631
- res.writeHead(
632
- proxyResponse.statusCode || 502,
633
- proxyHeaders(proxyResponse.headers),
634
- );
635
- proxyResponse.pipe(res);
636
- },
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()),
637
132
  );
638
- proxyRequest.on("error", (error) =>
639
- sendJson(res, 502, {
640
- detail: `Backend not reachable at ${backend.origin} (${String(error?.message || error)})`,
641
- }),
642
- );
643
- req.pipe(proxyRequest);
644
133
  }
645
134
 
646
- function requestPathname(req) {
135
+ function pathnameOf(req) {
647
136
  return new URL(req.url || "/", "http://abstractcode.local").pathname;
648
137
  }
649
138
 
650
- /** Connect-compatible middleware shared by Vite and the packaged server. */
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
+ */
651
144
  export function createGatewayMiddleware(options = {}) {
652
- const config = serverConfig(options);
653
- return function codeGatewayMiddleware(req, res, next) {
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) {
654
152
  let pathname;
655
153
  try {
656
- pathname = requestPathname(req);
154
+ pathname = pathnameOf(req);
657
155
  } catch {
658
156
  sendJson(res, 400, { detail: "Invalid request URL" });
659
157
  return;
660
158
  }
661
- if (pathname === "/api/connection/gateway") {
662
- if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
663
- sendJson(res, 403, {
664
- detail: "Cross-origin browser requests are not allowed",
665
- reason_code: "origin_required",
666
- });
667
- return;
668
- }
669
- void handleConnectionApi(req, res, config).catch((error) => {
670
- sendJson(res, 500, {
671
- detail: `Gateway connection request failed (${String(error?.message || error)})`,
672
- });
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" });
163
+ return;
164
+ }
165
+ let ctx;
166
+ try {
167
+ ctx = requestContext(req);
168
+ } catch (error) {
169
+ sendJson(res, Number(error?.status) || 400, { detail: String(error?.message || error) });
170
+ return;
171
+ }
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.
178
+ if (
179
+ pathname === CONNECTION_PATH &&
180
+ mutatingMethod(req.method) &&
181
+ isLoopbackPeerSocket(req) &&
182
+ !isLoopbackHostname(req.headers.host) &&
183
+ !trustProxyDeclared()
184
+ ) {
185
+ sendJson(res, 403, {
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",
673
188
  });
674
189
  return;
675
190
  }
676
- if (pathname === "/api/gateway" || pathname.startsWith("/api/gateway/")) {
677
- if (mutatingMethod(req.method) && !browserMutationAllowed(req, config)) {
678
- sendJson(res, 403, {
679
- detail: "Cross-origin browser requests are not allowed",
680
- reason_code: "origin_required",
681
- });
682
- return;
683
- }
684
- proxyGatewayRequest(req, res, config);
191
+ if (mutatingMethod(req.method) && !browserMutationAllowed(req, ctx)) {
192
+ sendJson(res, 403, { detail: "Cross-origin browser requests are not allowed", reason_code: "origin_required" });
193
+ return;
194
+ }
195
+ if (pathname === CONNECTION_PATH && req.method === "POST" && !jsonRequest(req)) {
196
+ sendJson(res, 415, { detail: "Gateway connection requests require Content-Type: application/json" });
685
197
  return;
686
198
  }
687
- if (typeof next === "function") next();
688
- else sendJson(res, 404, { detail: "Not found" });
689
- };
199
+ proxy.handle(req, res, pathname);
200
+ }
201
+ middleware.proxy = proxy;
202
+ return middleware;
690
203
  }
691
204
 
692
205
  function unsafeStaticPath(req) {
@@ -700,48 +213,60 @@ function unsafeStaticPath(req) {
700
213
  return decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..");
701
214
  }
702
215
 
703
- function serveFile(res, filePath) {
216
+ function isFile(filePath) {
704
217
  try {
705
- if (!existsSync(filePath) || !statSync(filePath).isFile()) return false;
706
- res.writeHead(200, {
707
- "Content-Type":
708
- MIME_TYPES[extname(filePath).toLowerCase()] ||
709
- "application/octet-stream",
710
- "Cache-Control": "no-cache",
711
- });
712
- res.end(readFileSync(filePath));
713
- return true;
218
+ return existsSync(filePath) && statSync(filePath).isFile();
714
219
  } catch {
715
220
  return false;
716
221
  }
717
222
  }
718
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
+
719
244
  export function createCodeRequestHandler(options = {}) {
720
245
  const distDir = resolve(options.distDir || DEFAULT_DIST_DIR);
721
246
  const gatewayMiddleware = createGatewayMiddleware(options);
722
- return function codeRequestHandler(req, res) {
247
+ return createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
723
248
  gatewayMiddleware(req, res, () => {
724
249
  let pathname;
725
250
  try {
726
- pathname = decodeURIComponent(requestPathname(req));
251
+ pathname = decodeURIComponent(pathnameOf(req));
727
252
  } catch {
728
253
  res.writeHead(400);
729
254
  res.end("Bad Request");
730
255
  return;
731
256
  }
732
- if (
733
- pathname === "/api" ||
734
- pathname.startsWith("/api/") ||
735
- unsafeStaticPath(req)
736
- ) {
737
- if (pathname === "/api" || pathname.startsWith("/api/"))
738
- sendJson(res, 404, { detail: "Not found" });
739
- else {
740
- res.writeHead(400);
741
- res.end("Bad Request");
742
- }
257
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
258
+ sendJson(res, 404, { detail: "Not found" });
743
259
  return;
744
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
+ }
745
270
  const filePath = resolve(distDir, pathname.replace(/^\/+/, ""));
746
271
  if (filePath !== distDir && !filePath.startsWith(`${distDir}${sep}`)) {
747
272
  res.writeHead(400);
@@ -750,12 +275,12 @@ export function createCodeRequestHandler(options = {}) {
750
275
  }
751
276
  if (serveFile(res, filePath)) return;
752
277
  if (serveFile(res, `${filePath}.html`)) return;
753
- if (serveFile(res, join(filePath, "index.html"))) return;
754
- 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;
755
280
  res.writeHead(404);
756
281
  res.end("Not Found");
757
282
  });
758
- };
283
+ });
759
284
  }
760
285
 
761
286
  export function createCodeServer(options = {}) {