geneseed 4.17.0 → 4.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/SHIPPED.md +1 -1
  3. package/docs/extending.md +1 -1
  4. package/docs/guides/upgrade.md +17 -1
  5. package/docs/web-ui.md +17 -3
  6. package/harness.config.json +1 -1
  7. package/js/README.md +1 -1
  8. package/js/web/actions.mjs +61 -16
  9. package/js/web/api.mjs +34 -9
  10. package/js/web/catalog.mjs +3 -0
  11. package/js/web/handler.mjs +102 -39
  12. package/js/web/jobs.mjs +70 -13
  13. package/js/web/server.mjs +6 -0
  14. package/js/web/update-check.mjs +106 -10
  15. package/js/web/user-files.mjs +24 -7
  16. package/package.json +1 -1
  17. package/web/dist/assets/{Activity-DMgUkz1y.js → Activity-BvbzB-H1.js} +1 -1
  18. package/web/dist/assets/{ActivityDetail-0NQav1Ct.js → ActivityDetail-CSwA3UmY.js} +1 -1
  19. package/web/dist/assets/{Dashboard-BrtpmSc7.js → Dashboard-DAN3juLM.js} +2 -2
  20. package/web/dist/assets/{Docs-BgSOOv3c.js → Docs-BQ2pEV8F.js} +1 -1
  21. package/web/dist/assets/{GreenhouseView-BCpp3_J7.js → GreenhouseView-BoJyzeuf.js} +1 -1
  22. package/web/dist/assets/{Installs-CJuwb-R_.js → Installs-3fAay1NE.js} +3 -3
  23. package/web/dist/assets/{JournalView-DoSufCu0.js → JournalView-Ck5c_3Md.js} +1 -1
  24. package/web/dist/assets/{KitApply-Csb2NKOJ.js → KitApply-CsnEjQi6.js} +1 -1
  25. package/web/dist/assets/{Kits-Dtq7aDW7.js → Kits-Bos4VAWe.js} +1 -1
  26. package/web/dist/assets/{Laws-CV584w6U.js → Laws-r1vX_zU5.js} +1 -1
  27. package/web/dist/assets/{Library-DfXSs4so.js → Library-DmFLcNYl.js} +1 -1
  28. package/web/dist/assets/{Loops-_D1DPJVK.js → Loops-DhRt563u.js} +1 -1
  29. package/web/dist/assets/{Markdown-zaXXXHz9.js → Markdown-unTJNwOC.js} +3 -3
  30. package/web/dist/assets/{OperatorHudView-Dh5gW8Bt.js → OperatorHudView-DIhkL4qf.js} +1 -1
  31. package/web/dist/assets/Personal-BxtZyYl_.js +1 -0
  32. package/web/dist/assets/{Tabs-CjSgf4wz.js → Tabs-CFVpjPm7.js} +1 -1
  33. package/web/dist/assets/{drawable-DlKtGexn.js → drawable-D2dpNTE8.js} +1 -1
  34. package/web/dist/assets/index-D-cBvfU2.js +2 -0
  35. package/web/dist/index.html +1 -1
  36. package/web/dist/assets/Personal-Bk6kfmgc.js +0 -1
  37. package/web/dist/assets/index-DbwBkTjy.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,26 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.17.2] — 2026-10-24
12
+
13
+ Patch: the web console closes its security and data-loss holes from the 2026-10-24 audit.
14
+
15
+ ### Security
16
+ - **Restore acts only on what the diff offers.** `POST /api/actions/restore` trusted the client's file list: one call could reset `user-rules.md`, `PROFILE.md` or a memory file to its seed, or delete a file the diff never listed. It now restores only the paths `/api/diff` offers, computed on the server. It never touches the files written once and then left to you (`user-rules.md`, `PROFILE.md`, `memory/`, `notebook/`). An added file is moved to `archived-restore/<stamp>/` in the install instead of being deleted.
17
+ - **The web console cannot be framed or driven from another site.** Every response carries a Content-Security-Policy with `frame-ancestors 'none'` and a per-response nonce on the token script, plus `X-Frame-Options: DENY`, `nosniff` and `Referrer-Policy: no-referrer`. One request guard checks the exact Host and port, refuses a cross-site `/api` request (`Sec-Fetch-Site`, foreign `Origin`), and compares the token in constant time. A repeated `GET /api/doctor?fresh=1` reuses a run less than 3 s old. The server sets request and header timeouts.
18
+ - **The markdown sanitizer drops SVG animation elements, `template`, raw-text elements and `style` attributes.** These closed a `javascript:` link set through `<set>`/`<animate>`, a full-page overlay drawn with `style`, and a mutation XSS through `xmp`, `noembed`, `noframes`, `noscript` or `plaintext`.
19
+
20
+ ### Fixed
21
+ - **Settings › Maintenance no longer offers an Uninstall.** It ran `geneseed uninstall --yes` with no target, so it always removed the OpenCode global install, whichever install you were viewing. Each install keeps its own Uninstall in Installs › Hosts.
22
+ - **Editing a rule keeps its scope.** A rule written `scope: global` was saved back as `project` on every Edit or Graduate. A scope the editor does not list is now kept as written. A rule body with a line that reads as a rule heading (`## …`) is refused with a 400, because it could create a second rule with a forged id. So is a `|` in the scope or source.
23
+
24
+ ## [4.17.1] — 2026-10-23
25
+
26
+ Patch: the web console's Update button works on a global npm install and re-checks for updates while the page is open.
27
+
28
+ ### Changed
29
+ - **The web console's Update button follows the install.** On a clone it runs `upgrade`, as before. On a global npm install it now runs `npm install -g geneseed@latest` and then the new package's `rebuild-all`, where it used to fail with "not a git checkout". On any other copy (an npx cache, a project's `node_modules`, an unzipped folder) it shows the command to run instead of a button. A clone also says whether there is anything to pull ("3 commits to pull", "up to date") from a daily background `git fetch`. An Overview page left open asks again every hour and when its tab comes back into view, so it shows a new release within a day.
30
+
11
31
  ## [4.17.0] — 2026-10-23
12
32
 
13
33
  Minor: doctrine comms 5 Mark the Ask; a version bump on main now releases itself; two Bob fixes.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.17.0**.
23
+ > string. Current label: **4.17.2**.
24
24
 
25
25
  ## Capabilities
26
26
 
package/docs/extending.md CHANGED
@@ -125,7 +125,7 @@ Notes that matter:
125
125
 
126
126
  ### The gates CI does not run
127
127
 
128
- - **The mutation matrix itself.** `tests/mutate.mjs` — 35 rows, each a one-string edit to a
128
+ - **The mutation matrix itself.** `tests/mutate.mjs` — 34 rows, each a one-string edit to a
129
129
  product file with a written argument for which gate should catch it. The full run is **in no
130
130
  workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
131
131
  Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
@@ -34,7 +34,23 @@ geneseed upgrade imperial # the same, switching the theme
34
34
 
35
35
  A dirty working tree, or a folder that is not a git clone, is reported rather than force-updated. `update` and `sync-self` are aliases of `upgrade`. `bootstrap` does the same and then opens the setup wizard (`--no-setup` skips the wizard).
36
36
 
37
- In the web console, the **Update** button on the Overview runs `upgrade`. On a clone it is always there, also when the release check cannot reach npm, because `main` moves between releases. When the update is done, the console restarts and the page reloads. If you ran `git pull` yourself, the Overview shows a banner that the console runs old code. Click **Rebuild** on an install that is behind: the console restarts after the rebuild and reloads the page. If no install is behind, click **Restart** in the banner.
37
+ ## The Update button in the web console
38
+
39
+ The **Update** button on the Overview picks its route from how this copy of Geneseed is installed:
40
+
41
+ | Install | What **Update** runs | When the button shows |
42
+ |---|---|---|
43
+ | A clone (or a worktree) | `geneseed upgrade` | Always, because `main` moves between releases. |
44
+ | A global npm install (`npm root -g`/geneseed) | `npm install -g geneseed@latest`, then `geneseed rebuild-all` from the new package | When npm has a newer release. |
45
+ | Anything else (an npx cache, a project's `node_modules`, an unzipped copy) | Nothing. The Overview shows the command to run instead. | Never. |
46
+
47
+ On a clone, the Overview also says whether there is anything to pull. Once a day the console runs `git fetch` in the background and counts the commits upstream has that the clone does not: "3 commits to pull", or "up to date". The fetch has the same timeout as `upgrade` (`GENESEED_NET_TIMEOUT`, 120 s by default). If the fetch fails, the Overview shows the release check instead, and the button stays. `GENESEED_NO_UPDATE_CHECK` turns off both the npm check and the fetch.
48
+
49
+ The console asks npm for the latest release once a day. An Overview page that stays open asks the console again every hour, and each time you come back to its tab, so it shows a new release within a day.
50
+
51
+ The npm route never uses `sudo` and never retries. If npm refuses (no permission on the global folder, a proxy, a private registry), the job log shows npm's own output. Fix the cause, or run the two commands yourself.
52
+
53
+ When the update is done, the console restarts and the page reloads. If you ran `git pull` yourself, the Overview shows a banner that the console runs old code. Click **Rebuild** on an install that is behind: the console restarts after the rebuild and reloads the page. If no install is behind, click **Restart** in the banner.
38
54
 
39
55
  ## What an upgrade keeps
40
56
 
package/docs/web-ui.md CHANGED
@@ -44,6 +44,15 @@ so other sites can't trigger builds, deletes, or shutdown. Mutating actions (bui
44
44
  update, export, restore, memory-delete, uninstall) run in the background and report back
45
45
  as toasts (fire-and-notify). Read-only browsing needs no token.
46
46
 
47
+ Every request passes one guard first: the `Host` must be `127.0.0.1`, `localhost` or `[::1]`
48
+ **at the server's own port** (DNS rebinding, other local servers), `/api/*` refuses a
49
+ `Sec-Fetch-Site` of `cross-site`/`same-site` or a foreign `Origin` (so another site cannot read
50
+ the API either), and every write compares the token in constant time. Every response carries
51
+ `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and a
52
+ strict CSP (`frame-ancestors 'none'`, same-origin only, the one inline token script admitted by
53
+ a per-response nonce). `?fresh=1` on the doctor re-runs at most once per 3 s, and the server
54
+ drops a request whose headers take over 10 s or whose whole request takes over 30 s.
55
+
47
56
  ## The rail
48
57
 
49
58
  The left rail mirrors the harness's own shape:
@@ -89,7 +98,9 @@ streams the output of background jobs and keeps their history across reloads.
89
98
 
90
99
  - **Rules** (`#/rules`) — your own standing rules, read from and written to the
91
100
  `user-rules.md` beside the deployed AGENT.md (the seed-once file no update ever
92
- touches). List with scope (user/project) and trial chips, add/edit/retire forms, a
101
+ touches). List with scope (user/project, or any other scope written by hand, which a
102
+ save keeps as written) and trial chips, add/edit/retire forms (a body line starting
103
+ `## ` is refused: the file would read it as a new rule heading), a
93
104
  **Graduate** action that adopts a trial rule for good, and a budget meter that turns
94
105
  amber as the always-loaded set gets heavy. Every write carries the fingerprint of the
95
106
  content the page last read — if an agent session edited the file meanwhile, the save
@@ -169,6 +180,9 @@ streams the output of background jobs and keeps their history across reloads.
169
180
  - **Changes** (`#/diff`) — the diff between the deployed harness and the source: edited /
170
181
  added / missing files with per-file expansion and coloured unified-diff lines. Select
171
182
  files to **export an `improvements.md`** back-port, or **restore** them to source.
183
+ Restore acts only on files the diff lists, and never on your own files (`user-rules.md`,
184
+ `PROFILE.md`, `memory/`, `notebook/`). An added file is moved into
185
+ `archived-restore/<date-time>/` in the install, not deleted.
172
186
  - **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
173
187
  theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
174
188
  the server's last run — warmed at start, redone after a job, an install toggle or a view
@@ -207,8 +221,8 @@ streams the output of background jobs and keeps their history across reloads.
207
221
  console: Cultivar, Operator, Matrix or Atlas) with a **Dashboard layout** control (Auto follows each theme's designed Status
208
222
  lens; Cultivar / Greenhouse / Operator / Journal force one regardless of skin), the
209
223
  install snapshot (deployed/target/theme/version/memory store), a Maintenance card (PATH
210
- link/unlink, uninstall), an offline package download, server controls (Stop), and the
211
- **About** colophon — project + creator credits and the source link, folded in from the
224
+ link/unlink and Update; each install is uninstalled from its own panel in Hosts), an
225
+ offline package download, server controls (Stop), and the **About** colophon — project + creator credits and the source link, folded in from the
212
226
  retired `#/about` tab (which still resolves here). Build, update, and MCP wiring live in
213
227
  the Harness tab.
214
228
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.17.0"
5
+ "version": "4.17.2"
6
6
  }
package/js/README.md CHANGED
@@ -235,7 +235,7 @@ the deployed harness as JSON.
235
235
  |---|---|
236
236
  | `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
237
237
  | `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
238
- | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards and body cap (before the body is read), static SPA fallback |
238
+ | `handler.mjs` | One function per request: dispatch, gzip, `requestGuard` (Host+port, Origin/Sec-Fetch-Site on `/api`, constant-time CSRF token) and body cap (before the body is read), `securityHeaders` (CSP nonce, framing), static SPA fallback |
239
239
  | `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
240
240
  | `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
241
241
  | `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
@@ -27,7 +27,8 @@
27
27
  */
28
28
  import { spawn } from 'node:child_process';
29
29
  import {
30
- accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync,
30
+ accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync,
31
+ renameSync, rmSync,
31
32
  } from 'node:fs';
32
33
  import os from 'node:os';
33
34
  import path from 'node:path';
@@ -36,7 +37,8 @@ import { emitGlobalInto } from '../build/driver.mjs';
36
37
  import {
37
38
  ROOT, discoverNames, knownRuleIds, loadKits, PACK_ORDER,
38
39
  } from '../build/source.mjs';
39
- import { withStdoutSwallowed } from '../inspect/diff.mjs';
40
+ import { diffCollect, drifted, withStdoutSwallowed } from '../inspect/diff.mjs';
41
+ import { SESSION_FILES } from '../hosts/hooks-context.mjs';
40
42
  import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
43
  import { installProfile, setupBuildArgs } from '../build/generate.mjs';
42
44
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
@@ -503,13 +505,30 @@ export function globalEmitHostFor(emit) {
503
505
  return HOSTS.some((h) => h.host === host) ? host : 'opencode';
504
506
  }
505
507
 
508
+ /**
509
+ * The session files' names and their top folders (`memory/`, `notebook/`, …): seed-once, the
510
+ * user's after the first build. Derived from `SESSION_FILES`, the one list of them.
511
+ */
512
+ const SEED_FILES = new Set(SESSION_FILES.filter((r) => !r.includes('/')));
513
+ const SEED_DIRS = new Set(SESSION_FILES.filter((r) => r.includes('/')).map((r) => r.split('/')[0]));
514
+ const isSeedOnce = (rel) => SEED_FILES.has(rel) || SEED_DIRS.has(rel.split('/')[0]);
515
+
506
516
  /**
507
517
  * Restore selected drifted files from the SOURCE render.
508
518
  *
509
519
  * Source wins and local edits are discarded (the inverse, keeping them, is Export
510
- * improvements). Renders the expected copy exactly as `diffCollect` does, then per rel:
511
- * expected present -> overwrite/create the deployed copy; expected absent but deployed present
512
- * (an 'added' file) -> delete it; neither -> an error and nothing touched.
520
+ * improvements). Renders the expected copy exactly as `diffCollect` does, then per rel, BY ITS
521
+ * DIFF STATUS: edited/missing -> overwrite/create the deployed copy; added -> move it into
522
+ * `archived-restore/<stamp>/` inside the install; anything else -> an error and nothing touched.
523
+ *
524
+ * THE CLIENT'S LIST IS A SELECTION, NEVER AN AUTHORITY. Only a rel the drift list offers is
525
+ * acted on, and that list is `diffCollect` with the arguments `apiDiff` passes, so the panel and
526
+ * this verb cannot disagree. Trusting the POST made it a write-or-delete primitive over the whole
527
+ * install (audit SE-1, 2026-10): one request reset `user-rules.md` and the memory index to their
528
+ * stubs, a second unlinked a memory fact and an unrelated secret file. The seed-once files are
529
+ * refused even when a manifest claims them. An added file is ARCHIVED, never unlinked, in the
530
+ * `archived-<name>/<stamp>` shape `archiveStore` gives uninstall; nothing owns the archive, so
531
+ * the next diff does not list it.
513
532
  *
514
533
  * SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
515
534
  * structured result rather than a job id — which is why the dispatcher answers it before it
@@ -534,7 +553,11 @@ export function apiRestore(state, files) {
534
553
  const restored = [];
535
554
  const deleted = [];
536
555
  const errors = [];
556
+ let archive = null;
537
557
  const target = resolvePath(state.target);
558
+ const offered = new Map(drifted(diffCollect({
559
+ target: state.target, theme: state.theme, emit: state.emit }).files ?? [])
560
+ .map((f) => [f.rel, f.status]));
538
561
  const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
539
562
  try {
540
563
  const expected = resolvePath(path.join(tmp, 'expected'));
@@ -566,26 +589,48 @@ export function apiRestore(state, files) {
566
589
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
567
590
  const dst = resolvePath(path.join(target, rel));
568
591
  const src = resolvePath(path.join(expected, rel));
592
+ const status = offered.get(rel);
569
593
  if (!rel || !within(dst, target) || !within(src, expected)) {
570
594
  errors.push(`${rel}: outside the deployed tree`);
571
- } else if (isFile(src)) {
572
- mkdirSync(path.dirname(dst), { recursive: true });
573
- // A BYTE copy, so the restored file keeps the render's own line endings rather than
574
- // the platform's (the `writeText`/`copy2` rule, one file over).
575
- copyFileSync(src, dst);
576
- restored.push(rel);
577
- } else if (isFile(dst)) {
578
- unlinkSync(dst);
579
- deleted.push(rel);
595
+ } else if (!status || isSeedOnce(rel)) {
596
+ errors.push(`${rel}: not a drifted file restore may touch`);
597
+ } else if (status !== 'added' && !isFile(src)) {
598
+ errors.push(`${rel}: not in the source render`);
599
+ } else if (status !== 'added') {
600
+ // Caught per file, so one EISDIR/EBUSY cannot lose the report of the files before it.
601
+ try {
602
+ mkdirSync(path.dirname(dst), { recursive: true });
603
+ // A BYTE copy, so the restored file keeps the render's own line endings rather than
604
+ // the platform's (the `writeText`/`copy2` rule, one file over).
605
+ copyFileSync(src, dst);
606
+ restored.push(rel);
607
+ } catch (e) {
608
+ errors.push(`${rel}: could not restore (${e.code || e.message})`);
609
+ }
580
610
  } else {
581
- errors.push(`${rel}: not in the source render nor deployed`);
611
+ // `archiveStore`'s local `YYYYMMDD-HHMMSS` stamp, one per call; a taken name gets `-2`, …
612
+ archive ??= path.join(target, 'archived-restore', new Date().toLocaleString('sv-SE')
613
+ .replace(/\D/g, '').replace(/^(\d{8})/, '$1-'));
614
+ let dest = resolvePath(path.join(archive, rel));
615
+ for (let n = 2; existsSync(dest); n += 1) dest = resolvePath(path.join(archive, `${rel}-${n}`));
616
+ if (!within(dest, archive)) {
617
+ errors.push(`${rel}: outside the archive`);
618
+ continue;
619
+ }
620
+ try {
621
+ mkdirSync(path.dirname(dest), { recursive: true });
622
+ renameSync(dst, dest);
623
+ deleted.push(rel);
624
+ } catch (e) {
625
+ errors.push(`${rel}: could not archive (${e.code || e.message})`);
626
+ }
582
627
  }
583
628
  }
584
629
  } finally {
585
630
  rmSync(tmp, { recursive: true, force: true });
586
631
  }
587
632
  state.refresh();
588
- return { restored, deleted, errors };
633
+ return { restored, deleted, errors, archive };
589
634
  }
590
635
 
591
636
  /** (host, scope) -> the emit name that installs it. */
package/js/web/api.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
9
9
  * which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
10
10
  */
11
- import { existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { readdirSync, statSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
14
  import { CONFIG, CREDIT_RE, ROOT, SRC, THEMES, discoverNames, loadKits, makeCfg, upstreamOf } from '../build/source.mjs';
@@ -35,7 +35,7 @@ import {
35
35
  configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
36
36
  } from './catalog.mjs';
37
37
  import { stampMinute, viewCfg } from './state.mjs';
38
- import { updateStatus } from './update-check.mjs';
38
+ import { MANUAL_UPDATE, gitBehind, installKind, updateStatus } from './update-check.mjs';
39
39
 
40
40
  /**
41
41
  * `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
@@ -80,13 +80,24 @@ export function apiSetup(state) {
80
80
  };
81
81
  }
82
82
 
83
+ // Short: a user who fixes a problem and clicks Re-run must not be shown the old verdict for long.
84
+ const DOCTOR_COOLDOWN_MS = 3_000;
85
+
83
86
  /**
84
87
  * The same engine as the `doctor` verb, grouped per check — served from the state's cached run
85
88
  * (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
86
89
  * for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
90
+ *
91
+ * A fresh ask within `DOCTOR_COOLDOWN_MS` of the last one THIS PATH ran reuses that run, so a
92
+ * page looping on `?fresh=1` cannot pin the event loop. `_doctorFreshAt` is written here only: a
93
+ * verdict planted any other way still re-runs. No single-flight promise — `runDoctor` is
94
+ * synchronous, so two fresh runs cannot overlap (revisit if it ever goes async).
87
95
  */
88
96
  export function apiDoctor(state, fresh = false) {
89
- if (fresh) state.runDoctor();
97
+ if (fresh && Date.now() - (state._doctorFreshAt ?? 0) >= DOCTOR_COOLDOWN_MS) {
98
+ state.runDoctor();
99
+ state._doctorFreshAt = Date.now();
100
+ }
90
101
  const { ok, problems, checked_at } = state.doctor;
91
102
  return { ...state._doctorRun, ok, problems, checked_at };
92
103
  }
@@ -499,12 +510,26 @@ export function apiLoopsActive() {
499
510
  return { loops: activeLoops() };
500
511
  }
501
512
 
502
- /** Is a newer release on npm than the source this console serves? See update-check.mjs. */
503
- // `git`: a clone updates by `git pull` whatever npm says — main moves between releases, and a
504
- // work proxy can leave the release check unanswered — so the console always offers Update there.
505
- // `.git` is a file in a worktree, a folder in a clone; existence covers both.
506
- export const apiUpdate = (state) => ({ ...updateStatus(state, releaseLabel()),
507
- git: existsSync(path.join(ROOT, '.git')) });
513
+ /**
514
+ * Is a newer release on npm than the source this console serves, and how would this copy take
515
+ * it? See update-check.mjs.
516
+ *
517
+ * `install` picks the Update route (`git`, `npm-global`, `other`); `git` is kept beside it for a
518
+ * console bundle older than the field. `behind` is a clone's own "up to date": commits upstream
519
+ * holds that it does not, null when unknown — a clone can always pull, since `main` moves between
520
+ * releases and a work proxy can leave the release check unanswered. `command` is what to run by
521
+ * hand when the console cannot update this copy itself.
522
+ */
523
+ export function apiUpdate(state) {
524
+ const install = installKind();
525
+ return {
526
+ ...updateStatus(state, releaseLabel()),
527
+ install,
528
+ git: install === 'git',
529
+ behind: install === 'git' ? gitBehind(state, { busy: Boolean(state.jobBusy?.()) }) : null,
530
+ command: install === 'other' ? MANUAL_UPDATE : null,
531
+ };
532
+ }
508
533
 
509
534
  /** The source's release label, or null when it cannot be read. */
510
535
  function releaseLabel() {
@@ -29,6 +29,9 @@ import { loadKits } from '../build/source.mjs';
29
29
  /** A requested catalog section or item that does not exist. */
30
30
  export class NotFound extends Error {}
31
31
 
32
+ /** A request body the route refuses on its content (a 400, not a 500): the caller can fix it. */
33
+ export class BadRequest extends Error {}
34
+
32
35
  /** `??` would also swallow an explicit `null` the file declared; this does not. */
33
36
  export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
34
37
 
@@ -6,29 +6,32 @@
6
6
  * surface testable at all. Keep the arity.
7
7
  *
8
8
  * What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
9
- * content types, two Cache-Control policies, a CSRF token on POST only, and a Host guard against
10
- * DNS rebinding. No streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
9
+ * content types, two Cache-Control policies, one set of security headers (CSP with a per-response
10
+ * nonce) on every response, and one request guard (`requestGuard`) in front of every route. No
11
+ * streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
11
12
  * declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
12
13
  */
13
14
  import { resolvePath } from '../hosts/hosts.mjs';
14
15
  import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
15
16
  import { parseIntStrict, percentDecode } from '../lib/text.mjs';
16
- import { preflight } from '../maintain/update.mjs';
17
17
  import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildActionCmds, mcpTargetPaths } from './actions.mjs';
18
- import { NotFound } from './catalog.mjs';
18
+ import { BadRequest, NotFound } from './catalog.mjs';
19
19
  import { openUrl, requestRestart } from './daemon.mjs';
20
20
  import { apiDocs, apiDocsPage } from './docs.mjs';
21
- import { actionCommands } from './jobs.mjs';
21
+ import { actionCommands, updatePlan } from './jobs.mjs';
22
22
  import { POST_ROUTES, PREFIX_ROUTES, STATE_ROUTES, readJsonBody } from './routes.mjs';
23
+ import { installKind } from './update-check.mjs';
23
24
  import { isFile } from '../lib/fs.mjs';
25
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
24
26
  import { readFileSync, statSync } from 'node:fs';
25
27
  import { basename, dirname, extname, join, sep } from 'node:path';
26
28
  import { gzipSync } from 'node:zlib';
27
29
 
28
30
  /**
29
- * DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST colon, so
30
- * `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
31
- * prefix check above it is for.
31
+ * DNS-rebinding guard, HOSTNAME ONLY. `h.split(":", 1)[0]` keeps everything before the FIRST
32
+ * colon, so `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
33
+ * prefix check above it is for. The server itself no longer calls it: `requestGuard` matches the
34
+ * exact `name:port` instead, which this looser predicate cannot express.
32
35
  */
33
36
  export function localHost(host) {
34
37
  const h = (host || '').trim().toLowerCase();
@@ -36,6 +39,62 @@ export function localHost(host) {
36
39
  return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
37
40
  }
38
41
 
42
+ /**
43
+ * Sent on EVERY response, merged last so no caller's `extra` can weaken one. `frame-ancestors` +
44
+ * `X-Frame-Options` stop a foreign page from framing the console and clickjacking its buttons; the
45
+ * CSP admits the one inline script (the token) by `nonce`, so an injected `<script>` cannot run
46
+ * and read the token. `style-src 'unsafe-inline'` because the React UI sets `style=` attributes.
47
+ */
48
+ export function securityHeaders(nonce) {
49
+ return {
50
+ 'X-Frame-Options': 'DENY',
51
+ 'Content-Security-Policy': "frame-ancestors 'none'; default-src 'self'; "
52
+ + `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; `
53
+ + "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; "
54
+ + "manifest-src 'self'; base-uri 'none'; form-action 'none'",
55
+ 'X-Content-Type-Options': 'nosniff',
56
+ 'Referrer-Policy': 'no-referrer',
57
+ };
58
+ }
59
+
60
+ /**
61
+ * THE one request guard, answered before dispatch (and before a POST body is read). `null` =
62
+ * pass, else `{ code, body }`.
63
+ *
64
+ * - Host must be an allowed hostname AT THIS SOCKET'S PORT, exactly: the hostname stops DNS
65
+ * rebinding, the port stops another local server's page from posing as this origin.
66
+ * - `/api/*`: a cross-site or same-site fetch, or a foreign `Origin`, is refused even on GET —
67
+ * the reads expose paths and memories. A request with neither header (curl, Node) passes.
68
+ * - Any non-GET: the CSRF token, compared in constant time so its bytes cannot be timed out.
69
+ */
70
+ export function requestGuard(req, token) {
71
+ const host = req.headers.host || '';
72
+ // Exact `name:port`, not `localHost` + a suffix test: that pair let `[::1]x.evil:4747` and
73
+ // `localhost:1:4747` through. Lower-cased the way browsers send it.
74
+ const want = `:${req.socket.localPort}`;
75
+ const h = host.trim().toLowerCase();
76
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(h.endsWith(want) ? h.slice(0, -want.length) : '')) {
77
+ return { code: 403, body: { error: 'forbidden host' } };
78
+ }
79
+ const path = (req.url || '').split('?', 1)[0];
80
+ if (path.startsWith('/api/')) {
81
+ const site = req.headers['sec-fetch-site'];
82
+ const origin = req.headers.origin;
83
+ if (site === 'cross-site' || site === 'same-site'
84
+ || (origin !== undefined && origin !== `http://${host}`)) {
85
+ return { code: 403, body: { error: 'forbidden origin' } };
86
+ }
87
+ }
88
+ if (req.method !== 'GET') {
89
+ const got = Buffer.from(String(req.headers['x-geneseed-token'] ?? ''), 'utf-8');
90
+ const want = Buffer.from(token, 'utf-8');
91
+ if (got.length !== want.length || !timingSafeEqual(got, want)) {
92
+ return { code: 403, body: { error: 'forbidden' } };
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
39
98
  const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
40
99
  'application/manifest+json', 'application/javascript'];
41
100
  const GZIP_MIN = 1024;
@@ -74,7 +133,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
74
133
  // page stayed blank with nothing logged on either side.
75
134
  const distReal = resolvePath(dist);
76
135
 
77
- function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null) {
136
+ function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null,
137
+ nonce = randomBytes(16).toString('base64')) {
78
138
  const headers = { ...(extra || {}) };
79
139
  if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
80
140
  && (acceptEncoding || '').includes('gzip')) {
@@ -86,6 +146,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
86
146
  'Content-Type': ctype,
87
147
  'Content-Length': String(body.length),
88
148
  ...headers,
149
+ ...securityHeaders(nonce),
89
150
  });
90
151
  res.end(body);
91
152
  }
@@ -114,8 +175,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
114
175
  }
115
176
  let data = entry.data;
116
177
  let extra = null;
178
+ // Fresh per response; for index.html the same value goes in the token script and its CSP.
179
+ const nonce = randomBytes(16).toString('base64');
117
180
  if (basename(fp) === 'index.html') {
118
- const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
181
+ const inject = Buffer.from(`<script nonce="${nonce}">window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
119
182
  data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
120
183
  extra = { 'Cache-Control': 'no-store' };
121
184
  // The injected bytes differ from `entry.data`, so the gz cache must not serve them.
@@ -124,7 +187,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
124
187
  extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
125
188
  }
126
189
  return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
127
- 200, extra, acceptEncoding, entry);
190
+ 200, extra, acceptEncoding, entry, nonce);
128
191
  }
129
192
 
130
193
  function doGet(req, res, path, ae) {
@@ -156,19 +219,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
156
219
  return serveStatic(res, path, ae);
157
220
  }
158
221
 
159
- /** The Host and token guards, answered BEFORE a byte of the body is read. `true` = refused. */
160
- function refusePost(req, res, ae) {
161
- if (!localHost(req.headers.host)) {
162
- sendJson(res, { error: 'forbidden host' }, 403, ae);
163
- return true;
164
- }
165
- if (req.headers['x-geneseed-token'] !== token) {
166
- sendJson(res, { error: 'forbidden' }, 403, ae);
167
- return true;
168
- }
169
- return false;
170
- }
171
-
172
222
  function doPost(req, res, path, ae, body) {
173
223
  if (path === '/api/shutdown') {
174
224
  if (jm.busy) return sendJson(res, BUSY, 409, ae);
@@ -287,16 +337,26 @@ export function makeHandler(state, jm, token, dist, holder = null) {
287
337
  return startJob(res, 'deploy', [plan.cmd], ae);
288
338
  }
289
339
  if (action === 'update') {
290
- const pre = preflight();
291
- if (!pre.ok) {
292
- return sendJson(res,
293
- { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
340
+ // The route follows the install (`updatePlan`); a refusal is the same friendly 422.
341
+ // The daily background `git fetch` still running would hold `.git` locks the update's own
342
+ // fetch needs, and fail it as `fetch_failed`; it ends within GENESEED_NET_TIMEOUT.
343
+ if (state._behind?.inflight) {
344
+ return sendJson(res, { precondition: 'checking', kind: 'info',
345
+ message: 'The console is checking upstream right now. Try Update again in a moment.' },
346
+ 422, ae);
294
347
  }
348
+ const plan = updatePlan(installKind());
349
+ if (plan.refuse) return sendJson(res, plan.refuse, 422, ae);
295
350
  // `requestRestart` declines on a foreground server, which then shows its stale banner.
296
- const jid = jm.start('update', actionCommands('update'), (rc) => {
351
+ const jid = jm.start('update', plan.cmds, (rc) => {
297
352
  state.refresh();
298
- if (rc === 0) requestRestart(state.theme);
299
- });
353
+ if (rc === 0) {
354
+ // The old answers ("3 commits to pull") are about the code just replaced.
355
+ delete state._behind;
356
+ delete state._update;
357
+ requestRestart(state.theme);
358
+ }
359
+ }, plan.opts);
300
360
  if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
301
361
  return sendJson(res, { job_id: jid }, 202, ae);
302
362
  }
@@ -336,10 +396,13 @@ export function makeHandler(state, jm, token, dist, holder = null) {
336
396
  const ae = req.headers['accept-encoding'] || '';
337
397
  const path = (req.url || '').split('?', 1)[0];
338
398
  try {
399
+ // GUARD FIRST, BODY SECOND: a refused request is DISCARDED (`resume`, nothing buffered).
400
+ const refused = requestGuard(req, token);
401
+ if (refused) {
402
+ sendJson(res, refused.body, refused.code, ae);
403
+ return req.resume();
404
+ }
339
405
  if (req.method === 'GET') {
340
- if (!localHost(req.headers.host)) {
341
- return sendJson(res, { error: 'forbidden host' }, 403, ae);
342
- }
343
406
  try {
344
407
  return doGet(req, res, path, ae);
345
408
  } catch (e) {
@@ -353,13 +416,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
353
416
  }
354
417
  }
355
418
  if (req.method === 'POST') {
356
- // GUARDS FIRST, BODY SECOND. The Host and token checks need no body, so they answer
357
- // before one is read — a refused request is DISCARDED (`resume`, nothing buffered), never
358
- // collected. A body over `MAX_BODY` is refused on its declared length, and the socket is
359
- // closed rather than drained. Otherwise the body is buffered before routing regardless
419
+ // The guard above needed no body, so it answered before one was read. A body over
420
+ // `MAX_BODY` is refused on its declared length, and the socket is closed rather than drained. Otherwise the body is buffered before routing regardless
360
421
  // of what the route needs it for: Node's parser owns the message boundary, so unread
361
422
  // bytes are discarded when the response ends rather than mis-parsed as the next request.
362
- if (refusePost(req, res, ae)) return req.resume();
363
423
  const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
364
424
  if (length > MAX_BODY) {
365
425
  res.setHeader('Connection', 'close');
@@ -377,6 +437,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
377
437
  if (e instanceof NotFound) {
378
438
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
379
439
  }
440
+ // A body the route refused on its content (a rule body with a `## ` heading) is
441
+ // the caller's to fix: 400, not the 500 of a server fault.
442
+ if (e instanceof BadRequest) return sendJson(res, { error: e.message }, 400, ae);
380
443
  return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
381
444
  }
382
445
  });