@dreamteamer/web 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,198 @@
1
- # Temporary Holding Version
1
+ # @dreamteamer/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `dt web` serves the dreamteamer UI in your browser, over a dreamteamer workspace on your own machine.
4
+ You get the same nav, lists, boards and record pages as the VS Code extension. Records change as they
5
+ change on disk, with no reload. Nothing leaves the machine: the server listens on loopback only.
6
+
7
+ ![The Northwind sample workspace in dt web: the nav on the left, a projects board on the right](docs/screenshot.png)
8
+
9
+ > **Not released yet.** This README is written ahead of the code and is the contract the package is
10
+ > built to. It is `0.x`: any minor version may change the command line, and `UPDATING.md` says what to do.
11
+ > Apache-2.0. Issues: https://github.com/dreamteamer/dreamteamer-modules/issues
12
+
13
+ ## quick start
14
+
15
+ You need Node 20 or newer, git, and a compiled dreamteamer workspace. If you don't have one yet:
16
+
17
+ ```bash
18
+ mkdir northwind && cd northwind
19
+ npx dreamteamer init
20
+ npx dreamteamer compile
21
+ ```
22
+
23
+ Then, in the workspace:
24
+
25
+ ```bash
26
+ npm install --save-dev @dreamteamer/web
27
+ npx dt web
28
+ ```
29
+
30
+ ```text
31
+ ✔ dreamteamer web at http://127.0.0.1:53817/sign-in/7Qx2…k9Fa
32
+ ```
33
+
34
+ The first line on stdout is always the sign-in URL. Your browser opens it. The page that loads is the
35
+ workspace.
36
+
37
+ The package ships the built app inside its tarball. The first run needs no network, and nothing is
38
+ written into `node_modules`.
39
+
40
+ To try it without adding a dependency, run it through `npx -p`, which works for every subcommand below:
41
+
42
+ ```bash
43
+ npx -p @dreamteamer/web dt-web . # start
44
+ npx -p @dreamteamer/web dt-web . status # the same as `npx dt web status`
45
+ ```
46
+
47
+ Uninstall: `npm uninstall @dreamteamer/web`. `dt web` leaves no files behind in the workspace once it
48
+ has stopped.
49
+
50
+ ## commands
51
+
52
+ | command | does |
53
+ |---|---|
54
+ | `npx dt web` | starts the server on a free loopback port, prints the sign-in URL, opens your browser |
55
+ | `npx dt web --no-open` | the same, but opens nothing. Only the URL is printed |
56
+ | `npx dt web --port 4870` | listens on 4870, or refuses to start if it is taken. `DT_WEB_PORT` does the same. The default is `0`, a free port |
57
+ | `npx dt web --idle 30` | stops after 30 minutes with no page connected. `0` never stops for idleness. The default is 30 |
58
+ | `npx dt web --owner-pid 4242` | stops within 5 seconds of process 4242 exiting |
59
+ | `npx dt web url` | prints a fresh sign-in URL for the running server |
60
+ | `npx dt web status` | `running at http://127.0.0.1:<port> · pid <n> · since <time>` and exit 0, or `not running` and exit 1. It writes nothing |
61
+ | `npx dt web stop` | stops the running server |
62
+
63
+ One server serves one workspace. `npx dt doctor` shows a `web` row saying whether a server is running.
64
+
65
+ For an agent session: the `serving-the-web-ui` skill, shipped in this package, says how a session starts
66
+ `dt web`, reads the first stdout line, and hands the URL to whatever shows it. `dt web` itself promises
67
+ only what is above: `--no-open`, `--owner-pid`, and the URL as the first line.
68
+
69
+ ## what you see
70
+
71
+ - **The nav** on the left, built from the same model as the extension's tree: module folders, collections
72
+ with their record counts, saved views, and a Layers folder for the workspaces under `git_modules/`. It
73
+ follows the workspace's `.vscode/settings.json` (`dreamteamer.navGroups`, `navOrder`, `navHidden`,
74
+ `layersFolder`).
75
+ - **The pages** are the extension's own: lists, boards, calendars, record pages, and the schema and
76
+ activity pages.
77
+ - **Live updates.** A record written by `dt set`, by an agent or by a `git pull` appears within a second.
78
+ So does a `dt compile` run while the server is up.
79
+ - **Light or dark** follows your system's setting.
80
+
81
+ Not drawn in the browser: the gestures that belong to the editor, such as New Module…, New Skill… and
82
+ running an editor command.
83
+
84
+ ## security
85
+
86
+ ### what it protects against, and what it does not
87
+
88
+ | | |
89
+ |---|---|
90
+ | **protects against** | web pages on other origins, including DNS names rebound to `127.0.0.1` (Host and Origin checks); a page on another site making your browser read or write here (`Sec-Fetch-Site: cross-site` is refused, and a write needs an `Origin` naming this server or a token); a hostile image, SVG or `.html` file a record points at running script as you (record files come from a second, sandboxed origin); other users on the machine reading your token (it is in your per-user runtime directory, mode `0600`); a module's app — from an npm package or a workspace under `git_modules/` — using your session: it runs in a sandboxed frame on the second origin with no cookie and no network API (`connect-src 'none'`); every record it asks for, every value it is shown and every change it sends back goes through a bridge that allows only its own collections, plus the collections and the writes YOU grant it in this workspace's own `.vscode/settings.json` (`dreamteamer.moduleApps` — the same entry in your user settings or in a `.code-workspace` file grants nothing), never anything a layer's own files declare; without a grant it is read-only; it writes only in its own repository, and it cannot commit, change the schema or replace another module's or the editor's own controls |
91
+ | **does not protect against** | **Another loopback server your browser visits.** Cookies are not port-scoped, so your browser sends this session to any server on `127.0.0.1`, whatever its port; a program serving one of those ports receives it and, not being a browser, can send any `Origin` it likes. **Another process running as you**: it can read your runtime directory, as it can read your files. **A browser extension** with access to `127.0.0.1`: the cookies API reads `HttpOnly` cookies. **Where the sign-in URL is written down** before it is used or expires: a session transcript, a desktop pane's logs, your shell history. **What a module's app does inside its reach**: it can copy what it may read into the collections you let it write, and a layer's records leave the machine when you push the layer; and, as for any web page, a peer connection (WebRTC) to a server it names and your browser's DNS prefetch of a link's host, which no page policy governs. **Your grant is the workspace's**: `.vscode/settings.json` is a file git tracks, so a grant is shared with everyone who clones the workspace and can be changed by anyone who can push to it |
92
+
93
+ Found a hole? See [SECURITY.md](../../SECURITY.md). Do not open a public issue.
94
+
95
+ ### how it works
96
+
97
+ - **Loopback only.** The server binds `127.0.0.1` and has no `--host` option. There is no tunnel and no
98
+ multi-user mode. To serve the API alone on another address, use `dt serve --host … --token …` from
99
+ `@dreamteamer/http`.
100
+ - **Host and Origin.** A request's `Host` must be `127.0.0.1:<port>`, `localhost:<port>` or
101
+ `[::1]:<port>`, with this server's port. An `Origin`, when sent, must be `http://` plus one of those.
102
+ Anything else gets `403`.
103
+ - **A token per start, and a single-use sign-in.** Each start mints a new random token. It lives as long
104
+ as the server and is kept in your per-user runtime directory (on Linux `$XDG_RUNTIME_DIR/dreamteamer-web/`,
105
+ on macOS `~/Library/Application Support/dreamteamer/web/runtime/`): the directory is mode `0700`, the
106
+ file `0600`, and nothing goes into the workspace, so it never lands in a synced folder. The printed URL
107
+ carries a **single-use sign-in code**, not the token. The first visit exchanges it for a session cookie
108
+ (`HttpOnly; SameSite=Strict; Path=/`) and redirects to `/`. A used code is refused, a code not used within
109
+ 10 minutes expires, and `dt web url` mints a new one. An agent session should mint the URL right before
110
+ opening it and never write it into a chat.
111
+ - **Cookies are not port-scoped.** Your browser sends this cookie to every server on `127.0.0.1`, whatever
112
+ the port (see the table above). So a write authenticated by the cookie must also carry an `Origin`, or
113
+ `Sec-Fetch-Site: same-origin`, naming this server; a write without either needs `Authorization: Bearer
114
+ <token>`. Every modern browser sends one or the other. Any request, read or write, marked
115
+ `Sec-Fetch-Site: cross-site` is refused.
116
+ - **Headers.** Every response carries `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`,
117
+ and no `X-Powered-By`. The app page carries exactly
118
+ `Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<new per response>'; style-src 'self' 'unsafe-inline'; img-src 'self' <files origin> data: blob:; font-src 'self' data:; connect-src 'self'; frame-src <files origin>/<key>/_dt/frame/ <files origin>/<key>/w/; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`.
119
+ The only inline script is the boot script, which carries that nonce. Scripts otherwise load only from
120
+ the app's own built files; no module's code is ever served by the app origin.
121
+ - **Module apps.** A module's `ui/app.js` runs in a frame (`sandbox="allow-scripts"`, never
122
+ `allow-same-origin`) whose one document the second origin serves at `/<key>/_dt/frame/<module>/<kind>.html`:
123
+ the module's code embedded as data, a guest that loads it, and the policy `default-src 'none'; script-src
124
+ 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none';
125
+ frame-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'; worker-src 'none'` — no host, no
126
+ hash, no nonce, so no script can load from anywhere. The frame asks the page for records by message; the
127
+ page answers through the bridge under the module's scope, which a grant in this workspace's own
128
+ `.vscode/settings.json` widens:
129
+ `"dreamteamer.moduleApps": { "<module>": { "reads": ["users"], "writes": ["<one of its own collections>"] } }`.
130
+ A refusal names the collection or route, the module or root that owns it, and what the app may reach.
131
+ - **Links in record text.** A link in a markdown field is drawn as a link only for `http:`, `https:` and
132
+ `mailto:` addresses and the app's own routes; anything else, such as `javascript:`, is drawn as text.
133
+ - **Revisions are checked.** A commit named in a request (`?hash=` on a record's history, `hash` on a
134
+ revert) must be `HEAD` or 4 to 40 hexadecimal characters; anything else is `400` and never reaches git.
135
+ - **Record files on a second origin.** Images, SVGs and `.html` pages a record points at are served from a
136
+ second loopback port. That port has no cookie and no API, and the address of each file carries its own
137
+ per-start key. Every response there carries `Content-Security-Policy: sandbox` (scripts off, unique
138
+ origin), and SVG and HTML are sent with `Content-Disposition: attachment` unless they are shown inside a
139
+ sandboxed frame. A hostile file can therefore never reach your records.
140
+ - **Writes go through the engine.** A record edited in the page is validated and written exactly as
141
+ `dt set` writes it. A refusal shows the engine's own sentence beside the control.
142
+
143
+ ### served
144
+
145
+ - **The app origin** serves the UI's built files and the protocol at `/api/` over the workspace's records.
146
+ - **The files origin** also serves each module app's frame document (from `.dreamteamer/ui/`), and nothing
147
+ of a module's code by any other path.
148
+ - **The files origin** serves files inside the folders that declared variables render to (for example
149
+ `${env:FILES_FOLDER}/…` on a record), and `.html` pages inside the workspace that a record names. A path
150
+ with `..`, an absolute path, or a link pointing out of its root is refused.
151
+ - **Listing a record's folder** (the carousel) answers only for a folder inside those declared roots, and
152
+ names its files relative to the root, never by absolute path.
153
+ - **Importing a file as a record** reads it only from inside those declared roots.
154
+
155
+ Nothing else is served: no other file on disk, no `.env` value, and no credential.
156
+
157
+ ## the editor marker
158
+
159
+ `npx dt status` prints an `editor:` line from `.dreamteamer/editor.json`, which an editor writes when it
160
+ opens the workspace. Whether `dt web` should show there, and how, is **an open question**. Today the
161
+ engine reads only that one file, so showing a second editor means either sharing the file with the VS Code
162
+ extension or an engine change. The answer goes into `UPDATING.md`. Until then, `npx dt web status` is the
163
+ way to ask whether a server is running.
164
+
165
+ ## versions
166
+
167
+ | @dreamteamer/web | @dreamteamer/protocol | @dreamteamer/ui | engine (`dreamteamer`) |
168
+ |---|---|---|---|
169
+ | 0.1.x | ^0.1.0 | ^0.1.0 | `>=0.43.0`, tested on 0.43 and 0.44 |
170
+
171
+ The app is `@dreamteamer/ui`'s browser page (`dist/app/` in that package), the same UI the VS Code
172
+ extension mounts, built when `@dreamteamer/ui` is published and served as it is. It reaches the server
173
+ through the UI's HTTP transport, so this package carries no app and no host emulation of its own. Known
174
+ advisories in dependencies, and whether they reach a request, are listed in `SECURITY.md`.
175
+
176
+ ## limits
177
+
178
+ - One workspace per server, and one person per machine.
179
+ - The nav reads the workspace's `.vscode/settings.json` only. A setting kept in your VS Code user settings
180
+ is not seen.
181
+ - Browsers: current Chrome, Edge, Firefox and Safari. Live updates use server-sent events, which Safari
182
+ supports.
183
+ - Linux: live updates need Node 20 or newer (recursive file watching). A workspace with very many files can
184
+ hit the inotify watch limit (`fs.inotify.max_user_watches`).
185
+ - macOS and Linux only. Windows is untested.
186
+
187
+ ## troubleshooting
188
+
189
+ - **"Sign in with the URL `dt web` printed"**: you opened the bare address, or reused a sign-in URL. Run
190
+ `npx dt web url` and open the new one.
191
+ - **A page at `[::1]`**: `[::1]` is redirected to `127.0.0.1`, because module frames cannot name an IPv6
192
+ address as their ancestor.
193
+ - **`403`**: the address is not one this server answers. Use `127.0.0.1`, `localhost` or `[::1]` with the
194
+ printed port.
195
+ - **"port 4870 is in use"**: drop `--port` (or `DT_WEB_PORT`) to take a free port.
196
+ - **The page doesn't change when a record does**: check that the server is still running (`npx dt web
197
+ status`). On Linux, check the Node version and the inotify limit.
198
+ - **"the app is missing"**: the installed package is incomplete. Reinstall it.
package/bin/dt-web.mjs ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // dt-web <workspace root> [url|status|stop] [flags] — dt web without adding a dependency to the workspace.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { main } from '../src/cli.js';
6
+
7
+ const [rootArg, ...rest] = process.argv.slice(2);
8
+ const root = path.resolve(rootArg ?? '.');
9
+ if (!fs.existsSync(path.join(root, 'package.json'))) {
10
+ console.error(`${root} is not a dreamteamer workspace (no package.json) — npx dreamteamer init`);
11
+ process.exit(2);
12
+ }
13
+ main(root, rest).then((code) => process.exit(code), (e) => { console.error(`✖ ${e.message}`); process.exit(2); });
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
- "name": "@dreamteamer/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@dreamteamer/web",
3
+ "version": "0.1.0",
4
+ "description": "dt web: the dreamteamer UI in your browser over a workspace on your machine — loopback, a sign-in URL, live updates",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dreamteamer/dreamteamer-modules.git",
10
+ "directory": "modules/web"
11
+ },
12
+ "keywords": [
13
+ "dreamteamer",
14
+ "dreamteamer-module"
15
+ ],
16
+ "bin": {
17
+ "dt-web": "bin/dt-web.mjs"
18
+ },
19
+ "exports": {
20
+ ".": "./src/server.js",
21
+ "./extension": "./src/extension.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "README.md",
26
+ "bin",
27
+ "src",
28
+ "skills"
29
+ ],
30
+ "dependencies": {
31
+ "@dreamteamer/protocol": "^0.1.0",
32
+ "@dreamteamer/ui": "^0.1.0"
33
+ },
34
+ "dreamteamer": {
35
+ "extension": "./src/extension.js",
36
+ "title": "Web",
37
+ "description": "dt web: the dreamteamer UI in a browser over a workspace on your own machine — the extension's nav, lists, boards and record pages, served on loopback behind a single-use sign-in, with record files on a second sandboxed origin and live updates as records change on disk. Adds the dt web verb and a web row in dt doctor.",
38
+ "engine": ">=0.43.0"
39
+ }
40
+ }
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: serving-the-web-ui
3
+ description: >-
4
+ use when a person asks a session to show the workspace, open the web UI, see the records in a browser or in the Browser pane beside the chat — "show me the workspace", "open dt web", "give me the URL", "stop the web UI", "is the web UI running" — in Claude Code desktop, Codex, a terminal or a cloud session. How to start `dt web` in the background, read its sign-in URL, hand the URL to whatever shows it, and stop it.
5
+ ---
6
+
7
+ # serving-the-web-ui
8
+
9
+ `dt web` serves the dreamteamer UI over this workspace on loopback. It prints ONE thing you need: its first
10
+ stdout line, `✔ dreamteamer web at http://127.0.0.1:<port>/sign-in/<code>`. The code in that URL signs in
11
+ once and expires after 10 minutes; `dt web url` mints a fresh one from the running server.
12
+
13
+ ## 1. Is one already running?
14
+
15
+ ```bash
16
+ npx dt web status # exit 0: "running at http://127.0.0.1:<port> · pid <n> · since <time>"; exit 1: "not running"
17
+ ```
18
+
19
+ Running: go to step 3 with a fresh URL from `npx dt web url`. A second `dt web` in the same workspace refuses
20
+ to start and names the running one.
21
+
22
+ ## 2. Start it in the background
23
+
24
+ Run it as a background command, never in the foreground: it serves until it is stopped.
25
+
26
+ ```bash
27
+ npx dt web --no-open # Claude Code: CLAUDE_PID is read on its own, and the server stops when the session ends
28
+ npx dt web --no-open --owner-pid "$PPID" # Codex, or any harness without CLAUDE_PID: stops when that process exits
29
+ ```
30
+
31
+ Read the URL from the first line of the command's output. Without the package installed, the same works through
32
+ `npx -p @dreamteamer/web dt-web . --no-open`.
33
+
34
+ The server also stops after 30 minutes with no page connected (`--idle <minutes>`, `0` never).
35
+
36
+ ## 3. Hand the URL to what shows it
37
+
38
+ - **Claude Code desktop:** open it in the Browser pane beside the chat with `preview_start` and the URL as
39
+ `url`. Mint the URL right before opening it (`npx dt web url`): a code used once, or older than 10 minutes,
40
+ shows a sign-in page instead of the workspace.
41
+ - **Codex, a terminal session, or any harness without a browser pane:** give the person the URL and say it
42
+ signs in once; `npx dt web url` gives another.
43
+ - **A cloud session (Claude Code on the web, Codex cloud):** the capability is unavailable. The server listens
44
+ on the cloud machine's loopback, which the person's browser cannot reach, and `dt web` has no tunnel and no
45
+ other address by design. Say so, and suggest running it on their own machine.
46
+
47
+ Never paste the URL into a file, a commit, a record or a note: until it is used it is a key to the workspace.
48
+
49
+ ## 4. Stop it
50
+
51
+ ```bash
52
+ npx dt web stop # "stopped", exit 0 — or "not running", exit 1
53
+ ```
54
+
55
+ ## A launch entry for the Browser pane
56
+
57
+ A workspace may keep a `.claude/launch.json` entry so the pane can start the server by name. `dt web` takes
58
+ its port from `--port` (or `DT_WEB_PORT`), not from `PORT`, so give it a fixed port rather than `autoPort`:
59
+
60
+ ```json
61
+ {
62
+ "version": "0.0.1",
63
+ "configurations": [
64
+ { "name": "dreamteamer-web", "runtimeExecutable": "npx", "runtimeArgs": ["dt", "web", "--no-open", "--port", "4870"], "port": 4870 }
65
+ ]
66
+ }
67
+ ```
68
+
69
+ The pane opens `http://localhost:4870/`, which answers the sign-in page: then open a fresh URL from
70
+ `npx dt web url` (or the entry's output in `preview_logs`) in the same pane.
71
+
72
+ ## What the page reads
73
+
74
+ - The nav follows the workspace's `.vscode/settings.json` (`dreamteamer.navGroups`, `navOrder`, `navHidden`,
75
+ `layersFolder`). A setting kept in the person's VS Code user settings is not seen by the browser.
76
+ - Records change on the page as they change on disk — a `dt set`, an agent's write, a `git pull` — with no
77
+ reload.
package/src/cli.js ADDED
@@ -0,0 +1,86 @@
1
+ // `dt web` and `dt-web <root>`: start · url · status · stop (README § commands). One argv grammar for both
2
+ // entries; `main` resolves to the process exit code.
3
+ import { spawn } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { claimStart, readState, removeState, running, stateFile, writeState, alive } from './runtime.js';
7
+
8
+ // the browser page @dreamteamer/ui builds, served as it is
9
+ const appDirOf = () => path.join(path.dirname(createRequire(import.meta.url).resolve('@dreamteamer/ui/package.json')), 'dist', 'app');
10
+
11
+ export function parseArgs(argv, env = process.env) {
12
+ const out = { cmd: 'start', port: env.DT_WEB_PORT !== undefined ? Number(env.DT_WEB_PORT) : 0, open: true, idle: 30, ownerPid: undefined };
13
+ const args = [...argv];
14
+ if (['url', 'status', 'stop'].includes(args[0])) out.cmd = args.shift();
15
+ for (let i = 0; i < args.length; i++) {
16
+ const a = args[i];
17
+ const m = /^--(port|idle|owner-pid)(?:=(.*))?$/.exec(a);
18
+ if (a === '--no-open') { out.open = false; continue; }
19
+ if (!m) throw new Error(`dt web does not take "${a}" — usage: dt web [url|status|stop] [--port <n>] [--no-open] [--idle <minutes>] [--owner-pid <pid>]`);
20
+ const v = m[2] ?? args[++i];
21
+ if (v === undefined || !/^\d+$/.test(v)) throw new Error(`--${m[1]} needs a whole number`);
22
+ if (m[1] === 'port') { if (Number(v) > 65535) throw new Error('--port must be 0..65535'); out.port = Number(v); }
23
+ if (m[1] === 'idle') out.idle = Number(v);
24
+ if (m[1] === 'owner-pid') out.ownerPid = Number(v);
25
+ }
26
+ if (!Number.isInteger(out.port) || out.port < 0 || out.port > 65535) throw new Error('DT_WEB_PORT must be 0..65535');
27
+ return out;
28
+ }
29
+
30
+ export async function main(root, argv, { log = console.log, env = process.env, appDir = env.DT_WEB_APP_DIR ?? appDirOf() } = {}) {
31
+ const o = parseArgs(argv, env);
32
+ const now = running(root);
33
+ if (o.cmd === 'status') {
34
+ if (!now) { log('not running'); return 1; }
35
+ log(`running at http://127.0.0.1:${now.port} · pid ${now.pid}${now.owner ? ` · owner ${now.owner}` : ''} · since ${now.started}`);
36
+ return 0;
37
+ }
38
+ if (o.cmd === 'stop') {
39
+ if (!now) { log('not running'); return 1; }
40
+ process.kill(now.pid, 'SIGTERM');
41
+ // stopped = the server removed its state file (a process its parent has not reaped yet still answers kill 0)
42
+ const gone = () => !readState(now.file) || !alive(now.pid);
43
+ for (let i = 0; i < 50 && !gone(); i++) await new Promise((r) => setTimeout(r, 100));
44
+ log(gone() ? 'stopped' : `pid ${now.pid} did not stop`);
45
+ return gone() ? 0 : 1;
46
+ }
47
+ if (o.cmd === 'url') {
48
+ if (!now) { log('not running'); return 1; }
49
+ const r = await fetch(`http://127.0.0.1:${now.port}/_dt/sign-in`, { method: 'POST', headers: { authorization: `Bearer ${now.token}` }, signal: AbortSignal.timeout(10_000) });
50
+ if (!r.ok) { log(`the server refused: ${r.status}`); return 1; }
51
+ log((await r.json()).url);
52
+ return 0;
53
+ }
54
+ if (now) { log(`already running at http://127.0.0.1:${now.port} (pid ${now.pid}) — npx dt web url`); return 1; }
55
+ const { startWeb } = await import('./server.js');
56
+ const ownerPid = o.ownerPid ?? (env.CLAUDE_PID ? Number(env.CLAUDE_PID) : undefined);
57
+ const file = stateFile(root);
58
+ const lock = claimStart(file);
59
+ if (!lock.ok) { log(`already running or starting${lock.pid ? ` (pid ${lock.pid})` : ''} — npx dt web status`); return 1; }
60
+ let web;
61
+ try {
62
+ web = await startWeb({ root, appDir, port: o.port, idleMinutes: o.idle, ownerPid, onStop: () => { removeState(file); lock.release(); } });
63
+ } catch (e) {
64
+ lock.release();
65
+ throw e;
66
+ }
67
+ writeState(file, { pid: process.pid, port: web.appPort, filesPort: web.filesPort, started: new Date().toISOString(), owner: ownerPid ?? null, token: web.token });
68
+ // the handlers before the first line: a caller may signal the moment it reads the URL
69
+ const stop = () => web.close('signal');
70
+ process.once('SIGINT', stop);
71
+ process.once('SIGTERM', stop);
72
+ const url = web.signInUrl();
73
+ log(`✔ dreamteamer web at ${url}`);
74
+ if (o.open) openBrowser(url);
75
+ await web.closed;
76
+ removeState(file);
77
+ lock.release();
78
+ return 0;
79
+ }
80
+
81
+ function openBrowser(url) {
82
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
83
+ try { spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* print-only */ }
84
+ }
85
+
86
+ export { readState };
@@ -0,0 +1,21 @@
1
+ // The engine extension: `dt web [url|status|stop] [flags]`, and a `web` row in `dt doctor`.
2
+ import { main, parseArgs } from './cli.js';
3
+ import { running } from './runtime.js';
4
+
5
+ export const apiVersion = 1;
6
+ const USAGE = ' web [url|status|stop] [--port <n>] [--no-open] [--idle <minutes>] [--owner-pid <pid>] the UI in your browser over this workspace (loopback; prints a sign-in URL)';
7
+
8
+ export default function activate() {
9
+ return {
10
+ commands: {
11
+ web: {
12
+ usage: USAGE,
13
+ async run(ws, argv) { parseArgs(argv); return main(ws.root, argv); },
14
+ },
15
+ },
16
+ doctor({ root }) {
17
+ const r = running(root);
18
+ return [{ label: 'web', state: 'ok', detail: r ? `running at http://127.0.0.1:${r.port} · pid ${r.pid}` : 'not running' }];
19
+ },
20
+ };
21
+ }
package/src/headers.js ADDED
@@ -0,0 +1,24 @@
1
+ // The headers every response carries, and the app page's exact Content-Security-Policy (README § security).
2
+ export function baseHeaders(res) {
3
+ res.setHeader('Referrer-Policy', 'no-referrer');
4
+ res.setHeader('X-Content-Type-Options', 'nosniff');
5
+ res.removeHeader('X-Powered-By');
6
+ }
7
+
8
+ export function appCsp(nonce, filesOrigin, filesKey) {
9
+ return [
10
+ "default-src 'self'",
11
+ `script-src 'self' 'nonce-${nonce}'`,
12
+ "style-src 'self' 'unsafe-inline'",
13
+ `img-src 'self' ${filesOrigin} data: blob:`,
14
+ "font-src 'self' data:",
15
+ "connect-src 'self'",
16
+ filesKey ? `frame-src ${filesOrigin}/${filesKey}/_dt/frame/ ${filesOrigin}/${filesKey}/w/` : `frame-src ${filesOrigin}`,
17
+ "frame-ancestors 'none'",
18
+ "base-uri 'none'",
19
+ "form-action 'none'",
20
+ ].join('; ');
21
+ }
22
+
23
+ /** A record file on the files origin: script off, a unique origin. */
24
+ export const FILE_CSP = 'sandbox';
@@ -0,0 +1,42 @@
1
+ // cr-modules-008 task 6: the files origin serves each module surface as ONE self-contained frame document —
2
+ // the module's app.js read once and embedded as data, the guest (Vue + the guest runtime) from @dreamteamer/ui,
3
+ // the frame policy with no host, hash or nonce. Nothing in the frame is fetched afterwards.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import { buildFrameDocument, FRAME_POLICY } from '@dreamteamer/protocol/frame-document';
8
+
9
+ export const FRAME_KINDS = new Set(['registry', 'app', 'list', 'edit', 'view', 'panel']);
10
+ const NAME = /^[a-z0-9@][a-z0-9._-]*$/i;
11
+
12
+ /** The guest @dreamteamer/ui builds (dist/guest/guest.js + guest.css), or null when that package is absent. */
13
+ export function resolveGuest(from) {
14
+ try {
15
+ const req = createRequire(path.join(from, 'package.json'));
16
+ const js = req.resolve('@dreamteamer/ui/dist/guest/guest.js');
17
+ const css = path.join(path.dirname(js), 'guest.css');
18
+ return { guestText: fs.readFileSync(js, 'utf8'), styleText: fs.existsSync(css) ? fs.readFileSync(css, 'utf8') : '' };
19
+ } catch { return null; }
20
+ }
21
+
22
+ /**
23
+ * A frame route for startWeb's addFrameRoute: segments after /_dt/frame/ are [module, "<kind>.html"].
24
+ * @param {{ root: string, appPort: () => number, guest: {guestText: string, styleText?: string} | null }} o
25
+ */
26
+ export function moduleFrameRoute({ root, appPort, guest }) {
27
+ return (segments) => {
28
+ if (segments.length !== 2) return null;
29
+ const [module, file] = segments;
30
+ const kind = file.endsWith('.html') ? file.slice(0, -5) : '';
31
+ if (!NAME.test(module) || !FRAME_KINDS.has(kind)) return null;
32
+ const bundleFile = path.join(root, '.dreamteamer', 'ui', module, 'app.js');
33
+ if (!fs.existsSync(bundleFile)) return null;
34
+ if (!guest) return { status: 503, body: 'module apps need the frame guest from @dreamteamer/ui, which is not installed — reinstall @dreamteamer/web' };
35
+ const { html } = buildFrameDocument({ guestText: guest.guestText, styleText: guest.styleText ?? '', bundleText: fs.readFileSync(bundleFile, 'utf8') });
36
+ const p = appPort();
37
+ return {
38
+ body: html, type: 'text/html; charset=utf-8',
39
+ headers: { 'Content-Security-Policy': `sandbox allow-scripts; ${FRAME_POLICY}; frame-ancestors http://127.0.0.1:${p} http://localhost:${p}`, 'Cache-Control': 'no-store' },
40
+ };
41
+ };
42
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,82 @@
1
+ // Where a running server keeps its state and its token: the per-user runtime directory, never the
2
+ // workspace (README: "nothing goes into the workspace, so it never lands in a synced folder"). One file per
3
+ // workspace, named by a hash of the workspace's real path; the directory is 0700 and the file 0600.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ /** The per-user runtime directory: $DT_WEB_RUNTIME_DIR, else $XDG_RUNTIME_DIR/dreamteamer-web on Linux,
10
+ * ~/Library/Application Support/dreamteamer/web/runtime on macOS, ~/.local/share/dreamteamer/web/runtime otherwise. */
11
+ export function runtimeDir(env = process.env, platform = process.platform, home = os.homedir()) {
12
+ if (env.DT_WEB_RUNTIME_DIR) return env.DT_WEB_RUNTIME_DIR;
13
+ if (platform === 'linux' && env.XDG_RUNTIME_DIR) return path.join(env.XDG_RUNTIME_DIR, 'dreamteamer-web');
14
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'dreamteamer', 'web', 'runtime');
15
+ return path.join(home, '.local', 'share', 'dreamteamer', 'web', 'runtime');
16
+ }
17
+
18
+ /** The state file of one workspace's server. */
19
+ export function stateFile(root, dir = runtimeDir()) {
20
+ const key = crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 16);
21
+ return path.join(dir, `${key}.json`);
22
+ }
23
+
24
+ export function writeState(file, state) {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
26
+ fs.chmodSync(path.dirname(file), 0o700);
27
+ const tmp = `${file}.${process.pid}.tmp`;
28
+ fs.writeFileSync(tmp, JSON.stringify(state, null, '\t') + '\n', { mode: 0o600 });
29
+ fs.renameSync(tmp, file);
30
+ }
31
+
32
+ export function readState(file) {
33
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
34
+ }
35
+
36
+ export function removeState(file, pid = process.pid) {
37
+ const s = readState(file);
38
+ if (s && s.pid === pid) fs.rmSync(file, { force: true });
39
+ }
40
+
41
+ export function alive(pid) {
42
+ if (!Number.isInteger(pid) || pid <= 0) return false;
43
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
44
+ }
45
+
46
+ /** The running server of a workspace, or null — a state file whose process is gone is removed. */
47
+ export function running(root) {
48
+ const file = stateFile(root);
49
+ const s = readState(file);
50
+ if (!s) return null;
51
+ if (!alive(s.pid)) { fs.rmSync(file, { force: true }); return null; }
52
+ return { ...s, file };
53
+ }
54
+
55
+ /**
56
+ * Claim the right to serve this workspace: a lock file created exclusively beside the state file, holding
57
+ * this pid for the server's whole life. Two starts at the same moment cannot both pass the "already
58
+ * running" check — only one creates the lock. A lock whose process is gone is taken over by one starter
59
+ * only: the takeover itself is an exclusive file, so two starters after a crash cannot both clear it.
60
+ */
61
+ export function claimStart(file, pid = process.pid) {
62
+ const lock = `${file}.lock`;
63
+ const take = `${lock}.takeover`;
64
+ const holder = (f) => { try { return Number(fs.readFileSync(f, 'utf8')); } catch { return null; } };
65
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
66
+ for (let attempt = 0; attempt < 2; attempt++) {
67
+ try {
68
+ fs.writeFileSync(lock, String(pid), { flag: 'wx', mode: 0o600 });
69
+ let released = false;
70
+ return { ok: true, release: () => { if (released) return; released = true; if (holder(lock) === pid) fs.rmSync(lock, { force: true }); } };
71
+ } catch (e) {
72
+ if (e.code !== 'EEXIST') throw e;
73
+ const held = holder(lock);
74
+ if (held !== null && alive(held)) return { ok: false, pid: held };
75
+ const taker = holder(take);
76
+ if (taker !== null && !alive(taker)) fs.rmSync(take, { force: true });
77
+ try { fs.writeFileSync(take, String(pid), { flag: 'wx', mode: 0o600 }); } catch { return { ok: false, pid: held }; }
78
+ try { if (holder(lock) === held) fs.rmSync(lock, { force: true }); } finally { fs.rmSync(take, { force: true }); }
79
+ }
80
+ }
81
+ return { ok: false, pid: null };
82
+ }
package/src/server.js ADDED
@@ -0,0 +1,306 @@
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.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import path from 'node:path';
8
+ import { createHandler } from '@dreamteamer/protocol/handler';
9
+ import { engine } from '@dreamteamer/protocol/host/engine';
10
+ import { envRoots } from '@dreamteamer/protocol/host/folder-images';
11
+ import { changeScope, invalidateMessage } from '@dreamteamer/protocol/host/changes';
12
+ import { appCsp, baseHeaders, FILE_CSP } from './headers.js';
13
+ import { readModuleGrantResult } from '@dreamteamer/protocol/module-grant';
14
+ import { moduleFrameRoute, resolveGuest } from './module-frames.js';
15
+
16
+ const SIGN_IN_TTL_MS = 10 * 60_000;
17
+ const DEBOUNCE_MS = 150;
18
+ const random = (n = 24) => crypto.randomBytes(n).toString('base64url');
19
+ 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' };
21
+
22
+ /** `file` under `root` after resolving every link, or null — `..`, absolute paths and links out are refused. */
23
+ export function contained(root, rel) {
24
+ if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
25
+ let realRoot, real;
26
+ try { realRoot = fs.realpathSync(root); real = fs.realpathSync(path.join(root, rel)); } catch { return null; }
27
+ return real === realRoot || real.startsWith(realRoot + path.sep) ? real : null;
28
+ }
29
+
30
+ const hostNames = (port) => new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
31
+
32
+ /**
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 }`.
35
+ */
36
+ export async function startWeb({ root, appDir, port = 0, idleMinutes = 30, ownerPid, onStop, signInTtlMs = SIGN_IN_TTL_MS, moduleGuest } = {}) {
37
+ if (!fs.existsSync(path.join(appDir, 'index.html'))) {
38
+ throw new Error(`the app is missing: ${path.join(appDir, 'index.html')} — @dreamteamer/ui is installed without its build (see the README)`);
39
+ }
40
+ const handler = await createHandler({ root });
41
+ const token = random(32);
42
+ 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;
47
+
48
+ // ---- the files origin --------------------------------------------------------------------
49
+ const varRoots = () => { try { const e = engine(); return envRoots(e.declaredVars ?? [], e.renderEnv); } catch { return []; } };
50
+ const fileBases = () => {
51
+ const out = [];
52
+ try {
53
+ const { isBinary, storageOf } = engine().descriptor;
54
+ for (const d of handler.api.descriptors().values()) if (isBinary(d) && storageOf(d).path) out.push({ collection: String(d.name), path: storageOf(d).path, suffix: String(storageOf(d).suffix ?? '') });
55
+ } catch { /* no binary collections */ }
56
+ return out;
57
+ };
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);
71
+ 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
+ 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
+ }
84
+ let file = null;
85
+ if (kind === 'v') {
86
+ const r = varRoots().find((x) => x.name === rest[0]);
87
+ if (r) file = contained(r.path, rest.slice(1).join('/'));
88
+ } else if (kind === 'w') {
89
+ const rel = rest.join('/');
90
+ const allowed = rel.endsWith('.html') && !rel.split('/').some((s) => s.startsWith('.') || s === 'node_modules')
91
+ || fileBases().some((b) => rel.startsWith(b.path.replace(/\\/g, '/') + '/'));
92
+ if (allowed) file = contained(root, rel);
93
+ }
94
+ if (!file || !fs.statSync(file).isFile()) return send(res, 404, 'not found');
95
+ const ext = path.extname(file).toLowerCase();
96
+ const headers = {};
97
+ 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);
99
+ };
100
+
101
+
102
+ // ---- the app origin ----------------------------------------------------------------------
103
+ 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');
117
+ return `<script nonce="${nonce}">Object.assign(window, ${json});</script>`;
118
+ };
119
+
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;
125
+ };
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');
139
+ 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();
146
+ }
147
+
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();
158
+ }
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() });
162
+ }
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.');
167
+ }
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>' });
172
+ }
173
+
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));
177
+ 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');
184
+ }
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);
189
+ 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` }); }
199
+ }
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; }
208
+ }
209
+ return send(res, 404, 'not found');
210
+ };
211
+
212
+
213
+ // ---- live updates ------------------------------------------------------------------------
214
+ let pending = null;
215
+ 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
+ const changed = (file) => {
221
+ const scope = changeScope(root, file);
222
+ pending = pending === 'schema' || scope === 'schema' ? 'schema' : 'data';
223
+ clearTimeout(timer);
224
+ timer = setTimeout(() => { const s = pending; pending = null; broadcast(s); }, DEBOUNCE_MS);
225
+ };
226
+ const watchers = [];
227
+ const watchDir = (dir) => {
228
+ 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 */ }
230
+ };
231
+ for (const d of ['data', 'state', '.dreamteamer']) watchDir(path.join(root, d));
232
+ try { for (const l of fs.readdirSync(path.join(root, 'git_modules'))) watchDir(path.join(root, 'git_modules', l, 'data')); } catch { /* no layers */ }
233
+
234
+ // ---- lifetime ----------------------------------------------------------------------------
235
+ let stopped = false;
236
+ let resolveClosed;
237
+ const closed = new Promise((r) => { resolveClosed = r; });
238
+ let v6 = null;
239
+ const close = async (why = 'stopped') => {
240
+ try { v6?.close(); } catch { /* not listening */ }
241
+ if (stopped) return closed;
242
+ stopped = true;
243
+ clearTimeout(timer); idle.clear(); clearInterval(ownerTimer);
244
+ 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?.();
248
+ await done;
249
+ onStop?.(why);
250
+ resolveClosed(why);
251
+ return closed;
252
+ };
253
+ const idle = {
254
+ t: null,
255
+ clear() { clearTimeout(this.t); this.t = null; },
256
+ reset() {
257
+ this.clear();
258
+ if (idleMinutes > 0 && clients.size === 0) this.t = setTimeout(() => close('idle'), idleMinutes * 60_000);
259
+ },
260
+ };
261
+ const ownerTimer = ownerPid ? setInterval(() => { try { process.kill(ownerPid, 0); } catch (e) { if (e.code !== 'EPERM') close('owner gone'); } }, 5_000) : null;
262
+ ownerTimer?.unref?.();
263
+
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);
266
+ // [::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(); });
268
+ v6.on('error', () => { /* no IPv6 loopback here: nothing to redirect */ });
269
+ v6.listen(appPort, '::1');
270
+ filesPort = await listen(files, 0);
271
+ 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
+ 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),
286
+ close,
287
+ closed,
288
+ };
289
+ }
290
+
291
+ function send(res, status, text) {
292
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
293
+ res.end(text);
294
+ }
295
+ function json(res, status, body) {
296
+ res.writeHead(status, { 'Content-Type': 'application/json' });
297
+ res.end(JSON.stringify(body));
298
+ }
299
+ function page(res, status, html) {
300
+ 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>`);
302
+ }
303
+ function sendFile(res, body, type, headers = {}) {
304
+ res.writeHead(200, { 'Content-Type': type, ...headers });
305
+ res.end(body);
306
+ }