@abstractframework/continuum 0.3.2 → 0.5.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 +41 -7
  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-E9lbmtBV.js} +2 -2
  8. package/dist/assets/{arc-Co6CTENJ.js → arc-XVGGKtJ-.js} +2 -2
  9. package/dist/assets/{architectureDiagram-ZJ3FMSHR-CEz8QvCN.js → architectureDiagram-ZJ3FMSHR-GRV2Ej0c.js} +2 -2
  10. package/dist/assets/{blockDiagram-677ZJIJ3-B_5v6oR-.js → blockDiagram-677ZJIJ3-Dz8wPMHC.js} +2 -2
  11. package/dist/assets/{c4Diagram-LMCZKHZV-B2dYGCte.js → c4Diagram-LMCZKHZV-CAHne5Am.js} +2 -2
  12. package/dist/assets/channel-BbrZydfX.js +2 -0
  13. package/dist/assets/{chunk-2Q5K7J3B-2eGZLgzu.js → chunk-2Q5K7J3B-DFXMgoy3.js} +2 -2
  14. package/dist/assets/{chunk-32BRIVSS-szXiGdYg.js → chunk-32BRIVSS-D9T7Whin.js} +2 -2
  15. package/dist/assets/{chunk-5VM5RSS4-F7rd4WOC.js → chunk-5VM5RSS4-D8jr-VV1.js} +2 -2
  16. package/dist/assets/{chunk-EX3LRPZG-BBaSONus.js → chunk-EX3LRPZG-BSkCDWzU.js} +2 -2
  17. package/dist/assets/{chunk-JWPE2WC7-BzQLSiNr.js → chunk-JWPE2WC7-iYmSeuV1.js} +2 -2
  18. package/dist/assets/{chunk-MOJQB5TN-CV4r6R-P.js → chunk-MOJQB5TN-BxgBSbz5.js} +2 -2
  19. package/dist/assets/{chunk-RYQCIY6F-lSwdPTG2.js → chunk-RYQCIY6F-D5JkyQAc.js} +2 -2
  20. package/dist/assets/{chunk-V7JOEXUC-BVe47Wh6.js → chunk-V7JOEXUC-B_6OmmFi.js} +2 -2
  21. package/dist/assets/{chunk-VR4S4FIN-Xsn7KCEd.js → chunk-VR4S4FIN-BsoDEIUS.js} +2 -2
  22. package/dist/assets/{chunk-XXDRQBXY-DdG9ww2-.js → chunk-XXDRQBXY-DG-goPoO.js} +2 -2
  23. package/dist/assets/classDiagram-OUVF2IWQ-BvV49U6G.js +2 -0
  24. package/dist/assets/classDiagram-v2-EOCWNBFH-BvV49U6G.js +2 -0
  25. package/dist/assets/{cose-bilkent-JH36ORCC-DDdy2AQ-.js → cose-bilkent-JH36ORCC-C95YoC9P.js} +2 -2
  26. package/dist/assets/{cynefin-VYW2F7L2-PkaGuTvX.js → cynefin-VYW2F7L2-A7Vh09t2.js} +3 -3
  27. package/dist/assets/{cynefinDiagram-TSTJHNR4-BHIvz7Or.js → cynefinDiagram-TSTJHNR4-BI8-04G-.js} +2 -2
  28. package/dist/assets/{dagre-VKFMJZFB-Dt336PVs.js → dagre-VKFMJZFB-owAzFOdh.js} +2 -2
  29. package/dist/assets/{diagram-FQU43EPY-DrmE36bq.js → diagram-FQU43EPY-B8FPxd72.js} +2 -2
  30. package/dist/assets/{diagram-G47NLZAW-tpUlukyV.js → diagram-G47NLZAW-CwooMJF9.js} +2 -2
  31. package/dist/assets/{diagram-NH7WQ7WH-CO5Zb4_d.js → diagram-NH7WQ7WH-Cr-bMH-G.js} +2 -2
  32. package/dist/assets/{diagram-OA4YK3LP-B0FwAFRE.js → diagram-OA4YK3LP-3rHTMgXR.js} +2 -2
  33. package/dist/assets/{diagram-WEI45ONY-CcLxvhZ0.js → diagram-WEI45ONY-BG4PjrBn.js} +2 -2
  34. package/dist/assets/{ebnfDiagram-CCIWWBDH-CdGlfEot.js → ebnfDiagram-CCIWWBDH-B6uukG4X.js} +2 -2
  35. package/dist/assets/{erDiagram-Q63AITRT-CxYfUHK8.js → erDiagram-Q63AITRT-D3OgqYHT.js} +2 -2
  36. package/dist/assets/{flowDiagram-23GEKE2U-fW4F9z69.js → flowDiagram-23GEKE2U-CxTaDERr.js} +2 -2
  37. package/dist/assets/{ganttDiagram-NO4QXBWP-BWM-HcND.js → ganttDiagram-NO4QXBWP-CN7iAeQh.js} +2 -2
  38. package/dist/assets/{gitGraphDiagram-IHSO6WYX-BlcnohGg.js → gitGraphDiagram-IHSO6WYX-DbvcWmmM.js} +2 -2
  39. package/dist/assets/index-Ca1Qebcu.js +121 -0
  40. package/dist/assets/index-fh60G2KG.css +1 -0
  41. package/dist/assets/{infoDiagram-FWYZ7A6U-pJSOWVdi.js → infoDiagram-FWYZ7A6U-DX4NPNgR.js} +2 -2
  42. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CpvrrPMQ.js → ishikawaDiagram-FXEZZL3T-DtL3v287.js} +2 -2
  43. package/dist/assets/{journeyDiagram-5HDEW3XC-DzFKBKHk.js → journeyDiagram-5HDEW3XC-CtAZFiGl.js} +2 -2
  44. package/dist/assets/{kanban-definition-HUTT4EX6-BPmBQCPR.js → kanban-definition-HUTT4EX6-CvrCkrKt.js} +2 -2
  45. package/dist/assets/{linear-DmNa6TPI.js → linear-CbLBdTPp.js} +2 -2
  46. package/dist/assets/{mermaid.core-_-YW0o95.js → mermaid.core-zt0bcQ8D.js} +6 -6
  47. package/dist/assets/{mindmap-definition-LN4V7U3C-Bdwo0rlk.js → mindmap-definition-LN4V7U3C-DU-r_mE6.js} +2 -2
  48. package/dist/assets/{pegDiagram-2B236MQR-CHZoedfQ.js → pegDiagram-2B236MQR-CoiPuGzc.js} +2 -2
  49. package/dist/assets/{pieDiagram-ENE6RG2P-Blp676Hr.js → pieDiagram-ENE6RG2P-ClUZORQg.js} +2 -2
  50. package/dist/assets/{quadrantDiagram-ABIIQ3AL-DhWHDdBX.js → quadrantDiagram-ABIIQ3AL-D0i0p2xo.js} +2 -2
  51. package/dist/assets/{railroadDiagram-RFXS5EU6-CEgbBDEg.js → railroadDiagram-RFXS5EU6-wI6Hdpa5.js} +2 -2
  52. package/dist/assets/{requirementDiagram-TGXJPOKE-Ds8x8FbV.js → requirementDiagram-TGXJPOKE-Bm4qvEra.js} +2 -2
  53. package/dist/assets/{sankeyDiagram-HTMAVEWB-yKIsBDhP.js → sankeyDiagram-HTMAVEWB-DX8is9uB.js} +2 -2
  54. package/dist/assets/{sequenceDiagram-DBY2YBRQ-DAgYlOsI.js → sequenceDiagram-DBY2YBRQ-5RpIQiJh.js} +2 -2
  55. package/dist/assets/{sizeCapture-X5ZJPWSS-C-LAlxdw.js → sizeCapture-X5ZJPWSS-ChX6cFSk.js} +2 -2
  56. package/dist/assets/{stateDiagram-2N3HPSRC-BLiS6piZ.js → stateDiagram-2N3HPSRC-Tea20pcW.js} +2 -2
  57. package/dist/assets/stateDiagram-v2-6OUMAXLB-CCBRDLbX.js +2 -0
  58. package/dist/assets/swimlanes-5IMT3BWC-BY7gSBH7.js +3 -0
  59. package/dist/assets/swimlanesDiagram-G3AALYLV-Dtz4rZCK.js +9 -0
  60. package/dist/assets/{timeline-definition-FHXFAJF6-BrJ5JJYj.js → timeline-definition-FHXFAJF6-Bq52M8-r.js} +2 -2
  61. package/dist/assets/{vennDiagram-L72KCM5P-BT9M9-9s.js → vennDiagram-L72KCM5P-BGBcMmhw.js} +2 -2
  62. package/dist/assets/{wardleyDiagram-EHGQE667-CukiKHz8.js → wardleyDiagram-EHGQE667-T3JqsmMn.js} +2 -2
  63. package/dist/assets/{xychartDiagram-FW5EYKEG-CIidttLL.js → xychartDiagram-FW5EYKEG-B0yTh8-d.js} +2 -2
  64. package/dist/index.html +7 -3
  65. package/package.json +5 -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
 
@@ -98,15 +103,43 @@ To point the Board at a project, or to enable executions, see
98
103
  Before exposing the console beyond localhost, read
99
104
  [docs/security.md](docs/security.md): a signed-in user can redeploy services.
100
105
 
106
+ ## Responsive layout
107
+
108
+ Continuum works in a desktop window of any size, on a tablet and on a phone,
109
+ in portrait and landscape. The layout follows the window as you resize it.
110
+
111
+ | Window width | Layout |
112
+ | --- | --- |
113
+ | 1440 px and wider | Sidebar docked; the Team page shows channels, thread and an open panel side by side; board lanes use the full width |
114
+ | 1024 to 1439 px | Sidebar docked; on the Team page an open panel (Members, Files, …) sits beside the thread and the channel list steps aside |
115
+ | 768 to 1023 px (tablets, narrow windows) | The sidebar becomes a menu: open it with the button at the left of the header |
116
+ | Below 768 px (phones) | One pane at a time: pick a channel, message or report to open it, and use **← Channels** or **← Back to the list** to return |
117
+
118
+ On phones and in short landscape windows:
119
+
120
+ - **Team**: the thread keeps the screen, with one bar holding **← Channels**,
121
+ the channel name and **More**. **More** shows the filters, search, sort,
122
+ channel actions and the Assistant / Members / Files / Leaderboard / Desk
123
+ panels. The message box takes the width and Send is an icon button.
124
+ - **Backlog**: each item is a card (title first, actions underneath).
125
+ - **Executions and Inbox**: the list, then the selected item.
126
+ - **Dialogs** (New task, Execute, file viewer) open as sheets from the bottom
127
+ of the screen with their buttons always visible, above the on-screen
128
+ keyboard.
129
+
130
+ On touch screens, buttons, rows and tabs are at least 44 px tall, form fields
131
+ use 16 px text, and messages, reports and specs use the 14 px reading size.
132
+ Escape closes the top dialog or drawer; it never closes the work-item drawer
133
+ while you are typing or editing the spec.
134
+
101
135
  ## Develop from source
102
136
 
103
- The source build resolves the shared UI kit (`@abstractframework/ui-kit`,
137
+ The shared UI kit (`@abstractframework/ui-kit`,
104
138
  `@abstractframework/panel-chat`) and the session proxy
105
- (`@abstractframework/app-server`) from a sibling
106
- [AbstractUIC](https://github.com/lpalbou/AbstractUIC) checkout:
139
+ (`@abstractframework/app-server`) are regular npm dependencies, so a plain
140
+ clone builds:
107
141
 
108
142
  ```bash
109
- git clone https://github.com/lpalbou/AbstractUIC.git abstractuic
110
143
  git clone https://github.com/lpalbou/AbstractContinuum.git abstractcontinuum
111
144
  cd abstractcontinuum
112
145
  npm install
@@ -128,6 +161,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
128
161
  - [docs/conventions.md](docs/conventions.md) — the work-item grammar the Board reads
129
162
  - [docs/security.md](docs/security.md) — trust model (read before deploying)
130
163
  - [docs/faq.md](docs/faq.md) / [docs/troubleshooting.md](docs/troubleshooting.md)
164
+ - [Responsive layout](#responsive-layout) — tablets, phones and window sizes
131
165
  - [CHANGELOG.md](CHANGELOG.md) — release history
132
166
  - [CONTRIBUTING.md](CONTRIBUTING.md) — development workflow
133
167
 
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
+ }