@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.
- package/README.md +41 -7
- package/bin/cli.js +16 -160
- package/bin/hub_proxy.js +52 -43
- package/bin/request_gate.js +43 -0
- package/bin/server.js +232 -0
- package/bin/settings.js +45 -28
- package/dist/assets/{abnfDiagram-VRR7QNED-C3nZSPxd.js → abnfDiagram-VRR7QNED-E9lbmtBV.js} +2 -2
- package/dist/assets/{arc-Co6CTENJ.js → arc-XVGGKtJ-.js} +2 -2
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-CEz8QvCN.js → architectureDiagram-ZJ3FMSHR-GRV2Ej0c.js} +2 -2
- package/dist/assets/{blockDiagram-677ZJIJ3-B_5v6oR-.js → blockDiagram-677ZJIJ3-Dz8wPMHC.js} +2 -2
- package/dist/assets/{c4Diagram-LMCZKHZV-B2dYGCte.js → c4Diagram-LMCZKHZV-CAHne5Am.js} +2 -2
- package/dist/assets/channel-BbrZydfX.js +2 -0
- package/dist/assets/{chunk-2Q5K7J3B-2eGZLgzu.js → chunk-2Q5K7J3B-DFXMgoy3.js} +2 -2
- package/dist/assets/{chunk-32BRIVSS-szXiGdYg.js → chunk-32BRIVSS-D9T7Whin.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-F7rd4WOC.js → chunk-5VM5RSS4-D8jr-VV1.js} +2 -2
- package/dist/assets/{chunk-EX3LRPZG-BBaSONus.js → chunk-EX3LRPZG-BSkCDWzU.js} +2 -2
- package/dist/assets/{chunk-JWPE2WC7-BzQLSiNr.js → chunk-JWPE2WC7-iYmSeuV1.js} +2 -2
- package/dist/assets/{chunk-MOJQB5TN-CV4r6R-P.js → chunk-MOJQB5TN-BxgBSbz5.js} +2 -2
- package/dist/assets/{chunk-RYQCIY6F-lSwdPTG2.js → chunk-RYQCIY6F-D5JkyQAc.js} +2 -2
- package/dist/assets/{chunk-V7JOEXUC-BVe47Wh6.js → chunk-V7JOEXUC-B_6OmmFi.js} +2 -2
- package/dist/assets/{chunk-VR4S4FIN-Xsn7KCEd.js → chunk-VR4S4FIN-BsoDEIUS.js} +2 -2
- package/dist/assets/{chunk-XXDRQBXY-DdG9ww2-.js → chunk-XXDRQBXY-DG-goPoO.js} +2 -2
- package/dist/assets/classDiagram-OUVF2IWQ-BvV49U6G.js +2 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BvV49U6G.js +2 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-DDdy2AQ-.js → cose-bilkent-JH36ORCC-C95YoC9P.js} +2 -2
- package/dist/assets/{cynefin-VYW2F7L2-PkaGuTvX.js → cynefin-VYW2F7L2-A7Vh09t2.js} +3 -3
- package/dist/assets/{cynefinDiagram-TSTJHNR4-BHIvz7Or.js → cynefinDiagram-TSTJHNR4-BI8-04G-.js} +2 -2
- package/dist/assets/{dagre-VKFMJZFB-Dt336PVs.js → dagre-VKFMJZFB-owAzFOdh.js} +2 -2
- package/dist/assets/{diagram-FQU43EPY-DrmE36bq.js → diagram-FQU43EPY-B8FPxd72.js} +2 -2
- package/dist/assets/{diagram-G47NLZAW-tpUlukyV.js → diagram-G47NLZAW-CwooMJF9.js} +2 -2
- package/dist/assets/{diagram-NH7WQ7WH-CO5Zb4_d.js → diagram-NH7WQ7WH-Cr-bMH-G.js} +2 -2
- package/dist/assets/{diagram-OA4YK3LP-B0FwAFRE.js → diagram-OA4YK3LP-3rHTMgXR.js} +2 -2
- package/dist/assets/{diagram-WEI45ONY-CcLxvhZ0.js → diagram-WEI45ONY-BG4PjrBn.js} +2 -2
- package/dist/assets/{ebnfDiagram-CCIWWBDH-CdGlfEot.js → ebnfDiagram-CCIWWBDH-B6uukG4X.js} +2 -2
- package/dist/assets/{erDiagram-Q63AITRT-CxYfUHK8.js → erDiagram-Q63AITRT-D3OgqYHT.js} +2 -2
- package/dist/assets/{flowDiagram-23GEKE2U-fW4F9z69.js → flowDiagram-23GEKE2U-CxTaDERr.js} +2 -2
- package/dist/assets/{ganttDiagram-NO4QXBWP-BWM-HcND.js → ganttDiagram-NO4QXBWP-CN7iAeQh.js} +2 -2
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BlcnohGg.js → gitGraphDiagram-IHSO6WYX-DbvcWmmM.js} +2 -2
- package/dist/assets/index-Ca1Qebcu.js +121 -0
- package/dist/assets/index-fh60G2KG.css +1 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-pJSOWVdi.js → infoDiagram-FWYZ7A6U-DX4NPNgR.js} +2 -2
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CpvrrPMQ.js → ishikawaDiagram-FXEZZL3T-DtL3v287.js} +2 -2
- package/dist/assets/{journeyDiagram-5HDEW3XC-DzFKBKHk.js → journeyDiagram-5HDEW3XC-CtAZFiGl.js} +2 -2
- package/dist/assets/{kanban-definition-HUTT4EX6-BPmBQCPR.js → kanban-definition-HUTT4EX6-CvrCkrKt.js} +2 -2
- package/dist/assets/{linear-DmNa6TPI.js → linear-CbLBdTPp.js} +2 -2
- package/dist/assets/{mermaid.core-_-YW0o95.js → mermaid.core-zt0bcQ8D.js} +6 -6
- package/dist/assets/{mindmap-definition-LN4V7U3C-Bdwo0rlk.js → mindmap-definition-LN4V7U3C-DU-r_mE6.js} +2 -2
- package/dist/assets/{pegDiagram-2B236MQR-CHZoedfQ.js → pegDiagram-2B236MQR-CoiPuGzc.js} +2 -2
- package/dist/assets/{pieDiagram-ENE6RG2P-Blp676Hr.js → pieDiagram-ENE6RG2P-ClUZORQg.js} +2 -2
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-DhWHDdBX.js → quadrantDiagram-ABIIQ3AL-D0i0p2xo.js} +2 -2
- package/dist/assets/{railroadDiagram-RFXS5EU6-CEgbBDEg.js → railroadDiagram-RFXS5EU6-wI6Hdpa5.js} +2 -2
- package/dist/assets/{requirementDiagram-TGXJPOKE-Ds8x8FbV.js → requirementDiagram-TGXJPOKE-Bm4qvEra.js} +2 -2
- package/dist/assets/{sankeyDiagram-HTMAVEWB-yKIsBDhP.js → sankeyDiagram-HTMAVEWB-DX8is9uB.js} +2 -2
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-DAgYlOsI.js → sequenceDiagram-DBY2YBRQ-5RpIQiJh.js} +2 -2
- package/dist/assets/{sizeCapture-X5ZJPWSS-C-LAlxdw.js → sizeCapture-X5ZJPWSS-ChX6cFSk.js} +2 -2
- package/dist/assets/{stateDiagram-2N3HPSRC-BLiS6piZ.js → stateDiagram-2N3HPSRC-Tea20pcW.js} +2 -2
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CCBRDLbX.js +2 -0
- package/dist/assets/swimlanes-5IMT3BWC-BY7gSBH7.js +3 -0
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dtz4rZCK.js +9 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-BrJ5JJYj.js → timeline-definition-FHXFAJF6-Bq52M8-r.js} +2 -2
- package/dist/assets/{vennDiagram-L72KCM5P-BT9M9-9s.js → vennDiagram-L72KCM5P-BGBcMmhw.js} +2 -2
- package/dist/assets/{wardleyDiagram-EHGQE667-CukiKHz8.js → wardleyDiagram-EHGQE667-T3JqsmMn.js} +2 -2
- package/dist/assets/{xychartDiagram-FW5EYKEG-CIidttLL.js → xychartDiagram-FW5EYKEG-B0yTh8-d.js} +2 -2
- package/dist/index.html +7 -3
- package/package.json +5 -3
- package/dist/assets/channel-D3yEdLfb.js +0 -2
- package/dist/assets/classDiagram-OUVF2IWQ-DJJxVI5p.js +0 -2
- package/dist/assets/classDiagram-v2-EOCWNBFH-DJJxVI5p.js +0 -2
- package/dist/assets/index-CFGJJTR4.js +0 -123
- package/dist/assets/index-DAHZ6K6R.css +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CdmuVCk9.js +0 -2
- package/dist/assets/swimlanes-5IMT3BWC-Bj_dHHPI.js +0 -3
- 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).
|
|
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
|
|
89
|
-
|
|
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
|
|
137
|
+
The shared UI kit (`@abstractframework/ui-kit`,
|
|
104
138
|
`@abstractframework/panel-chat`) and the session proxy
|
|
105
|
-
(`@abstractframework/app-server`)
|
|
106
|
-
|
|
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
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
|
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  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 (
|
|
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)
|
|
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
|
-
//
|
|
536
|
-
// proxy authors hub messages AS THE OPERATOR, so a
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
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 (!
|
|
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
|
+
}
|