@abstractframework/continuum 0.3.2 → 0.4.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.
Files changed (73) hide show
  1. package/README.md +8 -3
  2. package/bin/cli.js +16 -160
  3. package/bin/hub_proxy.js +52 -43
  4. package/bin/request_gate.js +43 -0
  5. package/bin/server.js +232 -0
  6. package/bin/settings.js +45 -28
  7. package/dist/assets/{abnfDiagram-VRR7QNED-C3nZSPxd.js → abnfDiagram-VRR7QNED-CptTkH57.js} +2 -2
  8. package/dist/assets/{arc-Co6CTENJ.js → arc-oQ5R3mHX.js} +2 -2
  9. package/dist/assets/{architectureDiagram-ZJ3FMSHR-CEz8QvCN.js → architectureDiagram-ZJ3FMSHR-Bn00f0nM.js} +2 -2
  10. package/dist/assets/{blockDiagram-677ZJIJ3-B_5v6oR-.js → blockDiagram-677ZJIJ3-BgQg2xzI.js} +2 -2
  11. package/dist/assets/{c4Diagram-LMCZKHZV-B2dYGCte.js → c4Diagram-LMCZKHZV-DSrFKkwF.js} +2 -2
  12. package/dist/assets/channel-BBUeEZaL.js +2 -0
  13. package/dist/assets/{chunk-2Q5K7J3B-2eGZLgzu.js → chunk-2Q5K7J3B-D8JcDR0u.js} +2 -2
  14. package/dist/assets/{chunk-32BRIVSS-szXiGdYg.js → chunk-32BRIVSS-Di3jxbyw.js} +2 -2
  15. package/dist/assets/{chunk-5VM5RSS4-F7rd4WOC.js → chunk-5VM5RSS4-xA0TZltq.js} +2 -2
  16. package/dist/assets/{chunk-EX3LRPZG-BBaSONus.js → chunk-EX3LRPZG-BkNMQroi.js} +2 -2
  17. package/dist/assets/{chunk-JWPE2WC7-BzQLSiNr.js → chunk-JWPE2WC7-DaTATpXD.js} +2 -2
  18. package/dist/assets/{chunk-MOJQB5TN-CV4r6R-P.js → chunk-MOJQB5TN-CzzOSJWb.js} +2 -2
  19. package/dist/assets/{chunk-RYQCIY6F-lSwdPTG2.js → chunk-RYQCIY6F-DAhqq1xl.js} +2 -2
  20. package/dist/assets/{chunk-V7JOEXUC-BVe47Wh6.js → chunk-V7JOEXUC-D88CIHey.js} +2 -2
  21. package/dist/assets/{chunk-VR4S4FIN-Xsn7KCEd.js → chunk-VR4S4FIN-CV5xjwAx.js} +2 -2
  22. package/dist/assets/{chunk-XXDRQBXY-DdG9ww2-.js → chunk-XXDRQBXY-CHnfAGOy.js} +2 -2
  23. package/dist/assets/classDiagram-OUVF2IWQ-GIwsZVZi.js +2 -0
  24. package/dist/assets/classDiagram-v2-EOCWNBFH-GIwsZVZi.js +2 -0
  25. package/dist/assets/{cose-bilkent-JH36ORCC-DDdy2AQ-.js → cose-bilkent-JH36ORCC-BgTNPlnQ.js} +2 -2
  26. package/dist/assets/{cynefin-VYW2F7L2-PkaGuTvX.js → cynefin-VYW2F7L2-DFRQoaMD.js} +3 -3
  27. package/dist/assets/{cynefinDiagram-TSTJHNR4-BHIvz7Or.js → cynefinDiagram-TSTJHNR4-DA2MOQXI.js} +2 -2
  28. package/dist/assets/{dagre-VKFMJZFB-Dt336PVs.js → dagre-VKFMJZFB-BUIShmyq.js} +2 -2
  29. package/dist/assets/{diagram-FQU43EPY-DrmE36bq.js → diagram-FQU43EPY-DVjgItuu.js} +2 -2
  30. package/dist/assets/{diagram-G47NLZAW-tpUlukyV.js → diagram-G47NLZAW-eJSMzGjb.js} +2 -2
  31. package/dist/assets/{diagram-NH7WQ7WH-CO5Zb4_d.js → diagram-NH7WQ7WH-uJNNsz13.js} +2 -2
  32. package/dist/assets/{diagram-OA4YK3LP-B0FwAFRE.js → diagram-OA4YK3LP-xmJewu37.js} +2 -2
  33. package/dist/assets/{diagram-WEI45ONY-CcLxvhZ0.js → diagram-WEI45ONY-CaXYx2ei.js} +2 -2
  34. package/dist/assets/{ebnfDiagram-CCIWWBDH-CdGlfEot.js → ebnfDiagram-CCIWWBDH-BrgvjnU5.js} +2 -2
  35. package/dist/assets/{erDiagram-Q63AITRT-CxYfUHK8.js → erDiagram-Q63AITRT-CFF8G5wJ.js} +2 -2
  36. package/dist/assets/{flowDiagram-23GEKE2U-fW4F9z69.js → flowDiagram-23GEKE2U-W02vEFp0.js} +2 -2
  37. package/dist/assets/{ganttDiagram-NO4QXBWP-BWM-HcND.js → ganttDiagram-NO4QXBWP-CRV1W7ht.js} +2 -2
  38. package/dist/assets/{gitGraphDiagram-IHSO6WYX-BlcnohGg.js → gitGraphDiagram-IHSO6WYX-CAe9Kkrb.js} +2 -2
  39. package/dist/assets/index-Cn7OlSFa.js +121 -0
  40. package/dist/assets/index-D1NTcviR.css +1 -0
  41. package/dist/assets/{infoDiagram-FWYZ7A6U-pJSOWVdi.js → infoDiagram-FWYZ7A6U-DzPZb8XV.js} +2 -2
  42. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CpvrrPMQ.js → ishikawaDiagram-FXEZZL3T-CUSZtsJ2.js} +2 -2
  43. package/dist/assets/{journeyDiagram-5HDEW3XC-DzFKBKHk.js → journeyDiagram-5HDEW3XC-Ce4DZjHq.js} +2 -2
  44. package/dist/assets/{kanban-definition-HUTT4EX6-BPmBQCPR.js → kanban-definition-HUTT4EX6-B2aNvJXN.js} +2 -2
  45. package/dist/assets/{linear-DmNa6TPI.js → linear-Cy9Vf-l3.js} +2 -2
  46. package/dist/assets/{mermaid.core-_-YW0o95.js → mermaid.core-rvwxvqSK.js} +6 -6
  47. package/dist/assets/{mindmap-definition-LN4V7U3C-Bdwo0rlk.js → mindmap-definition-LN4V7U3C-LIaA-8NC.js} +2 -2
  48. package/dist/assets/{pegDiagram-2B236MQR-CHZoedfQ.js → pegDiagram-2B236MQR-D3veo_Oo.js} +2 -2
  49. package/dist/assets/{pieDiagram-ENE6RG2P-Blp676Hr.js → pieDiagram-ENE6RG2P-DryYCAYE.js} +2 -2
  50. package/dist/assets/{quadrantDiagram-ABIIQ3AL-DhWHDdBX.js → quadrantDiagram-ABIIQ3AL-CQY6rt5A.js} +2 -2
  51. package/dist/assets/{railroadDiagram-RFXS5EU6-CEgbBDEg.js → railroadDiagram-RFXS5EU6-0QiEYfyx.js} +2 -2
  52. package/dist/assets/{requirementDiagram-TGXJPOKE-Ds8x8FbV.js → requirementDiagram-TGXJPOKE-CHk5lpA2.js} +2 -2
  53. package/dist/assets/{sankeyDiagram-HTMAVEWB-yKIsBDhP.js → sankeyDiagram-HTMAVEWB-CYPx25gJ.js} +2 -2
  54. package/dist/assets/{sequenceDiagram-DBY2YBRQ-DAgYlOsI.js → sequenceDiagram-DBY2YBRQ-BSUr-JBi.js} +2 -2
  55. package/dist/assets/{sizeCapture-X5ZJPWSS-C-LAlxdw.js → sizeCapture-X5ZJPWSS-B6ZebkVB.js} +2 -2
  56. package/dist/assets/{stateDiagram-2N3HPSRC-BLiS6piZ.js → stateDiagram-2N3HPSRC-Dmqo36MA.js} +2 -2
  57. package/dist/assets/stateDiagram-v2-6OUMAXLB-CwN_yVcy.js +2 -0
  58. package/dist/assets/swimlanes-5IMT3BWC-CuvrNUfQ.js +3 -0
  59. package/dist/assets/swimlanesDiagram-G3AALYLV-BFp8yG0X.js +9 -0
  60. package/dist/assets/{timeline-definition-FHXFAJF6-BrJ5JJYj.js → timeline-definition-FHXFAJF6-DSmOxhA8.js} +2 -2
  61. package/dist/assets/{vennDiagram-L72KCM5P-BT9M9-9s.js → vennDiagram-L72KCM5P-DKkd_lMz.js} +2 -2
  62. package/dist/assets/{wardleyDiagram-EHGQE667-CukiKHz8.js → wardleyDiagram-EHGQE667-DYYxdBBD.js} +2 -2
  63. package/dist/assets/{xychartDiagram-FW5EYKEG-CIidttLL.js → xychartDiagram-FW5EYKEG-Qsc3YQdG.js} +2 -2
  64. package/dist/index.html +5 -2
  65. package/package.json +3 -3
  66. package/dist/assets/channel-D3yEdLfb.js +0 -2
  67. package/dist/assets/classDiagram-OUVF2IWQ-DJJxVI5p.js +0 -2
  68. package/dist/assets/classDiagram-v2-EOCWNBFH-DJJxVI5p.js +0 -2
  69. package/dist/assets/index-CFGJJTR4.js +0 -123
  70. package/dist/assets/index-DAHZ6K6R.css +0 -1
  71. package/dist/assets/stateDiagram-v2-6OUMAXLB-CdmuVCk9.js +0 -2
  72. package/dist/assets/swimlanes-5IMT3BWC-Bj_dHHPI.js +0 -3
  73. package/dist/assets/swimlanesDiagram-G3AALYLV-CYrgsk-L.js +0 -9
package/README.md CHANGED
@@ -72,7 +72,11 @@ abstractcontinuum
72
72
  ```
73
73
 
74
74
  Open `http://localhost:3002` and sign in with a gateway user and token (the
75
- connection badge at the bottom of the sidebar). The token is exchanged
75
+ connection badge at the bottom of the sidebar). When your gateway manages
76
+ Continuum (the gateway console's **Apps** screen), open it from there
77
+ instead: the gateway serves it at `/apps/continuum/` on its own port, so a
78
+ remote or headless machine needs one port and one tunnel for the console and
79
+ every app. The token is exchanged
76
80
  server-side for an HttpOnly session cookie and never stored in the browser.
77
81
 
78
82
  The server is configured with launch flags or saved settings (full list in
@@ -85,8 +89,9 @@ abstractcontinuum config get # every setting and where it come
85
89
  ```
86
90
 
87
91
  - `--port` (default `3002`), `--host` (default `127.0.0.1`)
88
- - `--gateway-url` — the gateway this deployment talks to (default
89
- `http://127.0.0.1:8080`)
92
+ - `--gateway-url` (aliases `--gateway`, `--url`) — the gateway this
93
+ deployment talks to (default: the gateway installed on this computer, from
94
+ `~/.abstractframework/gateway.json`, else `http://127.0.0.1:8080`)
90
95
  - `--hub-url` / `--hub-seat` / `--hub-token-file` — the agora hub, your seat
91
96
  and its key for the Team page. The hub seat is also on the Settings page.
92
97
 
package/bin/cli.js CHANGED
@@ -6,23 +6,26 @@
6
6
  * shared app-origin session proxy (first-party cookies + CSRF; tokens never
7
7
  * in URLs) — the same @abstractframework/app-server module the observer,
8
8
  * flow, and code apps converge on.
9
+ *
10
+ * Mountable under the gateway at /apps/continuum/ (the kit's mount
11
+ * contract): every response announces `X-AbstractFramework-App:
12
+ * continuum; mount=1`, the page gets `<base href="<base path>/">` and
13
+ * `base_path`, and every "is this the operator's own computer?" check reads
14
+ * the kit's requestContext (bin/request_gate.js). Served at `/` on its own
15
+ * port it works exactly as before.
9
16
  */
10
17
 
11
- import * as http from 'http';
12
- import { readFileSync, existsSync, statSync } from 'fs';
13
- import { join, extname, dirname } from 'path';
18
+ import { readFileSync } from 'fs';
19
+ import { join, dirname } from 'path';
14
20
  import { fileURLToPath } from 'url';
15
- import { createGatewaySessionProxy } from '@abstractframework/app-server';
16
- import { createHubProxy } from './hub_proxy.js';
21
+ import { createContinuumServer } from './server.js';
17
22
  import {
18
23
  SETTINGS,
19
24
  SOURCE_WORDS,
20
25
  apply_session_proxy_gates,
21
26
  createLiveSettings,
22
- createSettingsRoute,
23
27
  default_settings_path,
24
28
  display_value,
25
- expand_home,
26
29
  parse_args,
27
30
  read_settings_file,
28
31
  resolve_settings,
@@ -53,6 +56,10 @@ function help_text() {
53
56
  const def = spec.type === 'bool' ? 'off' : spec.default === '' ? 'none' : String(spec.default);
54
57
  lines.push(col(left, spec.help));
55
58
  lines.push(`${' '.repeat(32)}default ${def}; setting ${spec.key}`);
59
+ if (spec.key === 'gateway_url') {
60
+ lines.push(`${' '.repeat(32)}also --gateway <url>, --url <url>; with none set, the gateway`);
61
+ lines.push(`${' '.repeat(32)}installed on this computer (~/.abstractframework/gateway.json)`);
62
+ }
56
63
  if (spec.key === 'hub_token') {
57
64
  lines.push(col('--hub-token-file <path>', 'read the hub token from a file (never printed)'));
58
65
  lines.push(`${' '.repeat(32)}setting hub_token`);
@@ -159,169 +166,18 @@ const PORT = AT_START.port.value;
159
166
  // binds are an explicit deployment choice (--host).
160
167
  const HOST = AT_START.host.value;
161
168
  const DEFAULT_GATEWAY_URL = AT_START.gateway_url.value;
162
-
163
169
  // The session proxy reads its hardening gates from the environment only;
164
170
  // hand it the resolved values (flag > setting > environment > default).
165
171
  apply_session_proxy_gates(AT_START, process.env);
166
172
 
167
- const MIME_TYPES = {
168
- '.html': 'text/html',
169
- '.js': 'application/javascript',
170
- '.css': 'text/css',
171
- '.json': 'application/json',
172
- '.png': 'image/png',
173
- '.jpg': 'image/jpeg',
174
- '.jpeg': 'image/jpeg',
175
- '.gif': 'image/gif',
176
- '.svg': 'image/svg+xml',
177
- '.ico': 'image/x-icon',
178
- '.webmanifest': 'application/manifest+json',
179
- };
180
-
181
- function getMimeType(filePath) {
182
- const ext = extname(filePath).toLowerCase();
183
- return MIME_TYPES[ext] || 'application/octet-stream';
184
- }
185
-
186
- // Content-Security-Policy for the app document (security adversary P1
187
- // 2026-07-16): untrusted markdown (channel files, messages, attachments)
188
- // renders in this app, and standard markdown allows remote images — a
189
- // crafted ![](https://attacker/beacon.png) would beacon the operator's IP
190
- // and read-timing to an external host the moment a preview opens. img-src
191
- // 'self' data: kills that class app-wide. script-src 'self' is a backstop
192
- // belt (the renderer already cannot emit script). ws:/wss: are needed
193
- // because 'self' does not reliably cover scheme-different WebSocket dials;
194
- // only our own bundle can open sockets (script-src), so this stays tight.
195
- const CSP = [
196
- "default-src 'self'",
197
- "script-src 'self'",
198
- "style-src 'self' 'unsafe-inline'", // React style={} attributes
199
- "img-src 'self' data:",
200
- "font-src 'self' data:",
201
- "connect-src 'self' ws: wss:",
202
- "object-src 'none'",
203
- "base-uri 'self'",
204
- "form-action 'self'",
205
- "frame-ancestors 'none'",
206
- ].join('; ');
207
-
208
- function serveFile(res, filePath) {
209
- try {
210
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
211
- return false;
212
- }
213
- const content = readFileSync(filePath);
214
- const mime = getMimeType(filePath);
215
- const headers = {
216
- 'Content-Type': mime,
217
- 'Cache-Control': 'no-cache',
218
- 'X-Content-Type-Options': 'nosniff',
219
- };
220
- // CSP is a document policy — attach it where a document loads.
221
- if (mime === 'text/html') headers['Content-Security-Policy'] = CSP;
222
- res.writeHead(200, headers);
223
- res.end(content);
224
- return true;
225
- } catch {
226
- return false;
227
- }
228
- }
229
-
230
- // Cookies abstractcontinuum_gateway_* and header x-abstractcontinuum-csrf
231
- // derive from appId.
232
- const gatewaySessionProxy = createGatewaySessionProxy({
233
- appId: 'abstractcontinuum',
234
- defaultGatewayUrl: DEFAULT_GATEWAY_URL,
235
- });
236
-
237
- // Team page transport: /api/hub/* forwards an allowlisted hub API subset
238
- // with the OPERATOR seat's key attached server-side (proposal c1692,
239
- // agency contract c1696 — authorship stays the operator's own).
240
- // Every hub setting is a getter: a seat saved on the Settings page or with
241
- // `abstractcontinuum config set` applies without a restart.
242
- const hubProxy = createHubProxy({
243
- hubUrl: () => settings.value('hub_url'),
244
- seat: () => settings.value('hub_seat'),
245
- keysPath: () => expand_home(settings.value('hub_keys')),
246
- token: () => settings.value('hub_token'),
247
- allowRemote: () => settings.value('hub_allow_remote'),
248
- });
249
-
250
- // The Settings page's door to this server's own settings (hub seat).
251
- const settingsRoute = createSettingsRoute({ live: settings });
252
-
253
- const server = http.createServer((req, res) => {
254
- // Parse against a FIXED base: a malformed Host header (e.g. "a b") makes
255
- // new URL throw synchronously, which would kill the whole process from
256
- // one bad request (adversarial P1 2026-07-13). The host plays no role in
257
- // routing here — only the pathname does.
258
- let pathname = '/';
259
- let search = '';
260
- try {
261
- const u = new URL(req.url, 'http://local');
262
- pathname = u.pathname;
263
- search = u.search;
264
- } catch {
265
- res.writeHead(400);
266
- res.end('Bad Request');
267
- return;
268
- }
269
-
270
- if (settingsRoute.handle(req, res, pathname)) {
271
- return;
272
- }
273
-
274
- if (hubProxy.handle(req, res, pathname, search)) {
275
- return;
276
- }
277
-
278
- if (gatewaySessionProxy.handle(req, res, pathname)) {
279
- return;
280
- }
281
-
282
- if (pathname.includes('..')) {
283
- res.writeHead(400);
284
- res.end('Bad Request');
285
- return;
286
- }
287
-
288
- if (pathname === '/' || pathname === '') {
289
- pathname = '/index.html';
290
- }
291
-
292
- const filePath = join(DIST_DIR, pathname);
293
- if (serveFile(res, filePath)) return;
294
- if (serveFile(res, filePath + '.html')) return;
295
-
296
- // SPA fallback
297
- if (serveFile(res, join(DIST_DIR, 'index.html'))) return;
298
-
299
- res.writeHead(404);
300
- res.end('Not Found');
301
- });
302
-
303
- // Live hub updates: relay browser WebSockets to the hub with the seat key
304
- // attached server-side (operator c2240 — realtime, not 5s polling).
305
- server.on('upgrade', (req, socket, head) => {
306
- // F7 (dm-99 tail): a raw upgrade socket with no 'error' listener is a
307
- // process-killer (browser aborts mid-handshake raise uncaught 'error');
308
- // and a rejected relay promise without .catch is an unhandled rejection.
309
- socket.on('error', () => {
310
- try { socket.destroy(); } catch { /* already gone */ }
311
- });
312
- void hubProxy.handleUpgrade(req, socket, head).then((handled) => {
313
- if (!handled) socket.destroy();
314
- }).catch(() => {
315
- try { socket.destroy(); } catch { /* already gone */ }
316
- });
317
- });
173
+ const server = createContinuumServer({ settings, distDir: DIST_DIR });
318
174
 
319
175
  server.listen(PORT, HOST, () => {
320
176
  const seat = AT_START.hub_seat;
321
177
  console.log(`
322
178
  AbstractContinuum is running.
323
179
  Local: http://localhost:${PORT}
324
- Gateway: ${DEFAULT_GATEWAY_URL}
180
+ Gateway: ${DEFAULT_GATEWAY_URL} (${SOURCE_WORDS[AT_START.gateway_url.source]})
325
181
  Hub seat: ${seat.value} (${SOURCE_WORDS[seat.source]})
326
182
  Settings: ${SETTINGS_PATH}
327
183
  `);
package/bin/hub_proxy.js CHANGED
@@ -20,6 +20,11 @@
20
20
  import { readFileSync } from 'fs';
21
21
  import { fileURLToPath } from 'url';
22
22
  import { dirname, join } from 'path';
23
+ import { APP_IDENTITY_HEADER, MountRequestError, identityHeaderValue, rejectUpgrade } from '@abstractframework/app-server';
24
+ import { request_gate } from './request_gate.js';
25
+
26
+ /** The gateway catalog id this server announces (`/apps/continuum/`). */
27
+ export const APP_ID = 'continuum';
23
28
 
24
29
  /** Console version for the hub's X-Agora-Client handshake (read once at
25
30
  * boot; "0.0.0" only if package.json is unreadable — the header's job is
@@ -190,30 +195,6 @@ const HUB_ROUTES = [
190
195
  { method: 'DELETE', re: /^\/channels\/[A-Za-z0-9_.:-]+\/reputation\/[A-Za-z0-9_.-]+$/ },
191
196
  ];
192
197
 
193
- /**
194
- * Cross-origin refusal (adversary P0, 2026-07-15): the loopback peer gate
195
- * defends against REMOTE peers, but the browser itself is a loopback peer —
196
- * a hostile page the operator visits can fire cross-origin fetches and
197
- * WebSocket handshakes at this proxy (WS handshakes skip CORS preflight
198
- * entirely), and the seat key rides server-side. Rule: when the browser
199
- * declares an Origin, it must match the Host the request was addressed to;
200
- * absent Origin (curl, scripts, same-origin GET navigations) passes — a
201
- * browser NEVER omits Origin on cross-origin fetch/WS, so the vector this
202
- * closes cannot dodge the check.
203
- */
204
- function origin_allowed(req) {
205
- const origin = String((req.headers && req.headers.origin) || '').trim();
206
- if (!origin) return true;
207
- let parsed;
208
- try {
209
- parsed = new URL(origin);
210
- } catch {
211
- return false;
212
- }
213
- const host = String((req.headers && req.headers.host) || '').trim().toLowerCase();
214
- return Boolean(host) && parsed.host.toLowerCase() === host;
215
- }
216
-
217
198
  function read_seat_key(keys_path, hub_url, seat, token) {
218
199
  // An explicit token (--hub-token / --hub-token-file / the hub_token
219
200
  // setting, resolved by bin/settings.js) wins over the key store.
@@ -427,13 +408,20 @@ export function createHubProxy(opts) {
427
408
  socket.on('error', () => {
428
409
  try { socket.destroy(); } catch { /* already gone */ }
429
410
  });
411
+ // Same gate as the HTTP surface (the BROWSER's address, which behind
412
+ // the gateway is the forwarded one), PLUS the origin gate against the
413
+ // host the browser addressed — WS handshakes have no CORS preflight,
414
+ // so without it any page the operator visits could open this relay
415
+ // and read/write as the operator seat (adversary P0).
416
+ let gate;
417
+ try {
418
+ gate = request_gate(req);
419
+ } catch (e) {
420
+ if (!(e instanceof MountRequestError)) throw e;
421
+ rejectUpgrade(socket, e.status, e.message);
422
+ return true;
423
+ }
430
424
  try {
431
- // Same unforgeable peer gate as the HTTP surface, PLUS the origin
432
- // gate — WS handshakes have no CORS preflight, so without it any
433
- // page the operator visits could open this relay and read/write
434
- // as the operator seat (adversary P0).
435
- const peer = String(req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '');
436
- const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1' || peer === '';
437
425
  const allow_remote = cfg().allow_remote;
438
426
  const seat_key = key();
439
427
  let WebSocketServer, WebSocket;
@@ -443,7 +431,15 @@ export function createHubProxy(opts) {
443
431
  socket.destroy();
444
432
  return true;
445
433
  }
446
- if ((!loopback && !allow_remote) || !seat_key || !origin_allowed(req)) {
434
+ if (!gate.loopback && !allow_remote) {
435
+ rejectUpgrade(socket, 403, 'The hub relay answers this computer only (it authors as the operator seat).');
436
+ return true;
437
+ }
438
+ if (!gate.same_origin) {
439
+ rejectUpgrade(socket, 403, 'Cross-origin WebSocket to the hub relay refused.');
440
+ return true;
441
+ }
442
+ if (!seat_key) {
447
443
  socket.destroy();
448
444
  return true;
449
445
  }
@@ -451,7 +447,13 @@ export function createHubProxy(opts) {
451
447
  // one side of a string-pipe relay ships RSV1 frames the other side
452
448
  // never negotiated (live find: browsers/ws clients offer it by
453
449
  // default and the pipe corrupts).
454
- if (!this._wss) this._wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
450
+ if (!this._wss) {
451
+ this._wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
452
+ // The 101 announces the app like every other response
453
+ // (X-AbstractFramework-App: continuum; mount=1).
454
+ const identity = `${APP_IDENTITY_HEADER}: ${identityHeaderValue(APP_ID)}`;
455
+ this._wss.on('headers', (headers) => headers.push(identity));
456
+ }
455
457
  const hub_ws_url = cfg().hub_url.replace(/^http/, 'ws') + '/ws';
456
458
  const upstream = new WebSocket(hub_ws_url, {
457
459
  headers: { Authorization: `Bearer ${seat_key}` },
@@ -532,24 +534,31 @@ export function createHubProxy(opts) {
532
534
  handle(req, res, pathname, search) {
533
535
  if (!pathname.startsWith('/api/hub/') && pathname !== '/api/hub/meta') return false;
534
536
 
535
- // Socket-peer gate (entity's c1768 class, applied at the mount): this
536
- // proxy authors hub messages AS THE OPERATOR, so a non-loopback peer
537
- // reaching it could forge the operator's authorship. Gate on the
538
- // connection's REAL peer (unforgeable), never a client-controlled
539
- // header. The default 127.0.0.1 bind already prevents remote peers;
540
- // this is defense-in-depth if HOST is widened deliberately.
541
- const peer = String(req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '');
542
- const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1' || peer === '';
537
+ // Client gate (entity's c1768 class, applied at the mount): this
538
+ // proxy authors hub messages AS THE OPERATOR, so a browser on another
539
+ // machine could forge the operator's authorship. The client is the
540
+ // kit's requestContext answer (bin/request_gate.js): the socket peer,
541
+ // or behind the gateway's /apps/continuum/ (a loopback peer) the
542
+ // address the gateway forwarded — never a header from anyone else.
543
+ let gate;
544
+ try {
545
+ gate = request_gate(req);
546
+ } catch (e) {
547
+ if (!(e instanceof MountRequestError)) throw e;
548
+ res.writeHead(e.status, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({ error: 'bad_request', detail: e.message }));
550
+ return true;
551
+ }
543
552
  const allow_remote = cfg().allow_remote;
544
- if (!loopback && !allow_remote) {
553
+ if (!gate.loopback && !allow_remote) {
545
554
  res.writeHead(403, { 'Content-Type': 'application/json' });
546
- res.end(JSON.stringify({ error: 'hub_proxy_non_loopback', detail: `Hub proxy refuses non-loopback peer ${peer} (it authors as the operator seat). Start Continuum with --hub-allow-remote only behind a trusted front.` }));
555
+ res.end(JSON.stringify({ error: 'hub_proxy_non_loopback', detail: `Hub proxy refuses a browser on another machine (${gate.ctx.clientAddress}): it authors as the operator seat. Start Continuum with --hub-allow-remote only behind a trusted front.` }));
547
556
  return true;
548
557
  }
549
558
  // Cross-origin browser requests are refused even from loopback: the
550
559
  // browser is a loopback peer, and a hostile page can fire simple
551
560
  // POSTs (no preflight) at this surface. Origin-vs-Host is the gate.
552
- if (!origin_allowed(req)) {
561
+ if (!gate.same_origin) {
553
562
  res.writeHead(403, { 'Content-Type': 'application/json' });
554
563
  res.end(JSON.stringify({ error: 'hub_proxy_cross_origin', detail: 'Cross-origin requests to the hub proxy are refused (the proxy authors as the operator seat).' }));
555
564
  return true;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * "Is this request from this computer, and from this app's own page?" —
3
+ * the ONE answer every app-local privileged route uses (the hub proxy,
4
+ * which authors as the operator's seat, and the Settings route, which
5
+ * steers it).
6
+ *
7
+ * Both questions go through @abstractframework/app-server's
8
+ * `requestContext(req)`: behind the gateway's `/apps/continuum/` the socket
9
+ * peer is ALWAYS loopback (the gateway), so the socket address says nothing
10
+ * about the browser. The kit believes the gateway's X-Forwarded-For /
11
+ * X-Forwarded-Host only from a loopback peer, and answers the browser's
12
+ * address and the host the browser addressed.
13
+ *
14
+ * - loopback: the BROWSER runs on this computer (`clientIsLoopback`).
15
+ * - same_origin: a declared Origin names the host the browser addressed
16
+ * (the forwarded host behind the gateway, else the Host header). An
17
+ * absent Origin passes: a browser never omits it on a cross-origin
18
+ * fetch or WebSocket handshake, so the vector this closes cannot dodge
19
+ * the check.
20
+ *
21
+ * Throws MountRequestError (status 400) for a malformed forwarded header or
22
+ * an unknown socket peer; callers refuse the request.
23
+ */
24
+
25
+ import { requestContext } from '@abstractframework/app-server';
26
+
27
+ export function request_gate(req) {
28
+ const ctx = requestContext(req);
29
+ return { ctx, loopback: ctx.clientIsLoopback, same_origin: origin_matches(req, ctx.host) };
30
+ }
31
+
32
+ function origin_matches(req, host) {
33
+ const origin = String((req.headers && req.headers.origin) || '').trim();
34
+ if (!origin) return true;
35
+ let parsed;
36
+ try {
37
+ parsed = new URL(origin);
38
+ } catch {
39
+ return false;
40
+ }
41
+ const want = String(host || '').trim().toLowerCase();
42
+ return Boolean(want) && parsed.host.toLowerCase() === want;
43
+ }
package/bin/server.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The AbstractContinuum HTTP server: the built console, the app-origin
3
+ * gateway session proxy (/api/* → the gateway), the Team page's hub proxy
4
+ * (/api/hub/*, HTTP + the /api/hub/ws relay) and the Settings route.
5
+ *
6
+ * Mountable under the gateway at /apps/continuum/ (the
7
+ * @abstractframework/app-server mount contract): every response, the
8
+ * WebSocket 101 included, announces `X-AbstractFramework-App: continuum;
9
+ * mount=1`; the page gets `<base href="<base path>/">` and `base_path`;
10
+ * cookies carry `Path=<base path>/`; and every "is this the operator's own
11
+ * computer?" check reads the kit's requestContext (bin/request_gate.js),
12
+ * never the socket peer. Served at `/` on its own port it works exactly as
13
+ * before.
14
+ */
15
+
16
+ import * as http from 'http';
17
+ import { createHash } from 'crypto';
18
+ import { readFileSync, existsSync, statSync } from 'fs';
19
+ import { join, extname } from 'path';
20
+ import {
21
+ MountRequestError,
22
+ createGatewaySessionProxy,
23
+ createGatewayUrlResolver,
24
+ createMountedHandler,
25
+ injectShell,
26
+ rejectUpgrade,
27
+ requestContext,
28
+ } from '@abstractframework/app-server';
29
+ import { APP_ID, createHubProxy } from './hub_proxy.js';
30
+ import { createSettingsRoute, expand_home } from './settings.js';
31
+
32
+ const MIME_TYPES = {
33
+ '.html': 'text/html',
34
+ '.js': 'application/javascript',
35
+ '.css': 'text/css',
36
+ '.json': 'application/json',
37
+ '.png': 'image/png',
38
+ '.jpg': 'image/jpeg',
39
+ '.jpeg': 'image/jpeg',
40
+ '.gif': 'image/gif',
41
+ '.svg': 'image/svg+xml',
42
+ '.ico': 'image/x-icon',
43
+ '.webmanifest': 'application/manifest+json',
44
+ };
45
+
46
+ function getMimeType(filePath) {
47
+ const ext = extname(filePath).toLowerCase();
48
+ return MIME_TYPES[ext] || 'application/octet-stream';
49
+ }
50
+
51
+ // Content-Security-Policy for the app document (security adversary P1
52
+ // 2026-07-16): untrusted markdown (channel files, messages, attachments)
53
+ // renders in this app, and standard markdown allows remote images — a
54
+ // crafted ![](https://attacker/beacon.png) would beacon the operator's IP
55
+ // and read-timing to an external host the moment a preview opens. img-src
56
+ // 'self' data: kills that class app-wide. script-src 'self' is a backstop
57
+ // belt (the renderer already cannot emit script). ws:/wss: are needed
58
+ // because 'self' does not reliably cover scheme-different WebSocket dials;
59
+ // only our own bundle can open sockets (script-src), so this stays tight.
60
+ const CSP = [
61
+ "default-src 'self'",
62
+ "script-src 'self'",
63
+ "style-src 'self' 'unsafe-inline'", // React style={} attributes
64
+ "img-src 'self' data:",
65
+ "font-src 'self' data:",
66
+ "connect-src 'self' ws: wss:",
67
+ "object-src 'none'",
68
+ "base-uri 'self'",
69
+ "form-action 'self'",
70
+ "frame-ancestors 'none'",
71
+ ].join('; ');
72
+
73
+ /**
74
+ * settings: live settings (bin/settings.js createLiveSettings); distDir: the
75
+ * built console. The gateway URL, like the port and host, is read once from
76
+ * the settings at creation.
77
+ */
78
+ export function createContinuumServer({ settings, distDir }) {
79
+ const AT_START = settings.get();
80
+ const DEFAULT_GATEWAY_URL = AT_START.gateway_url.value;
81
+ const DIST_DIR = distDir;
82
+ // A gateway URL nobody chose (the local gateway pointer, or the built-in
83
+ // default) follows the pointer while the server runs: the kit's resolver
84
+ // re-reads it when the gateway refuses a connection. A flag, setting or
85
+ // environment choice never moves.
86
+ const CHOSEN_GATEWAY = ['flag', 'setting', 'env'].includes(AT_START.gateway_url.source);
87
+
88
+ /**
89
+ * The console document for one request: the kit's injectShell puts
90
+ * `<base href="<base path>/">` first in <head> (every relative asset and
91
+ * fetch resolves under /apps/continuum/ behind the gateway, under / on the
92
+ * app's own port) plus `window.__ABSTRACT_UI_CONFIG__` (gateway_url,
93
+ * base_path) in one inline script. The CSP admits exactly that script by
94
+ * its hash; every other inline script stays refused.
95
+ */
96
+ function shellResponse(html, ctx) {
97
+ const page = injectShell(html, { basePath: ctx.basePath, config: { gateway_url: gatewaySessionProxy.defaultGatewayUrl } });
98
+ const inline = /<base [^>]*><script>([\s\S]*?)<\/script>/.exec(page);
99
+ if (!inline) throw new Error('injectShell produced no inline config script');
100
+ const hash = createHash('sha256').update(inline[1], 'utf8').digest('base64');
101
+ const csp = CSP.replace("script-src 'self'", `script-src 'self' 'sha256-${hash}'`);
102
+ return { page, csp };
103
+ }
104
+
105
+ function serveFile(res, filePath, ctx) {
106
+ try {
107
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
108
+ return false;
109
+ }
110
+ const mime = getMimeType(filePath);
111
+ const headers = {
112
+ 'Content-Type': mime,
113
+ 'Cache-Control': 'no-cache',
114
+ 'X-Content-Type-Options': 'nosniff',
115
+ };
116
+ if (mime === 'text/html') {
117
+ // CSP is a document policy — attach it where a document loads.
118
+ const { page, csp } = shellResponse(readFileSync(filePath, 'utf8'), ctx);
119
+ headers['Content-Type'] = 'text/html; charset=utf-8';
120
+ headers['Content-Security-Policy'] = csp;
121
+ res.writeHead(200, headers);
122
+ res.end(page);
123
+ return true;
124
+ }
125
+ res.writeHead(200, headers);
126
+ res.end(readFileSync(filePath));
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // Cookies abstractcontinuum_gateway_* and header x-abstractcontinuum-csrf
134
+ // derive from appId; mounted, the cookies carry Path=/apps/continuum/.
135
+ const gatewaySessionProxy = createGatewaySessionProxy({
136
+ appId: 'abstractcontinuum',
137
+ defaultGatewayUrl: CHOSEN_GATEWAY ? DEFAULT_GATEWAY_URL : createGatewayUrlResolver({}),
138
+ });
139
+
140
+ // Team page transport: /api/hub/* forwards an allowlisted hub API subset
141
+ // with the OPERATOR seat's key attached server-side (proposal c1692,
142
+ // agency contract c1696 — authorship stays the operator's own).
143
+ // Every hub setting is a getter: a seat saved on the Settings page or with
144
+ // `abstractcontinuum config set` applies without a restart.
145
+ const hubProxy = createHubProxy({
146
+ hubUrl: () => settings.value('hub_url'),
147
+ seat: () => settings.value('hub_seat'),
148
+ keysPath: () => expand_home(settings.value('hub_keys')),
149
+ token: () => settings.value('hub_token'),
150
+ allowRemote: () => settings.value('hub_allow_remote'),
151
+ });
152
+
153
+ // The Settings page's door to this server's own settings (hub seat).
154
+ const settingsRoute = createSettingsRoute({ live: settings });
155
+
156
+ const server = http.createServer(createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
157
+ // Parse against a FIXED base: a malformed Host header (e.g. "a b") makes
158
+ // new URL throw synchronously, which would kill the whole process from
159
+ // one bad request (adversarial P1 2026-07-13). The host plays no role in
160
+ // routing here — only the pathname does.
161
+ let pathname = '/';
162
+ let search = '';
163
+ try {
164
+ const u = new URL(req.url, 'http://local');
165
+ pathname = u.pathname;
166
+ search = u.search;
167
+ } catch {
168
+ res.writeHead(400);
169
+ res.end('Bad Request');
170
+ return;
171
+ }
172
+
173
+ if (settingsRoute.handle(req, res, pathname)) {
174
+ return;
175
+ }
176
+
177
+ if (hubProxy.handle(req, res, pathname, search)) {
178
+ return;
179
+ }
180
+
181
+ if (gatewaySessionProxy.handle(req, res, pathname)) {
182
+ return;
183
+ }
184
+
185
+ if (pathname.includes('..')) {
186
+ res.writeHead(400);
187
+ res.end('Bad Request');
188
+ return;
189
+ }
190
+
191
+ if (pathname === '/' || pathname === '') {
192
+ pathname = '/index.html';
193
+ }
194
+
195
+ const filePath = join(DIST_DIR, pathname);
196
+ if (serveFile(res, filePath, ctx)) return;
197
+ if (serveFile(res, filePath + '.html', ctx)) return;
198
+
199
+ // SPA fallback
200
+ if (serveFile(res, join(DIST_DIR, 'index.html'), ctx)) return;
201
+
202
+ res.writeHead(404);
203
+ res.end('Not Found');
204
+ }));
205
+
206
+ // Live hub updates: relay browser WebSockets to the hub with the seat key
207
+ // attached server-side (operator c2240 — realtime, not 5s polling).
208
+ server.on('upgrade', (req, socket, head) => {
209
+ // F7 (dm-99 tail): a raw upgrade socket with no 'error' listener is a
210
+ // process-killer (browser aborts mid-handshake raise uncaught 'error');
211
+ // and a rejected relay promise without .catch is an unhandled rejection.
212
+ socket.on('error', () => {
213
+ try { socket.destroy(); } catch { /* already gone */ }
214
+ });
215
+ // A malformed forwarded header or an unknown peer: refused before any
216
+ // route (the kit's rule; the hub relay applies it again for its gate).
217
+ try {
218
+ requestContext(req);
219
+ } catch (e) {
220
+ if (!(e instanceof MountRequestError)) throw e;
221
+ rejectUpgrade(socket, e.status, e.message);
222
+ return;
223
+ }
224
+ void hubProxy.handleUpgrade(req, socket, head).then((handled) => {
225
+ if (!handled) rejectUpgrade(socket, 404, 'No WebSocket here.');
226
+ }).catch(() => {
227
+ try { socket.destroy(); } catch { /* already gone */ }
228
+ });
229
+ });
230
+
231
+ return server;
232
+ }