@dreamteamer/web 0.1.0 → 0.3.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/src/server.js CHANGED
@@ -1,23 +1,49 @@
1
- // dt web's two loopback listeners. The APP origin serves the UI, the protocol at /api/ and the live channel;
2
- // the FILES origin serves the record files a record points at, sandboxed, with no cookie and no API, under a
3
- // per-start key (README § security). Every rule here is written in modules/web/README.md.
1
+ // dt web's ONE loopback listener (cr-modules-017 §1). A strict prefix dispatcher (dispatch.js) names what a path is
2
+ // before anything else runs: the shell (/app/, no cookie, no data), sign-in (/sign-in/<code>), the protocol (/api/,
3
+ // the cookie with Fetch-Metadata), the server's own routes (/_dt/), record files (/_files/<key>/, sandboxed) and module
4
+ // frames (/_frames/<key>/, sandbox allow-scripts). Every rule here is written in modules/web/README.md.
4
5
  import crypto from 'node:crypto';
5
6
  import fs from 'node:fs';
6
7
  import http from 'node:http';
7
8
  import path from 'node:path';
8
9
  import { createHandler } from '@dreamteamer/protocol/handler';
10
+ import { validateSelection } from '@dreamteamer/protocol/pane';
9
11
  import { engine } from '@dreamteamer/protocol/host/engine';
10
12
  import { envRoots } from '@dreamteamer/protocol/host/folder-images';
11
- import { changeScope, invalidateMessage } from '@dreamteamer/protocol/host/changes';
13
+ import { changeScope, invalidateMessage, NAV_DOC_FILE } from '@dreamteamer/protocol/host/changes';
14
+ import { hasNavSettings } from '@dreamteamer/protocol/host/nav';
15
+ import { migrateNavSettings } from '@dreamteamer/protocol/nav-doc';
16
+ import { workspaceNavSettings } from '@dreamteamer/protocol/nav-model';
17
+ import { gate } from '@dreamteamer/protocol/gate';
18
+ import { internalCollections, LEVELS, levelRank, lowerLevel, paneScope } from '@dreamteamer/protocol/pane-scope';
19
+ import { matchRoute } from '@dreamteamer/protocol/routes';
12
20
  import { appCsp, baseHeaders, FILE_CSP } from './headers.js';
13
- import { readModuleGrantResult } from '@dreamteamer/protocol/module-grant';
21
+ import { classify } from './dispatch.js';
14
22
  import { moduleFrameRoute, resolveGuest } from './module-frames.js';
23
+ import { createPairing } from './pairing.js';
24
+ import { createSessions } from './sessions.js';
25
+ import { readWorkspaceState, stateDir as defaultStateDir, workspaceKey, writeWorkspaceState } from './state.js';
26
+ import { matchClientToken, mintClientToken } from './tokens.js';
27
+ import { createPaneStore, workspaceName } from './pane-store.js';
28
+ import { CHALLENGE, IDENTITY_PATH, identityProof } from './runtime.js';
29
+ import { handoff, listWorkspaces } from './handoff.js';
15
30
 
16
- const SIGN_IN_TTL_MS = 10 * 60_000;
17
31
  const DEBOUNCE_MS = 150;
32
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
33
+ export const NAV_MIGRATED_NOTICE = 'nav.yaml written from your nav settings — they are no longer read';
34
+ export const SIGN_IN_REFUSED = 'This sign-in link was used or is older than a minute — ask the chat to show the pane again (dt web open).';
35
+ export const NOT_FROM_THE_PANE = "a request with the pane's cookie must come from the pane";
36
+ export const CLIENT_NOT_PANE = "a client token cannot set the pane's selection or queue — they are the page's";
37
+ export const PAGE_CANNOT_RAISE = (level) => `the page cannot raise the level — ask in the chat (dt web open --${level})`;
38
+ const SIGN_IN_FIRST = 'sign in: ask the chat to show the pane (dt web open)';
39
+ export const HOST_CAPABILITIES = { nav: 'app', commands: false, openFile: 'tab', tabs: false, history: 'hash', moduleFrames: 'url' };
18
40
  const random = (n = 24) => crypto.randomBytes(n).toString('base64url');
19
41
  const same = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
20
- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8' };
42
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json' };
43
+ /** A record file's bytes are data, never code or style the browser would apply. */
44
+ const NEVER_ACTIVE = new Set(['.js', '.mjs', '.cjs', '.css']);
45
+ const WRITES = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
46
+ const JSON_TYPE = /^application\/json\s*(;|$)/i;
21
47
 
22
48
  /** `file` under `root` after resolving every link, or null — `..`, absolute paths and links out are refused. */
23
49
  export function contained(root, rel) {
@@ -28,24 +54,71 @@ export function contained(root, rel) {
28
54
  }
29
55
 
30
56
  const hostNames = (port) => new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
57
+ /** The methods each prefix answers, for the Allow header of a 405 to OPTIONS. */
58
+ const ALLOW = { root: 'GET, HEAD', shell: 'GET, HEAD', 'sign-in': 'GET', api: 'GET, HEAD, POST, PUT, PATCH, DELETE', dt: 'GET, POST', files: 'GET, HEAD', frames: 'GET, HEAD' };
59
+ class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
60
+ const decode = (s) => { try { return decodeURIComponent(s); } catch { throw new HttpError(400, 'a malformed percent-escape in the path'); } };
31
61
 
32
62
  /**
33
- * Start dt web for the workspace at `root`. `appDir` holds the built app (index.html + assets/) — @dreamteamer/ui's dist/app.
34
- * Resolves `{ appUrl, filesOrigin, token, signInUrl(), close(), closed }`.
63
+ * Start dt web for the workspace at `root` on one listener. `appDir` holds the built app (index.html + assets/) —
64
+ * @dreamteamer/ui's dist/app. `stateDir` is the per-user state dir (state.js); `pane` the PaneStore the protocol's
65
+ * pane routes answer from (absent: the handler's own memory store).
66
+ * Resolves `{ url, port, controlToken, instance, filesKey, pair({ route?, cap? }) → url, broadcast(msg), clients() → number, close(why?), closed, movedFrom? }` —
67
+ * `instance` the per-start id the runtime file keeps and GET /_dt/identity proves, `clients()` the pages connected with
68
+ * the cookie (a client token's stream is not one), `movedFrom` the stored port it could not take.
35
69
  */
36
- export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, ownerPid, onStop, signInTtlMs = SIGN_IN_TTL_MS, moduleGuest } = {}) {
70
+ export async function startWeb({ root, appDir, port = 0, idleMinutes = 60, ownerPid, onStop, now = Date.now, stateDir = defaultStateDir(), moduleGuest, pane } = {}) {
37
71
  if (!fs.existsSync(path.join(appDir, 'index.html'))) {
38
72
  throw new Error(`the app is missing: ${path.join(appDir, 'index.html')} — @dreamteamer/ui is installed without its build (see the README)`);
39
73
  }
40
- const handler = await createHandler({ root });
41
- const token = random(32);
74
+ const state = readWorkspaceState(root, stateDir);
75
+ const save = () => writeWorkspaceState(root, state, stateDir);
76
+ /** The client tokens as `next`: the state file first, then memory — a write that throws changes neither. */
77
+ const saveTokens = (next) => {
78
+ writeWorkspaceState(root, { ...state, clientTokens: next }, stateDir);
79
+ state.clientTokens = next;
80
+ };
81
+ const sessions = createSessions({ now });
82
+ const pairing = createPairing({ now });
83
+ const controlToken = random(32);
84
+ const instance = random(32);
42
85
  const filesKey = random(18);
43
- const signIns = new Map(); // code → expiry
44
- const sessions = new Set();
45
- const clients = new Set();
46
- let appPort, filesPort;
86
+ let handler = null;
87
+ const store = pane ?? createPaneStore({
88
+ root, state, save, filesKey, now,
89
+ workspace: { id: workspaceKey(root), name: workspaceName(root) },
90
+ fileBases: () => fileBases(),
91
+ varRoots: () => varRoots(),
92
+ onChange: (type) => broadcast({ type }),
93
+ });
94
+ handler = await createHandler({ root, pane: store });
95
+ // cr-dt-008: the old nav settings are written into nav.yaml on the first GET /nav of this server's life, once; a
96
+ // failure is logged and the request still answers (the legacy tree, from the settings)
97
+ let navMigrationTried = false;
98
+ const migrateNavOnce = () => {
99
+ if (navMigrationTried) return false;
100
+ navMigrationTried = true;
101
+ try {
102
+ if (fs.existsSync(path.join(root, NAV_DOC_FILE))) return false;
103
+ const settings = workspaceNavSettings(root);
104
+ return hasNavSettings(settings) && migrateNavSettings(root, settings).written;
105
+ } catch (e) {
106
+ console.error(`dt web: your nav settings were not written into nav.yaml — ${String(e?.message ?? e).split('\n')[0]}`);
107
+ return false;
108
+ }
109
+ };
110
+
111
+ // the event streams: the PAGES (the cookie) are what `dt web show` delivers to, what the hook calls connected and
112
+ // what keeps the server from its idle stop; a client token's stream hears the same messages and counts for none of it
113
+ const pageStreams = new Set();
114
+ /** @type {Map<import('node:http').ServerResponse, object>} each client token's stream → its token record */
115
+ const clientStreams = new Map();
116
+ /** End the streams of a client token that is no longer in the state (revoked, or minted again under its name). */
117
+ const endRevokedStreams = () => { for (const [res, rec] of clientStreams) if (!state.clientTokens.includes(rec)) res.end(); };
118
+ let listenPort;
119
+ const cookieName = () => `dt_pane_${listenPort}`;
47
120
 
48
- // ---- the files origin --------------------------------------------------------------------
121
+ // ---- record files and module frames --------------------------------------------------------
49
122
  const varRoots = () => { try { const e = engine(); return envRoots(e.declaredVars ?? [], e.renderEnv); } catch { return []; } };
50
123
  const fileBases = () => {
51
124
  const out = [];
@@ -55,38 +128,21 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
55
128
  } catch { /* no binary collections */ }
56
129
  return out;
57
130
  };
58
- const frameRoutes = [];
59
- // cr-modules-008: module apps run in frames served here; `moduleGuest: null` forces the missing-guest path in tests
60
- frameRoutes.push(moduleFrameRoute({ root, appPort: () => appPort, guest: moduleGuest === undefined ? resolveGuest(path.dirname(new URL(import.meta.url).pathname)) : moduleGuest }));
61
- const files = http.createServer((req, res) => {
62
- try {
63
- serveFile(req, res);
64
- } catch {
65
- if (res.headersSent) return void res.destroy();
66
- send(res, 400, 'bad request');
67
- }
68
- });
69
- const serveFile = (req, res) => {
70
- baseHeaders(res);
131
+ // cr-modules-008: module apps run in frames; `moduleGuest: null` forces the missing-guest path in tests
132
+ const frameRoute = moduleFrameRoute({ root, appPort: () => listenPort, guest: moduleGuest === undefined ? resolveGuest(path.dirname(new URL(import.meta.url).pathname)) : moduleGuest });
133
+
134
+ const serveFile = (req, res, url, rest) => {
71
135
  res.setHeader('Content-Security-Policy', FILE_CSP);
72
- if (!hostNames(filesPort).has(String(req.headers.host ?? '').toLowerCase())) return send(res, 403, 'not this server');
73
- if (req.headers['sec-fetch-site'] === 'cross-site') return send(res, 403, 'cross-site');
74
136
  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'read only');
75
- const url = new URL(req.url, 'http://x');
76
- const parts = url.pathname.split('/').slice(1);
77
- if (!same(parts[0], filesKey)) return send(res, 404, 'not found');
78
- const kind = parts[1];
79
- const rest = parts.slice(2).map((p) => { try { return decodeURIComponent(p); } catch { return '\0'; } });
80
- if (kind === '_dt' && rest[0] === 'frame') {
81
- for (const r of frameRoutes) { const out = r(rest.slice(1), req); if (out) return out.status ? send(res, out.status, out.body) : sendFile(res, out.body, out.type ?? 'text/html; charset=utf-8', out.headers); }
82
- return send(res, 404, 'no frame');
83
- }
137
+ const [key, kind, ...tail] = rest;
138
+ if (!same(key, filesKey)) return send(res, 404, 'not found');
139
+ const parts = tail.map(decode);
84
140
  let file = null;
85
141
  if (kind === 'v') {
86
- const r = varRoots().find((x) => x.name === rest[0]);
87
- if (r) file = contained(r.path, rest.slice(1).join('/'));
142
+ const r = varRoots().find((x) => x.name === parts[0]);
143
+ if (r) file = contained(r.path, parts.slice(1).join('/'));
88
144
  } else if (kind === 'w') {
89
- const rel = rest.join('/');
145
+ const rel = parts.join('/');
90
146
  const allowed = rel.endsWith('.html') && !rel.split('/').some((s) => s.startsWith('.') || s === 'node_modules')
91
147
  || fileBases().some((b) => rel.startsWith(b.path.replace(/\\/g, '/') + '/'));
92
148
  if (allowed) file = contained(root, rel);
@@ -95,140 +151,388 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
95
151
  const ext = path.extname(file).toLowerCase();
96
152
  const headers = {};
97
153
  if ((ext === '.svg' || ext === '.html') && url.searchParams.get('frame') !== '1') headers['Content-Disposition'] = 'attachment';
98
- return sendFile(res, fs.readFileSync(file), MIME[ext] ?? 'application/octet-stream', headers);
154
+ const type = NEVER_ACTIVE.has(ext) ? 'application/octet-stream' : (MIME[ext] ?? 'application/octet-stream');
155
+ return sendFile(res, fs.readFileSync(file), type, headers, req.method === 'HEAD');
156
+ };
157
+ const serveFrame = (req, res, rest) => {
158
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'read only');
159
+ const [key, ...tail] = rest;
160
+ if (!same(key, filesKey)) return send(res, 404, 'not found');
161
+ const out = frameRoute(tail.map(decode), req);
162
+ if (!out) return send(res, 404, 'no frame');
163
+ if (out.status) return send(res, out.status, out.body);
164
+ return sendFile(res, out.body, out.type ?? 'text/html; charset=utf-8', out.headers, req.method === 'HEAD');
99
165
  };
100
166
 
101
-
102
- // ---- the app origin ----------------------------------------------------------------------
167
+ // ---- the shell -----------------------------------------------------------------------------
103
168
  const appHtml = fs.readFileSync(path.join(appDir, 'index.html'), 'utf8');
104
- // the files origin on the host the page was opened at: a page at localhost framing 127.0.0.1 would be cross-site,
105
- // which the files origin refuses (cr-modules-008 t13)
106
- const filesOrigin = (pageHost = '') => `http://${/^localhost(:|$)/i.test(pageHost) ? 'localhost' : '127.0.0.1'}:${filesPort}`;
107
- const boot = (nonce, pageHost) => {
108
- const base = `${filesOrigin(pageHost)}/${filesKey}`;
109
- const globals = {
110
- __DT_HOST__: { nav: 'app', commands: false, openFile: 'tab', tabs: false, history: 'hash', moduleFrames: 'url' },
111
- ...((g) => ({ __DT_MODULE_GRANTS__: g.grant, __DT_MODULE_GRANT_PROBLEM__: g.problem }))(readModuleGrantResult(root)),
112
- __DT_FILE_BASES__: fileBases().map((b) => ({ ...b, uri: `${base}/w/${b.path}` })),
113
- __DT_VAR_ROOTS__: varRoots().map((r) => ({ ...r, uri: `${base}/v/${encodeURIComponent(r.name)}` })),
114
- __DT_FILES__: base,
115
- };
116
- const json = JSON.stringify(globals).replace(/</g, '\\u003c');
169
+ const boot = (nonce) => {
170
+ const json = JSON.stringify({ __DT_HOST__: HOST_CAPABILITIES }).replace(/</g, '\\u003c');
117
171
  return `<script nonce="${nonce}">Object.assign(window, ${json});</script>`;
118
172
  };
173
+ const serveShell = (req, res, url, host) => {
174
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'the app is read only');
175
+ const rel = url.pathname.slice('/app/'.length);
176
+ if (rel === '' || rel === 'index.html') {
177
+ const nonce = random(16);
178
+ res.setHeader('Content-Security-Policy', appCsp(nonce, host));
179
+ res.setHeader('Cache-Control', 'no-store');
180
+ return sendFile(res, appHtml.replace(/<head>/i, `<head>${boot(nonce)}`), 'text/html; charset=utf-8', {}, req.method === 'HEAD');
181
+ }
182
+ const f = contained(appDir, decode(rel));
183
+ if (!f || !fs.statSync(f).isFile()) return send(res, 404, 'not found');
184
+ return sendFile(res, fs.readFileSync(f), MIME[path.extname(f).toLowerCase()] ?? 'application/octet-stream', {}, req.method === 'HEAD');
185
+ };
119
186
 
120
- const authed = (req) => {
121
- const m = /^Bearer (.+)$/.exec(req.headers.authorization ?? '');
122
- if (m && same(m[1], token)) return 'bearer';
123
- const sid = new RegExp(`(?:^|;\\s*)dt_session_${appPort}=([^;]+)`).exec(req.headers.cookie ?? '')?.[1];
124
- return sid && sessions.has(sid) ? 'cookie' : null;
187
+ // ---- sign-in -------------------------------------------------------------------------------
188
+ const signIn = (req, res, url) => {
189
+ if (req.method !== 'GET') return send(res, 405, 'sign in with GET');
190
+ const taken = pairing.take(url.pathname.slice('/sign-in/'.length));
191
+ if (!taken) return page(res, 401, SIGN_IN_REFUSED);
192
+ const sid = sessions.mint(taken.cap ?? null);
193
+ // a browser-session cookie: no Max-Age, since the session itself lives only as long as this process. Cookies ignore
194
+ // the port, so every other server on 127.0.0.1 is sent this one too — Path keeps that to the two prefixes that read
195
+ // it, never a page, an asset or the root (README, "does not protect against")
196
+ res.setHeader('Set-Cookie', ['/api/', '/_dt/'].map((p) => `${cookieName()}=${sid}; HttpOnly; SameSite=Strict; Path=${p}`));
197
+ res.writeHead(302, { Location: `/app/#${taken.route}` });
198
+ return res.end();
125
199
  };
126
- // a request that throws — a malformed percent-escape, a client gone mid-body — answers or is dropped, never
127
- // takes the server down with it
128
- const app = http.createServer((req, res) => {
129
- handle(req, res).catch((err) => {
130
- if (res.headersSent || req.destroyed) return void res.destroy();
131
- json(res, err instanceof URIError ? 400 : 500, { error: err instanceof URIError ? 'a malformed percent-escape in the path' : 'the request failed' });
132
- });
133
- });
134
- const handle = async (req, res) => {
135
- baseHeaders(res);
136
- const host = String(req.headers.host ?? '').toLowerCase();
137
- if (!hostNames(appPort).has(host)) return send(res, 403, `Host "${req.headers.host ?? ''}" is not this server — use 127.0.0.1:${appPort}`);
138
- if (req.headers['sec-fetch-site'] === 'cross-site') return send(res, 403, 'a request from another site is refused');
200
+ const pairUrl = ({ route = '/', cap = null } = {}) => `http://127.0.0.1:${listenPort}/sign-in/${pairing.mint({ route, cap })}`;
201
+
202
+ // ---- who is asking -------------------------------------------------------------------------
203
+ const bearerOf = (req) => /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1] ?? null;
204
+ const cookieOf = (req) => {
205
+ const name = cookieName();
206
+ for (const part of String(req.headers.cookie ?? '').split(';')) {
207
+ const i = part.indexOf('=');
208
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
209
+ }
210
+ return null;
211
+ };
212
+ /** A cookie counts only from the pane's own page: Sec-Fetch-Site same-origin, or an Origin equal to this request's. */
213
+ const fromThePane = (req) => {
214
+ const site = req.headers['sec-fetch-site'];
139
215
  const origin = req.headers.origin;
140
- if (origin !== undefined && !(origin.startsWith('http://') && hostNames(appPort).has(origin.slice(7).toLowerCase()))) return send(res, 403, `Origin "${origin}" may not call this server`);
141
- const url = new URL(req.url, 'http://x');
142
- // CSP has no IPv6 host-source, so module frames cannot name [::1] as an ancestor: the page moves to 127.0.0.1
143
- if (host.startsWith('[::1]') && req.method === 'GET' && !url.pathname.startsWith('/api/')) {
144
- res.writeHead(302, { Location: `http://127.0.0.1:${appPort}${req.url}` });
145
- return res.end();
216
+ if (origin !== undefined) return origin === `http://${String(req.headers.host ?? '').toLowerCase()}` && (site === undefined || site === 'same-origin');
217
+ return site === 'same-origin';
218
+ };
219
+
220
+ const readBody = async (req) => {
221
+ let size = 0;
222
+ const chunks = [];
223
+ for await (const chunk of req) {
224
+ size += chunk.length;
225
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, 'the body is too large');
226
+ chunks.push(chunk);
146
227
  }
228
+ return Buffer.concat(chunks).toString('utf8');
229
+ };
230
+ /** A write's body: JSON only, by Content-Type — 415 for any other type, 400 for JSON that does not parse. */
231
+ const jsonBody = async (req) => {
232
+ const raw = await readBody(req);
233
+ if (!WRITES.has(req.method) || !raw.length) return undefined;
234
+ if (!JSON_TYPE.test(String(req.headers['content-type'] ?? ''))) throw new HttpError(415, `${req.method} takes Content-Type: application/json`);
235
+ try { return JSON.parse(raw); } catch { throw new HttpError(400, 'the body is not JSON'); }
236
+ };
147
237
 
148
- if (req.method === 'GET' && url.pathname.startsWith('/sign-in/')) {
149
- const code = url.pathname.slice('/sign-in/'.length);
150
- const exp = signIns.get(code);
151
- signIns.delete(code);
152
- if (!exp || exp < Date.now()) return page(res, 401, 'Sign in with the URL <code>dt web</code> printed — run <code>npx dt web url</code> for a new one.');
153
- const sid = random(32);
154
- sessions.add(sid);
155
- res.setHeader('Set-Cookie', `dt_session_${appPort}=${sid}; HttpOnly; SameSite=Strict; Path=/`);
156
- res.writeHead(302, { Location: '/' });
157
- return res.end();
238
+ // ---- levels ---------------------------------------------------------------------------------
239
+ // the collections whose record WRITES need admin (reading 1), recomputed when the descriptors change
240
+ let internalFor = null;
241
+ let internal = null;
242
+ const internalNow = () => {
243
+ const d = handler.api.descriptors();
244
+ if (d !== internalFor) {
245
+ let e = {};
246
+ try { e = engine().descriptor; } catch { /* no engine: system collections only */ }
247
+ internal = internalCollections(d.values(), e);
248
+ internalFor = d;
158
249
  }
159
- if (req.method === 'POST' && url.pathname === '/_dt/sign-in') {
160
- if (authed(req) !== 'bearer') return send(res, 401, 'a new sign-in URL needs the bearer token');
161
- return json(res, 200, { url: signInUrl() });
250
+ return internal;
251
+ };
252
+ /** The level a request is answered at: the stored level lowered by the session's cap, or the client token's own. */
253
+ const levelOf = (who) => (who.kind === 'client' ? who.record.level : lowerLevel(state.level, who.cap ?? 'admin'));
254
+ const setLevel = (level) => {
255
+ const was = state.level;
256
+ if (levelRank(level) > levelRank(was)) state.raisedAt = new Date(now()).toISOString();
257
+ else if (levelRank(level) < levelRank(was)) state.raisedAt = null;
258
+ state.level = level;
259
+ save();
260
+ broadcast({ type: 'dt:host' });
261
+ };
262
+
263
+ // ---- /api/ and /_dt/ -----------------------------------------------------------------------
264
+ /** Who asks /api/: a client token (bearer), or the cookie from the pane's own page. The control token is not one. */
265
+ const apiCaller = (req) => {
266
+ const bearer = bearerOf(req);
267
+ if (bearer !== null) {
268
+ const record = matchClientToken(state.clientTokens, bearer);
269
+ if (!record) return { refuse: [401, same(bearer, controlToken) ? 'the control token opens /_dt/ only — /api/ takes the cookie or a client token (dt web token)' : 'not a client token of this server'] };
270
+ return { kind: 'client', record };
162
271
  }
163
- const who = authed(req);
164
- if (!who) {
165
- if (url.pathname.startsWith('/api/')) { res.setHeader('WWW-Authenticate', 'Bearer'); return json(res, 401, { error: 'sign in with the URL dt web printed, or send Authorization: Bearer <token>' }); }
166
- return page(res, 401, 'Sign in with the URL <code>dt web</code> printed — run <code>npx dt web url</code> for a new one.');
272
+ const sid = cookieOf(req);
273
+ const session = sid ? sessions.check(sid) : null;
274
+ if (!session) return { refuse: [401, SIGN_IN_FIRST] };
275
+ if (!fromThePane(req)) return { refuse: [403, NOT_FROM_THE_PANE] };
276
+ return { kind: 'cookie', cap: session.cap, sid };
277
+ };
278
+
279
+ const serveApi = async (req, res, url) => {
280
+ const who = apiCaller(req);
281
+ if (who.refuse) return json(res, who.refuse[0], { error: who.refuse[1] });
282
+ if (who.kind === 'client') idle.reset();
283
+ if (req.method === 'GET' && url.pathname === '/api/events') return openStream(req, res, who);
284
+ const body = await jsonBody(req);
285
+ const apiPath = req.url.slice(4);
286
+ const hit = matchRoute(req.method, apiPath);
287
+ if (who.kind === 'client' && (hit?.route.id === 'selection-put' || hit?.route.id === 'queue-post')) return json(res, 403, { error: CLIENT_NOT_PANE });
288
+ const level = levelOf(who);
289
+ const decision = gate(paneScope({ level, internal: internalNow(), label: who.kind === 'client' ? 'this token' : 'this pane' }), handler.api.descriptors(), req.method, apiPath, url.searchParams, body);
290
+ // no route: the handler answers its own 404, with the encoding hint
291
+ if (!decision.ok && decision.status !== 404) return json(res, decision.status, { error: decision.error });
292
+ if (hit?.route.id === 'host') return json(res, 200, await store.host({ level }));
293
+ const log = console.log;
294
+ console.log = () => {};
295
+ try {
296
+ // the migration writes nav.yaml, so it waits for a caller at Edit or above; below, the handler draws the old
297
+ // settings' tree and nothing is written
298
+ const migrated = req.method === 'GET' && url.pathname === '/api/nav' && levelRank(level) >= levelRank('edit') && migrateNavOnce();
299
+ const out = await handler.handle(req.method, apiPath, body);
300
+ if (migrated && out.status === 200 && Array.isArray(out.body) && out.body[0]?.kind === 'workspace') out.body[0] = { ...out.body[0], notice: NAV_MIGRATED_NOTICE };
301
+ return json(res, out.status, out.body ?? null);
302
+ } catch (e) {
303
+ if (e instanceof URIError) throw new HttpError(400, 'a malformed percent-escape in the path');
304
+ return json(res, 500, { error: String(e?.message ?? e) });
305
+ } finally { console.log = log; }
306
+ };
307
+
308
+ // ---- the push's targets --------------------------------------------------------------------
309
+ const reader = { descriptors: () => handler.api.descriptors(), handle: (m, p) => handler.handle(m, p) };
310
+ const parseRef = (ref) => { try { return engine().parseRef(ref); } catch { return null; } };
311
+ /** `dt web show`'s target → the app route: a collection, then a record ref, then a ui-view id; null for none. */
312
+ const resolveTarget = async (target) => {
313
+ if (typeof target !== 'string' || !target || target.length > 500) return null;
314
+ if (handler.api.descriptors().has(target)) return `/content/${target}`;
315
+ const ref = parseRef(target);
316
+ if (ref?.collection && ref.id && handler.api.descriptors().has(ref.collection)) {
317
+ const r = await handler.handle('GET', `/collections/${encodeURIComponent(ref.collection)}/records/${ref.id.split('/').map(encodeURIComponent).join('/')}`);
318
+ if (r.status === 200) return `/content/${ref.collection}/${ref.id}`;
167
319
  }
168
- const write = !['GET', 'HEAD', 'OPTIONS'].includes(req.method);
169
- if (write && who === 'cookie') {
170
- const named = (origin !== undefined) || req.headers['sec-fetch-site'] === 'same-origin';
171
- if (!named) return json(res, 403, { error: 'a write with the session cookie must carry this server\'s Origin or Sec-Fetch-Site: same-origin — or send Authorization: Bearer <token>' });
320
+ if (!target.includes('/')) {
321
+ const v = await handler.handle('GET', `/collections/ui-views/records/${encodeURIComponent(target)}`);
322
+ const route = v.status === 200 ? v.body?.fields?.route : null;
323
+ if (typeof route === 'string' && route.startsWith('/')) return route;
172
324
  }
325
+ return null;
326
+ };
327
+ /** `dt web select`'s refs: existing records, at most 50, all of one collection (as PUT /selection checks them). */
328
+ const checkRefs = async (refs) => {
329
+ const r = await validateSelection(reader, { view: { other: true }, refs });
330
+ if (!r.ok) throw new HttpError(400, r.error);
331
+ if (new Set(r.value.refs.map((x) => parseRef(x)?.collection)).size > 1) throw new HttpError(400, 'the refs of a selection are all of one collection');
332
+ return r.value.refs;
333
+ };
173
334
 
174
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
175
- const nonce = random(16);
176
- res.setHeader('Content-Security-Policy', appCsp(nonce, filesOrigin(host), filesKey));
335
+ const levelIn = (body) => {
336
+ if (!LEVELS.includes(body?.level)) throw new HttpError(400, `a level is ${LEVELS.join(', ')}`);
337
+ return body.level;
338
+ };
339
+ /** /_dt/ under the control token: the verbs' routes. */
340
+ const controlRoutes = {
341
+ 'POST /_dt/pair': async (body) => {
342
+ let route = typeof body?.route === 'string' && body.route.startsWith('/') ? body.route : '/';
343
+ // `dt web open <target>`: the same resolver as the push
344
+ if (body?.target !== undefined) {
345
+ route = await resolveTarget(body.target);
346
+ if (!route) return [400, { error: `no record, collection or ui-view ${body.target}` }];
347
+ }
348
+ const cap = LEVELS.includes(body?.cap) ? body.cap : null;
349
+ return [200, { url: pairUrl({ route, cap }) }];
350
+ },
351
+ 'POST /_dt/token': (body) => {
352
+ let minted;
353
+ try { minted = mintClientToken({ name: body?.name, level: body?.level ?? 'read', now }); } catch (e) { throw new HttpError(400, e.message); }
354
+ // written first, then swapped in memory: a refused write (a read-only state dir) leaves the earlier token of
355
+ // that name working, here and in the file
356
+ saveTokens([...state.clientTokens.filter((t) => t.name !== minted.record.name), minted.record]);
357
+ endRevokedStreams();
358
+ return [200, { token: minted.token, api: `http://127.0.0.1:${listenPort}/api` }];
359
+ },
360
+ 'POST /_dt/revoke': (body) => {
361
+ if (body?.token !== undefined) {
362
+ const kept = state.clientTokens.filter((t) => t.name !== body.token);
363
+ const n = state.clientTokens.length - kept.length;
364
+ // nothing to revoke, nothing to write; otherwise written first, then swapped (as a token is)
365
+ if (n) { saveTokens(kept); endRevokedStreams(); }
366
+ return [200, { revoked: n }];
367
+ }
368
+ const n = sessions.revokeAll();
369
+ for (const c of pageStreams) c.end();
370
+ return [200, { revoked: n }];
371
+ },
372
+ 'POST /_dt/level': (body) => { setLevel(levelIn(body)); return [200, { level: state.level, raisedAt: state.raisedAt ?? null }]; },
373
+ 'POST /_dt/push': async (body) => {
374
+ if (body?.type === 'navigate') {
375
+ const route = await resolveTarget(body.target);
376
+ if (!route) return [400, { error: `no record, collection or ui-view ${body.target}` }];
377
+ broadcast({ type: 'dt:navigate', route });
378
+ return [200, { route, delivered: pageStreams.size }];
379
+ }
380
+ if (body?.type === 'select') {
381
+ const refs = body.clear === true ? [] : await checkRefs(body.refs);
382
+ broadcast({ type: 'dt:select', refs });
383
+ return [200, { refs, delivered: pageStreams.size }];
384
+ }
385
+ return [400, { error: 'a push is { type: "navigate", target } or { type: "select", refs | clear: true }' }];
386
+ },
387
+ 'GET /_dt/pane': (_body, _who, url) => [200, store.snapshot({ hook: url.searchParams.get('hook') === '1', connected: pageStreams.size > 0 })],
388
+ 'POST /_dt/pane/delivered': (body) => [200, { delivered: store.delivered(body?.ids) }],
389
+ };
390
+ /** /_dt/ under the cookie: the page's own routes. */
391
+ const pageRoutes = {
392
+ 'POST /_dt/level': (body, who) => {
393
+ const level = levelIn(body);
394
+ if (levelRank(level) > levelRank(levelOf(who))) return [403, { error: PAGE_CANNOT_RAISE(level) }];
395
+ if (level !== state.level) setLevel(level);
396
+ return [200, { level }];
397
+ },
398
+ 'GET /_dt/workspaces': async () => [200, await listWorkspaces({ selfId: workspaceKey(root), stateDir })],
399
+ 'POST /_dt/handoff': async (body, who) => {
400
+ const r = await handoff({ id: body?.id, pageLevel: levelOf(who), selfId: workspaceKey(root), stateDir, now, moved: (to) => store.moved?.(to) });
401
+ return [r.status, r.body];
402
+ },
403
+ };
404
+
405
+ const serveDt = async (req, res, url) => {
406
+ const key = `${req.method} ${url.pathname}`;
407
+ // anyone may ask the server to prove it is the one its runtime file names: a fresh challenge in, an HMAC under the
408
+ // per-start instance out — no token, no data, and nothing that names the instance (runtime.js `proves`)
409
+ if (url.pathname === IDENTITY_PATH) {
410
+ if (req.method !== 'GET') return json(res, 405, { error: `${IDENTITY_PATH} takes GET` });
411
+ const challenge = url.searchParams.get('challenge');
412
+ if (typeof challenge !== 'string' || !CHALLENGE.test(challenge)) return json(res, 400, { error: 'a challenge is 16 to 128 base64url characters' });
177
413
  res.setHeader('Cache-Control', 'no-store');
178
- return sendFile(res, appHtml.replace(/<head>/i, `<head>${boot(nonce, host)}`), 'text/html; charset=utf-8');
179
- }
180
- if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
181
- const f = contained(path.join(appDir, 'assets'), decodeURIComponent(url.pathname.slice(8)));
182
- if (!f) return send(res, 404, 'not found');
183
- return sendFile(res, fs.readFileSync(f), MIME[path.extname(f).toLowerCase()] ?? 'application/octet-stream');
414
+ return json(res, 200, { proof: identityProof(instance, challenge) });
184
415
  }
185
- if (req.method === 'GET' && url.pathname === '/api/events') {
186
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
187
- res.write(': open\n\n');
188
- clients.add(res);
416
+ const bearer = bearerOf(req);
417
+ if (bearer !== null) {
418
+ if (!same(bearer, controlToken)) {
419
+ const client = matchClientToken(state.clientTokens, bearer);
420
+ return json(res, 401, { error: client ? 'a client token opens /api/ only' : 'not this server\'s control token' });
421
+ }
189
422
  idle.reset();
190
- req.on('close', () => { clients.delete(res); idle.reset(); });
191
- return;
192
- }
193
- if (url.pathname.startsWith('/api/')) {
194
- let raw = '';
195
- for await (const chunk of req) raw += chunk;
196
- let body;
197
- if (write && raw.length) {
198
- try { body = JSON.parse(raw); } catch { return json(res, 415, { error: `${req.method} ${url.pathname} takes a JSON body` }); }
423
+ const body = await jsonBody(req);
424
+ const fn = controlRoutes[key];
425
+ if (!fn) return json(res, 404, { error: `no route ${key}` });
426
+ let answer;
427
+ // a state write refused by the dir's mode answers its sentence, with the code the verbs refuse on as open does
428
+ try { answer = await fn(body, { kind: 'control' }, url); } catch (e) {
429
+ if (e?.code === 'EDTWEBDIR') return json(res, 500, { error: e.message, code: e.code });
430
+ throw e;
199
431
  }
200
- const log = console.log;
201
- console.log = () => {};
202
- try {
203
- const out = await handler.handle(req.method, req.url.slice(4), body);
204
- return json(res, out.status, out.body ?? null);
205
- } catch (e) {
206
- return json(res, 500, { error: String(e?.message ?? e) });
207
- } finally { console.log = log; }
432
+ const [status, out] = answer;
433
+ return json(res, status, out);
434
+ }
435
+ const sid = cookieOf(req);
436
+ if (sid !== null) {
437
+ if (!fromThePane(req)) return json(res, 403, { error: NOT_FROM_THE_PANE });
438
+ const session = sessions.check(sid);
439
+ if (!session) return json(res, 401, { error: SIGN_IN_FIRST });
440
+ const body = await jsonBody(req);
441
+ const fn = pageRoutes[key];
442
+ if (!fn) return json(res, 401, { error: `${key} takes the control token` });
443
+ const [status, out] = await fn(body, { kind: 'cookie', cap: session.cap, sid }, url);
444
+ return json(res, status, out);
208
445
  }
209
- return send(res, 404, 'not found');
446
+ return json(res, 401, { error: `${url.pathname} takes the control token` });
447
+ };
448
+
449
+ // ---- the event stream ----------------------------------------------------------------------
450
+ const openStream = (req, res, who) => {
451
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
452
+ res.write(': open\n\n');
453
+ if (who.kind === 'client') clientStreams.set(res, who.record);
454
+ else pageStreams.add(res);
455
+ idle.reset();
456
+ req.on('close', () => { pageStreams.delete(res); clientStreams.delete(res); idle.reset(); });
457
+ };
458
+ const broadcast = (msg) => {
459
+ const data = `data: ${JSON.stringify(msg)}\n\n`;
460
+ for (const c of pageStreams) c.write(data);
461
+ for (const c of clientStreams.keys()) c.write(data);
210
462
  };
211
463
 
464
+ // ---- one request ---------------------------------------------------------------------------
465
+ const handle = async (req, res) => {
466
+ baseHeaders(res);
467
+ const host = String(req.headers.host ?? '').toLowerCase();
468
+ if (!hostNames(listenPort).has(host)) return send(res, 403, `Host "${req.headers.host ?? ''}" is not this server — use 127.0.0.1:${listenPort}`);
469
+ if (req.headers['sec-fetch-site'] === 'cross-site') return send(res, 403, 'a request from another site is refused');
470
+ const origin = req.headers.origin;
471
+ if (origin !== undefined && origin !== 'null' && !(origin.startsWith('http://') && hostNames(listenPort).has(origin.slice(7).toLowerCase()))) return send(res, 403, `Origin "${origin}" may not call this server`);
472
+ // CSP has no IPv6 host-source, so module frames cannot name [::1] as an ancestor: the page moves to 127.0.0.1
473
+ if (host.startsWith('[::1]')) {
474
+ res.writeHead(302, { Location: `http://127.0.0.1:${listenPort}${req.url}` });
475
+ return res.end();
476
+ }
477
+ const url = new URL(req.url, 'http://x');
478
+ const rawPath = String(req.url).split('?')[0];
479
+ const kind = classify(rawPath);
480
+ // a sandboxed document (a record file, a module frame) has an opaque origin: it reaches nothing but bytes
481
+ if (origin === 'null' && kind !== 'files' && kind !== 'frames') return kind === 'api' || kind === 'dt' ? json(res, 403, { error: NOT_FROM_THE_PANE }) : send(res, 403, 'a request from a sandboxed document is refused');
482
+ // no cross-origin request is ever granted, so there is no preflight to answer: OPTIONS is refused as a method on
483
+ // every prefix (never a redirect, a 404 that says the route is missing, or a 401 that asks for a token)
484
+ if (req.method === 'OPTIONS' && ALLOW[kind]) {
485
+ res.setHeader('Allow', ALLOW[kind]);
486
+ return kind === 'api' || kind === 'dt' ? json(res, 405, { error: `OPTIONS is not answered here — ${ALLOW[kind]}` }) : send(res, 405, `OPTIONS is not answered here — ${ALLOW[kind]}`);
487
+ }
488
+ switch (kind) {
489
+ case 'root':
490
+ res.writeHead(302, { Location: '/app/' });
491
+ return res.end();
492
+ case 'shell': return serveShell(req, res, url, host);
493
+ case 'sign-in': return signIn(req, res, url);
494
+ case 'api': return serveApi(req, res, url);
495
+ case 'dt': return serveDt(req, res, url);
496
+ case 'files': return serveFile(req, res, url, rawPath.slice('/_files/'.length).split('/'));
497
+ case 'frames': return serveFrame(req, res, rawPath.slice('/_frames/'.length).split('/'));
498
+ default: return send(res, 404, 'not found');
499
+ }
500
+ };
501
+ // a request that throws — a malformed percent-escape, a client gone mid-body — answers or is dropped, never takes
502
+ // the server down with it
503
+ const server = http.createServer((req, res) => {
504
+ handle(req, res).catch((err) => {
505
+ // a request whose body was read to its end is `destroyed` by then; only a gone socket is beyond answering
506
+ if (res.headersSent || res.destroyed || req.socket?.destroyed) return void res.destroy();
507
+ const status = err instanceof HttpError ? err.status : err instanceof URIError ? 400 : 500;
508
+ const message = err instanceof HttpError ? err.message : err instanceof URIError ? 'a malformed percent-escape in the path' : 'the request failed';
509
+ const kind = classify(String(req.url ?? '').split('?')[0]);
510
+ return kind === 'api' || kind === 'dt' ? json(res, status, { error: message }) : send(res, status, message);
511
+ });
512
+ });
212
513
 
213
514
  // ---- live updates ------------------------------------------------------------------------
214
515
  let pending = null;
215
516
  let timer = null;
216
- const broadcast = (scope) => {
217
- const data = `data: ${JSON.stringify(invalidateMessage(scope))}\n\n`;
218
- for (const c of clients) c.write(data);
219
- };
220
517
  const changed = (file) => {
221
518
  const scope = changeScope(root, file);
222
519
  pending = pending === 'schema' || scope === 'schema' ? 'schema' : 'data';
223
520
  clearTimeout(timer);
224
- timer = setTimeout(() => { const s = pending; pending = null; broadcast(s); }, DEBOUNCE_MS);
521
+ timer = setTimeout(() => { const s = pending; pending = null; broadcast(invalidateMessage(s)); }, DEBOUNCE_MS);
225
522
  };
226
523
  const watchers = [];
524
+ // A watcher reports trouble as an 'error' event, and an EventEmitter with no 'error' listener throws it — out of the
525
+ // server, uncaught. On Linux a recursive watch is Node's own walk (readdir of each new folder), so a folder that a
526
+ // compile creates and removes again (.dreamteamer/skills) fails that walk with ENOENT. That is a change, not a fault:
527
+ // it is sent as one, and the watch goes on.
528
+ const watched = (w, dir) => { w.on('error', (e) => { if (!stopped) changed(typeof e?.path === 'string' ? e.path : dir); }); watchers.push(w); };
227
529
  const watchDir = (dir) => {
228
530
  if (!fs.existsSync(dir)) return;
229
- try { watchers.push(fs.watch(dir, { recursive: true }, (_e, f) => f && changed(path.join(dir, String(f))))); } catch { /* unwatchable */ }
531
+ try { watched(fs.watch(dir, { recursive: true }, (_e, f) => f && changed(path.join(dir, String(f)))), dir); } catch { /* unwatchable */ }
230
532
  };
231
533
  for (const d of ['data', 'state', '.dreamteamer']) watchDir(path.join(root, d));
534
+ // nav.yaml at the root, by name: a save renames a temp file over it, which only a watch on the folder sees
535
+ try { watched(fs.watch(root, (_e, f) => f && String(f) === NAV_DOC_FILE && changed(path.join(root, NAV_DOC_FILE))), path.join(root, NAV_DOC_FILE)); } catch { /* unwatchable */ }
232
536
  try { for (const l of fs.readdirSync(path.join(root, 'git_modules'))) watchDir(path.join(root, 'git_modules', l, 'data')); } catch { /* no layers */ }
233
537
 
234
538
  // ---- lifetime ----------------------------------------------------------------------------
@@ -242,9 +546,11 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
242
546
  stopped = true;
243
547
  clearTimeout(timer); idle.clear(); clearInterval(ownerTimer);
244
548
  for (const w of watchers) w.close();
245
- for (const c of clients) c.end();
246
- const done = Promise.all([new Promise((r) => app.close(r)), new Promise((r) => files.close(r))]);
247
- app.closeAllConnections?.(); files.closeAllConnections?.();
549
+ for (const c of pageStreams) c.end();
550
+ for (const c of clientStreams.keys()) c.end();
551
+ try { store.flush?.(); } catch { /* the state dir went away: the next start reads what was written */ }
552
+ const done = new Promise((r) => server.close(r));
553
+ server.closeAllConnections?.();
248
554
  await done;
249
555
  onStop?.(why);
250
556
  resolveClosed(why);
@@ -255,36 +561,53 @@ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, owner
255
561
  clear() { clearTimeout(this.t); this.t = null; },
256
562
  reset() {
257
563
  this.clear();
258
- if (idleMinutes > 0 && clients.size === 0) this.t = setTimeout(() => close('idle'), idleMinutes * 60_000);
564
+ if (idleMinutes > 0 && pageStreams.size === 0) this.t = setTimeout(() => close('idle'), idleMinutes * 60_000);
259
565
  },
260
566
  };
261
567
  const ownerTimer = ownerPid ? setInterval(() => { try { process.kill(ownerPid, 0); } catch (e) { if (e.code !== 'EPERM') close('owner gone'); } }, 5_000) : null;
262
568
  ownerTimer?.unref?.();
263
569
 
264
- const listen = (srv, p) => new Promise((resolve, reject) => { srv.once('error', reject); srv.listen(p, '127.0.0.1', () => { srv.off('error', reject); resolve(srv.address().port); }); });
265
- appPort = await listen(app, port);
570
+ // the port: the one asked for, else the stored one, else any. A stored port another process took moves the server and
571
+ // the caller says where from (no session outlives a process, so the old port's cookie names nothing here); a port the
572
+ // person asked for is theirs, and taken it is an error. The port is stored at once.
573
+ const listenOn = (p) => new Promise((resolve, reject) => {
574
+ const fail = (e) => reject(e);
575
+ server.once('error', fail);
576
+ server.listen(p, '127.0.0.1', () => { server.off('error', fail); resolve(server.address().port); });
577
+ });
578
+ const stored = port ? null : state.port;
579
+ let movedFrom;
580
+ try {
581
+ try {
582
+ listenPort = await listenOn(port || stored || 0);
583
+ } catch (e) {
584
+ if (e?.code !== 'EADDRINUSE' || !stored) throw e;
585
+ movedFrom = stored;
586
+ listenPort = await listenOn(0);
587
+ }
588
+ } catch (e) {
589
+ clearTimeout(timer); clearInterval(ownerTimer);
590
+ for (const w of watchers) w.close();
591
+ throw e;
592
+ }
593
+ if (state.port !== listenPort) { state.port = listenPort; save(); }
266
594
  // [::1] answers only to send the page to 127.0.0.1: CSP cannot name an IPv6 ancestor for module frames (cr-modules-008 t13)
267
- v6 = http.createServer((req, res) => { baseHeaders(res); res.writeHead(302, { Location: `http://127.0.0.1:${appPort}${req.url ?? '/'}` }); res.end(); });
595
+ v6 = http.createServer((req, res) => { baseHeaders(res); res.writeHead(302, { Location: `http://127.0.0.1:${listenPort}${req.url ?? '/'}` }); res.end(); });
268
596
  v6.on('error', () => { /* no IPv6 loopback here: nothing to redirect */ });
269
- v6.listen(appPort, '::1');
270
- filesPort = await listen(files, 0);
597
+ v6.listen(listenPort, '::1');
271
598
  idle.reset();
272
- const signInUrl = () => {
273
- const code = random(24);
274
- signIns.set(code, Date.now() + signInTtlMs);
275
- return `http://127.0.0.1:${appPort}/sign-in/${code}`;
276
- };
277
599
  return {
278
- appUrl: `http://127.0.0.1:${appPort}`,
279
- appPort,
280
- filesPort,
281
- filesOrigin: filesOrigin(),
282
- token,
283
- signInUrl,
284
- /** cr-modules-008's frame document plugs in here: (segments after /_dt/frame/, req) → { body, type?, headers? } | null */
285
- addFrameRoute: (fn) => frameRoutes.push(fn),
600
+ url: `http://127.0.0.1:${listenPort}`,
601
+ port: listenPort,
602
+ controlToken,
603
+ instance,
604
+ filesKey,
605
+ pair: pairUrl,
606
+ broadcast,
607
+ clients: () => pageStreams.size,
286
608
  close,
287
609
  closed,
610
+ ...(movedFrom !== undefined ? { movedFrom } : {}),
288
611
  };
289
612
  }
290
613
 
@@ -293,14 +616,18 @@ function send(res, status, text) {
293
616
  res.end(text);
294
617
  }
295
618
  function json(res, status, body) {
619
+ // serialised before the head is written: a body that cannot be (a cycle) is a 500 that says so, never a dropped
620
+ // connection
621
+ let text;
622
+ try { text = JSON.stringify(body); } catch (e) { status = 500; text = JSON.stringify({ error: `the answer could not be sent as JSON: ${e?.message ?? e}` }); }
296
623
  res.writeHead(status, { 'Content-Type': 'application/json' });
297
- res.end(JSON.stringify(body));
624
+ res.end(text);
298
625
  }
299
- function page(res, status, html) {
626
+ function page(res, status, text) {
300
627
  res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'" });
301
- res.end(`<!doctype html><meta charset="utf-8"><title>dreamteamer</title><p style="font:14px system-ui;margin:2em">${html}</p>`);
628
+ res.end(`<!doctype html><meta charset="utf-8"><title>dreamteamer</title><p style="font:14px system-ui;margin:2em">${text.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</p>`);
302
629
  }
303
- function sendFile(res, body, type, headers = {}) {
304
- res.writeHead(200, { 'Content-Type': type, ...headers });
305
- res.end(body);
630
+ function sendFile(res, body, type, headers = {}, headOnly = false) {
631
+ res.writeHead(200, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(body), ...headers });
632
+ res.end(headOnly ? undefined : body);
306
633
  }