clearotron 0.2.2 → 0.2.3

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 (118) hide show
  1. package/.env.example +28 -0
  2. package/.nvmrc +1 -1
  3. package/INSTALL.md +13 -11
  4. package/README.md +11 -16
  5. package/THIRD-PARTY-NOTICES.md +1 -1
  6. package/bin/clearotron.mjs +22 -0
  7. package/bin/example.mjs +4 -0
  8. package/bin/onboard.mjs +195 -19
  9. package/bin/start.mjs +154 -8
  10. package/build-info.json +2 -2
  11. package/demo/full-country-search/run/_driver/framework.json +1 -1
  12. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  13. package/demo/knockout-search/run/_driver/framework.json +1 -1
  14. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  15. package/docs/E2E.md +5 -5
  16. package/docs/PORTAL.md +2 -2
  17. package/docs/README.md +5 -4
  18. package/docs/SECURITY.md +17 -0
  19. package/docs/architecture/04-configuration-reference.md +2 -0
  20. package/docs/architecture/05-config-governance.md +14 -0
  21. package/docs/architecture/05-customer-profiles.md +2 -2
  22. package/docs/configuration.md +4 -3
  23. package/driver/CHANGELOG.md +56 -0
  24. package/driver/common-law-receipts.mjs +1 -1
  25. package/driver/config-staleness.mjs +1 -1
  26. package/driver/dev-portal.mjs +2 -2
  27. package/driver/driver.config.mjs +1 -1
  28. package/driver/engine/cli-version.mjs +103 -0
  29. package/driver/enqueue-schema.mjs +8 -8
  30. package/driver/feedback-store.mjs +1 -1
  31. package/driver/findings-model.mjs +52 -2
  32. package/driver/flag-snapshot.mjs +20 -0
  33. package/driver/framework.mjs +1 -1
  34. package/driver/gateway.mjs +18 -1
  35. package/driver/package.json +3 -3
  36. package/driver/party-facts.mjs +2 -2
  37. package/driver/phase0.mjs +1 -1
  38. package/driver/pipeline.mjs +5 -5
  39. package/driver/plain-register.mjs +120 -0
  40. package/driver/portal-access.mjs +1 -1
  41. package/driver/portal-config-view.mjs +40 -1
  42. package/driver/portal-families.mjs +1 -1
  43. package/driver/portal-report.mjs +1 -1
  44. package/driver/portal-service.mjs +26 -7
  45. package/driver/portal-upstream.mjs +2 -2
  46. package/driver/predelivery-lint.mjs +121 -1
  47. package/driver/profile-page.html +10 -2
  48. package/driver/profile-service.mjs +4 -4
  49. package/driver/profiles/README.md +3 -3
  50. package/driver/profiles.mjs +144 -12
  51. package/driver/publish/index.mjs +2 -2
  52. package/driver/publish/knockout.mjs +7 -1
  53. package/driver/publish/render-knockout.mjs +453 -118
  54. package/driver/publish/render.mjs +2 -2
  55. package/driver/reference-strip-signatures.mjs +20 -0
  56. package/driver/search-policy.mjs +2 -2
  57. package/driver/skills/blind-frame/SKILL.md +1 -1
  58. package/driver/skills/knockout-assess/SKILL.md +84 -0
  59. package/driver/skills/matter-frame/SKILL.md +6 -6
  60. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  61. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  62. package/driver/skills/prelim-search/SKILL.md +1 -1
  63. package/driver/skills/prelim-search/report-prose.md +82 -0
  64. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  65. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  66. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  67. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  68. package/driver/skills/prelim-variants/SKILL.md +3 -3
  69. package/driver/stages.mjs +4 -4
  70. package/driver/suite-census.json +134 -38
  71. package/driver/unit-inventory.mjs +184 -27
  72. package/driver/verify.mjs +1 -1
  73. package/driver/whatif-memo-run.mjs +26 -4
  74. package/driver/whatif-memo.mjs +19 -1
  75. package/examples/README.md +2 -2
  76. package/examples/grants.example.json +6 -7
  77. package/mcp-server/CHANGELOG.md +2 -0
  78. package/mcp-server/lib/scrub.mjs +1 -1
  79. package/mcp-server/lib/whatif.mjs +2 -2
  80. package/mcp-server/package.json +1 -1
  81. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  82. package/mcp-server/server.mjs +1 -1
  83. package/package.json +28 -10
  84. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  85. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-CEYvXdqH.js} +226 -49
  86. package/portal-ui/dist/index.html +2 -2
  87. package/portal-ui/package.json +1 -1
  88. package/providers/_shared/term-shape.mjs +1 -1
  89. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  90. package/providers/oauth-mcp-bridge/package.json +1 -1
  91. package/providers/uspto-local/src/index-store.js +2 -2
  92. package/scripts/citation-line-check.mjs +121 -2
  93. package/scripts/env-classify.mjs +67 -3
  94. package/scripts/live-surface-check.mjs +38 -1
  95. package/scripts/nightly-notice.mjs +0 -2
  96. package/scripts/preinstall-node-check.mjs +24 -0
  97. package/scripts/strip-tracker-citations.mjs +29 -6
  98. package/scripts/test-run.mjs +16 -0
  99. package/shared/anon-overlay.mjs +3 -3
  100. package/shared/customer-store.mjs +1 -1
  101. package/shared/invocation.mjs +28 -0
  102. package/shared/names-in-force.mjs +2 -0
  103. package/shared/node-floor.mjs +97 -0
  104. package/shared/staff-domain.mjs +154 -0
  105. package/shared/store-in-repo.mjs +23 -2
  106. package/skills/clearotron-ops/SKILL.md +1 -1
  107. package/driver/recipes/README.md +0 -49
  108. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  109. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  110. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  111. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  112. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  113. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  114. package/scripts/ai-page-render-check.mjs +0 -532
  115. package/scripts/clearances-render-check.mjs +0 -480
  116. package/scripts/composer-render-check.mjs +0 -1027
  117. package/scripts/home-render-check.mjs +0 -526
  118. package/scripts/portal-lifecycle-check.mjs +0 -639
package/.env.example CHANGED
@@ -508,3 +508,31 @@ CLEAROTRON_REQUESTER_WHATSAPP=
508
508
  # requester notice reaches. Read by driver/stages.mjs (whatsappRouting).
509
509
  # effect: deployment
510
510
  CLEAROTRON_WHATSAPP_OPERATOR_COPY=
511
+
512
+ # ── The two switches that reveal accounts an install deliberately does not offer ──────────────────
513
+ #
514
+ # NEITHER IS FOR AN OPERATOR. Both are set by the product's own commands, and a row here exists because
515
+ # product code reads them, not because copying this file should set them. Leave both unset. Setting
516
+ # either on a real deployment puts accounts in a picker that a clean install is meant not to have.
517
+ #
518
+ # `1` makes the profile loader return the three test-suite fixture accounts in the roster
519
+ # it resolves. They are marked `testFixture` and are refused otherwise on every install route, because a
520
+ # packaging exclusion protects only the tarball and `git clone && npm install` is a documented way in.
521
+ # Set by scripts/test-run.mjs, once, for every child — most checks that need a fixture do not call the
522
+ # loader directly, so there is no argument to pass. An explicit `includeTestFixtures` argument beats
523
+ # this, distinguished on `undefined`, so a check asking what a customer's install resolves is never
524
+ # handed the suite's answer instead. Read by driver/profiles.mjs.
525
+ # effect: harness
526
+ CLEAROTRON_TEST_FIXTURE_PROFILES=
527
+
528
+ # `1` makes the profile loader return the BUNDLED demo account (demo-brand-owner, marked `demoData`).
529
+ # A fresh install resolves `generic` alone — owner ruling 2026-09-08, so nobody has to clean demo
530
+ # material out of an environment they just made — and the demo brings its own account when it runs.
531
+ # Set by `clearotron demo`, by `clearotron start --demo`, and by scripts/test-run.mjs.
532
+ #
533
+ # The gate is on the LAYER, not the flag: a `demoData` account in a deployment's OWN configured store is
534
+ # that deployment's choice and stays offered whatever this is set to, and the admission wall still
535
+ # refuses a real clearance under it. An explicit `includeDemo` argument beats this, on the same rule.
536
+ # Read by driver/profiles.mjs.
537
+ # effect: harness
538
+ CLEAROTRON_DEMO_PROFILES=
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- 22
1
+ 22.13
package/INSTALL.md CHANGED
@@ -38,13 +38,15 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
38
38
  ```
39
39
 
40
40
  Skip this on any machine that already builds software.
41
- - **Node.js >= 22**, and npm. A hard floor: `package.json` declares it, `.nvmrc` pins it, and the free
42
- US register runs on `node:sqlite`, which ships with FTS5 from Node 22. Node 20 installs and then fails
43
- at the first US search. `nvm use` picks the pin up.
44
- - **macOS, Linux, or native Windows for the demo; WSL2 with Node 22 for a clearance.** `npx clearotron
41
+ - **Node.js >= 22.13**, and npm. A hard floor, and the minor matters: the free US register runs on
42
+ `node:sqlite`, which is not a built-in module before 22.13 measured across releases, 2026-09-08. On
43
+ Node 20, or on 22.0 through 22.12, the install succeeds and the first US search fails with
44
+ `ERR_UNKNOWN_BUILTIN_MODULE`, saying nothing about Node. `package.json` declares the floor, the
45
+ install refuses below it before writing anything, and `nvm use` picks the pin up.
46
+ - **macOS, Linux, or native Windows for the demo; WSL2 for a clearance.** `npx clearotron
45
47
  demo` runs anywhere Node does, native Windows included. A real clearance does not: the engine resolves
46
48
  the reasoning CLI the POSIX way, so a native-Windows clearance refuses at preflight even with the CLI
47
- on `PATH`. On Windows, `wsl --install`, then install Node 22 **inside** the Linux distribution and work
49
+ on `PATH`. On Windows, `wsl --install`, then install Node 22.13 or newer **inside** the Linux distribution and work
48
50
  through this page from there. A *hosted*
49
51
  deployment needs Linux for one further thing, the systemd outbox trigger —
50
52
  [driver/systemd/README.md](driver/systemd/README.md).
@@ -459,9 +461,9 @@ matches, the neutral Generic default applies.
459
461
 
460
462
  - **Bundled with the package** (`driver/profiles/`): `generic.json` (the Generic default) and
461
463
  `demo-brand-owner.json`, the account the demo runs as, so you can run and read the machinery
462
- immediately. `driver/profiles/README.md` documents every field. (A clone carries three more
463
- `aurora`, `zephyr`, `petcary` which are fixtures the test suite reads, not accounts to run
464
- clearances for. They are excluded from the published package for exactly that reason.)
464
+ immediately. `driver/profiles/README.md` documents every field. (A clone of the repository carries
465
+ three more, marked `testFixture` in their own files: the test suite reads them, no install offers
466
+ them, and they are excluded from the published package as well.)
465
467
  - **Your real customers live outside the repo.** Point `CLEAROTRON_CUSTOMERS_DIR` at your own private
466
468
  config store and the engine loads *those* accounts instead. **Same engine, different config path** —
467
469
  the code carries no customer identities.
@@ -480,8 +482,8 @@ working examples in `driver/profiles/`:
480
482
  the engine attaches to the profile it loads. One ships beside a bundled demo customer.
481
483
  - **Project overlays** — `projects/<customer-key>/<slug>.json`. A project is one engagement under a
482
484
  customer: a launch screening, a flagship clearance, a regional push. Each may carry its own
483
- `<slug>.context.md` beside it. `projects/aurora/console-ecosystem.json` and
484
- `projects/demo-brand-owner/japan-and-korea-app-launch.json` are the shipped examples.
485
+ `<slug>.context.md` beside it. `projects/demo-brand-owner/japan-and-korea-app-launch.json` is the
486
+ shipped example.
485
487
 
486
488
  ### What a project may and may not change
487
489
 
@@ -536,7 +538,7 @@ Copy or author the customers, context packs and project overlays you want; assum
536
538
  "forwarder": "alex",
537
539
  "forwarderEmail": "alex@example.com",
538
540
  "forwarderDomain": "example.com",
539
- "marks": [{ "ref": "TM-0001", "name": "AURORA SKY", "classes": [9, 42] }],
541
+ "marks": [{ "ref": "TM-0001", "name": "IRONWHISK", "classes": [9, 42] }],
540
542
  "goods": "cloud software for weather analytics",
541
543
  "jurisdictions": ["US"],
542
544
  "product": "knockout-search"
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <p align="center">
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0--only-860F09?style=flat-square" alt="Licence: AGPL-3.0-only"></a>
10
- <a href=".nvmrc"><img src="https://img.shields.io/badge/node-%E2%89%A5%2022-250902?style=flat-square" alt="Node 22+"></a>
10
+ <a href=".nvmrc"><img src="https://img.shields.io/badge/node-%E2%89%A5%2022.13-250902?style=flat-square" alt="Node 22.13+"></a>
11
11
  </p>
12
12
 
13
13
  Give it a mark, its classes and a territory. Clearotron searches the trademark registers and the open
@@ -35,8 +35,9 @@ calls to us.
35
35
  npm install -g clearotron
36
36
  ```
37
37
 
38
- Node 22 or newer, on macOS, Linux, or Windows via WSL2. That puts `clearotron` on your `PATH`; every
39
- command below works in that short form.
38
+ Node 22.13 or newer, on macOS or Linux. That puts `clearotron` on your `PATH`; every command below
39
+ works in that short form. **On Windows the demo above runs natively; a real clearance needs WSL2** —
40
+ the engine resolves the reasoning CLI the POSIX way and refuses at preflight otherwise.
40
41
 
41
42
  That command gives you the **stable** release — the one that has run a real clearance end to end before it
42
43
  was published. If you want the newest code instead, a beta is published whenever there is something worth
@@ -49,17 +50,6 @@ npm install -g clearotron@beta
49
50
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
50
51
  sure, the first command is the one you want.
51
52
 
52
- **Or run it from source**, which is what you want if you intend to change it:
53
-
54
- ```bash
55
- git clone https://github.com/CordilleraSarl/Clearotron
56
- cd clearotron
57
- npm install # every workspace
58
- npm run build -w portal-ui # the browser bundle is not committed — build it once
59
- ```
60
-
61
- From a clone the commands are `npx clearotron …`, run from that directory.
62
-
63
53
  ## Quick start
64
54
 
65
55
  Check the install before it does anything. `doctor` only reads — it writes nothing, calls nobody, and
@@ -135,13 +125,18 @@ credential.
135
125
 
136
126
  ## Development
137
127
 
128
+ A clone is the working tree, not a way to install the product — install it from the package above.
129
+
138
130
  ```bash
139
131
  git clone https://github.com/CordilleraSarl/Clearotron
140
132
  cd Clearotron
141
- npm install
142
- npm test # the offline suiteno credentials, no network
133
+ npm install # every workspace
134
+ npm run build -w portal-ui # the browser bundle is not committed build it once
135
+ npm test # the offline suite — no credentials, no network
143
136
  ```
144
137
 
138
+ From a clone the commands are `npx clearotron …`, run from that directory.
139
+
145
140
  `npm test` is the whole verification story for someone with no credentials, and it is the first thing
146
141
  [CONTRIBUTING.md](CONTRIBUTING.md) asks of a contributor.
147
142
 
@@ -6193,7 +6193,7 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
6193
6193
  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
6194
6194
  ```
6195
6195
 
6196
- ## undici@8.10.2
6196
+ ## undici@7.29.1
6197
6197
 
6198
6198
  - **Licence declared:** `MIT`
6199
6199
  - **Repository:** https://github.com/nodejs/undici
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { constants as SIG } from "node:os";
24
24
  import { isEntrypoint } from "../shared/is-entrypoint.mjs";
25
+ import { nodeFloorVerdict, nodeFloorRefusal } from "../shared/node-floor.mjs"; // — one floor, read from package.json
25
26
  import { invocationPrefix } from "../shared/invocation.mjs"; // — print a command the reader can type
26
27
 
27
28
  export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -133,6 +134,27 @@ const [verb, ...rest] = process.argv.slice(2);
133
134
  process.exit(2);
134
135
  }
135
136
 
137
+ // — THE RUNTIME FLOOR, REFUSED HERE, AND THE ONE THING THIS DISPATCHER LOOKS AT.
138
+ //
139
+ // This file's header says it implements nothing and does not inspect what it forwards, and that rule
140
+ // is right: a second, weaker copy of a check that already exists is worse than no copy. This is not
141
+ // that. It is not about the verb — it is about whether ANY verb can run, which is prior to dispatch
142
+ // and cannot live inside a child that will not start.
143
+ //
144
+ // It exists because the checks we had were in `doctor` and `setup`, which are things you run AFTER
145
+ // `npm install` succeeds. A reader whose runtime is too old meets a failure inside a command instead,
146
+ // and on one report it was an engine door exiting 1 with its message lost. `npx clearotron demo` and
147
+ // `clearotron start` never reached the sentence written for them.
148
+ //
149
+ // `--version` and `--help` above are DELIBERATELY on the near side of this. They are the two things a
150
+ // person runs while writing a bug report, and refusing them would take away the answer to the first
151
+ // question we ask.
152
+ const floor = nodeFloorVerdict();
153
+ if (!floor.ok) {
154
+ console.error(`clearotron: ${nodeFloorRefusal(floor)}`);
155
+ process.exit(1);
156
+ }
157
+
136
158
  // — EVERY VERB ANSWERS --help, INCLUDING THE TWO WHOSE CHILDREN REFUSE IT.
137
159
  //
138
160
  // `run` and `run-queue` dispatch to driver/pipeline.mjs and driver/runner.mjs, orchestrator entry points
package/bin/example.mjs CHANGED
@@ -95,6 +95,10 @@ const die = (...lines) => { console.error(`\n${lines.join("\n")}\n`); process.ex
95
95
  // already walked a container — every child holding a meta.json and a run/ — and `bin/start.mjs` seeds the
96
96
  // portal's archive from it. A bare run directory here would have left that call finding nothing and the
97
97
  // installed portal serving an empty archive, silently. Same directory, both readers, no second mechanism.
98
+ // THE PLAYER IS THE DEMO, so it asks for the demo account the same way `start --demo` does. Without
99
+ // this the roster it resolves is a fresh install's — `generic` alone — and the account whose reports
100
+ // it is about would not be there to name.
101
+ process.env.CLEAROTRON_DEMO_PROFILES ??= "1";
98
102
  const DEMO_ROOT = join(REPO, "demo");
99
103
  // The rule lives in `driver/demo-container.mjs` — ONE definition, because it used to be three and they
100
104
  // disagreed. That file records what a knockout demo carries instead of a report.md, and why this line
package/bin/onboard.mjs CHANGED
@@ -64,11 +64,16 @@ import { stdin as input, stdout as output } from "node:process";
64
64
  import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, chmodSync } from "node:fs"; // read the process table here; moved that to shared/process-table.mjs
65
65
  import { homedir, userInfo } from "node:os";
66
66
  import { invocationPrefix } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
67
+ import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor is package.json engines, not a constant here
67
68
  import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
68
69
  import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
69
70
  import { installShim } from "../shared/verb-shim.mjs"; // — the verb goes on PATH
70
71
  import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight where the meaning is
71
- import { bracketAsciiCells } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
72
+ import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
73
+ // ONE CLASSIFIER, shared with `bin/start.mjs`. The wizard asks the question; the launcher enforces the
74
+ // answer. Two copies of "what does this domain admit" is a wizard that consents to one rule and a
75
+ // launcher that builds another.
76
+ import { classifyStaffDomain, domainOfEmail, staffDomainRefusal, staffGrantSentence } from "../shared/staff-domain.mjs";
72
77
  import { join, dirname, isAbsolute, resolve } from "node:path";
73
78
  import { fileURLToPath, pathToFileURL } from "node:url";
74
79
  import { delimiter } from "node:path";
@@ -121,7 +126,6 @@ const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed
121
126
  // command is applying. Writes stay on ENV_PATH: a writer that followed the file backwards would keep an
122
127
  // install in the directory npm replaces forever.
123
128
  const READ_ENV_PATH = () => activeEnvPath({ repoRoot: REPO });
124
- const NODE_FLOOR = 22;
125
129
 
126
130
  const argv = process.argv.slice(2);
127
131
  const has = (n) => argv.includes(n);
@@ -587,6 +591,80 @@ export const AMBIENT_KEYS = [
587
591
  // ── helpers ──────────────────────────────────────────────────────────────────────────────────────────
588
592
  const present = (v) => typeof v === "string" && v.trim() !== "";
589
593
 
594
+ /**
595
+ * Ask who signs in, and take a real yes for the access rule that address implies.
596
+ *
597
+ * ── WHY THIS QUESTION EXISTS AT ALL ─────────────────────────────────────────────────────────────────
598
+ *
599
+ * Setup never asked for an address. `clearotron start` therefore took one from `--user`, from
600
+ * `PORTAL_LOCAL_USER`, or from the local account as `<account>@localhost` — and derived a STAFF RULE
601
+ * from everything after its `@`. On the local-account default that rule is `localhost`: one machine,
602
+ * one identity, nothing granted to anyone. Given a real address it is that address's whole domain, and
603
+ * the settings page then reports it back as "Anyone at <domain> — a rule, not a person".
604
+ *
605
+ * An outside install reached exactly that state. Nobody typed the address into this wizard, because
606
+ * this wizard had no prompt for it; an assistant filled one into the environment file on the operator's
607
+ * behalf, and the install granted a documentation domain. They read their own settings page and
608
+ * reported it as a back door. They were right to: a grant to a group had been made, by nobody.
609
+ *
610
+ * So the address is asked for here, where a person is definitionally present, and the grant it implies
611
+ * is shown in the words the settings page uses BEFORE it is written. `start` no longer guesses: it
612
+ * refuses a rule wider than one machine unless `PORTAL_STAFF_DOMAINS` says so in writing, which is what
613
+ * a yes here writes.
614
+ *
615
+ * THE DEFAULT IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
616
+ * variable. An identity that becomes an access rule comes from the person, or from the one source that
617
+ * cannot name a second person.
618
+ *
619
+ * `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branch that matters is the one where a
620
+ * reader pressed Enter at the grant question and NOTHING must be granted, and closed over a terminal
621
+ * that branch is asserted nowhere. Returns the `.env` keys to write and nothing else.
622
+ */
623
+ export async function askSignIn(io, { localAccount = "user", staffLabel = "Staff", envPath = "" } = {}) {
624
+ const { askValue, confirm, say = () => {}, ok = () => {}, info = () => {},
625
+ warn = () => {}, problem = () => {} } = io;
626
+ const localDefault = `${localAccount}@localhost`;
627
+ prose("The portal admits one address on this install, and that address is also what decides who is an",
628
+ "administrator. Enter accepts the local-account form, which is this machine and nobody else.");
629
+ for (;;) {
630
+ const typed = String(await askValue("Sign-in address:", { def: localDefault })).trim().toLowerCase();
631
+ if (!typed.includes("@") || typed.indexOf("@") !== typed.lastIndexOf("@")) {
632
+ problem(`"${typed}" is not a single email address. The portal refuses a multi-@ identity outright, `
633
+ + "so this would sign in and then be denied at the door.");
634
+ continue;
635
+ }
636
+ const domain = domainOfEmail(typed);
637
+ const verdict = classifyStaffDomain(domain);
638
+ if (verdict === "public" || verdict === "reserved") {
639
+ // The classifier's own sentence, never a second copy: `clearotron start` prints these same words
640
+ // when it meets the same domain, and two wordings of one refusal is how a reader comes to believe
641
+ // they have met two different problems.
642
+ problem(staffDomainRefusal(domain));
643
+ continue;
644
+ }
645
+ if (verdict === "narrow") {
646
+ ok(`${typed} — this machine only. No domain rule is written, and nobody else is granted anything.`);
647
+ return { PORTAL_LOCAL_USER: typed };
648
+ }
649
+ // `wide`: a real domain, so the grant is a grant. Stated first, in the settings page's own words,
650
+ // and Enter is NOT a yes — the default is no, because this is the one answer in the wizard that
651
+ // admits people the reader has never met.
652
+ say("");
653
+ warn(`${typed} makes this a rule about ${domain}, not about you.`);
654
+ say(` ${staffGrantSentence(domain, { staffLabel })}`);
655
+ say("");
656
+ say(` Everyone at ${domain} who gets past this install's sign-in door would see every brand owner`);
657
+ say(" on it — every clearance, every report, every configuration. On a laptop that is only you,");
658
+ say(" because only one address can sign in. Behind a company login it is the whole domain.");
659
+ say("");
660
+ if (await confirm(`Grant ${domain} that, and write it down as PORTAL_STAFF_DOMAINS?`, false))
661
+ return { PORTAL_LOCAL_USER: typed, PORTAL_STAFF_DOMAINS: domain };
662
+ info(`nothing granted${envPath ? `, and nothing written to ${envPath}` : ""}. ${typed} would sign in `
663
+ + "and every page would refuse it, because signing in is not being enrolled — so choose the "
664
+ + "local-account form, or answer yes above.");
665
+ }
666
+ }
667
+
590
668
  /**
591
669
  * An engine binary, resolved the way the engine resolves it — and the trap that resolution carries.
592
670
  *
@@ -998,9 +1076,9 @@ export async function runCheck() {
998
1076
  }
999
1077
 
1000
1078
  say("\n Node");
1001
- const major = Number(process.versions.node.split(".")[0]);
1002
- if (major >= NODE_FLOOR) ok(`node ${process.versions.node}`);
1003
- else problem(`node ${process.versions.node} — this engine needs >= ${NODE_FLOOR} (node:sqlite and TS type-stripping are load-bearing)`);
1079
+ const nodeV = nodeFloorVerdict();
1080
+ if (nodeV.ok) ok(`node ${nodeV.current}`);
1081
+ else problem(`node ${nodeV.current} — this engine needs ${nodeV.required} or newer; node:sqlite is not a built-in module before then`);
1004
1082
 
1005
1083
  // Read the file up here rather than at the `.env` heading below: the engine section is the first that
1006
1084
  // needs `effective()`, and which ENGINE is configured decides which binary variable to check. Reading
@@ -1170,6 +1248,46 @@ export async function runCheck() {
1170
1248
  + "clearotron doctor --probe-engine to find out.");
1171
1249
  }
1172
1250
 
1251
+ // AND WHETHER THE ENGINE AGREES, which is a different question from the one above and the reason an
1252
+ // outside user gave up on this product. This command reads its OWN environment; the portal's New
1253
+ // clearance screen reads what the engine recorded when it last started. Both were right and they
1254
+ // said opposite things, so a reader who checked the confident-looking one first was told the engine
1255
+ // was fine while no search would start. Reported here in the same words the configuration page uses,
1256
+ // because a doctor that cannot see a contradiction the product ships with is the wrong doctor.
1257
+ try {
1258
+ const { readFlagSnapshot, postureDisagreement } = await import("../driver/flag-snapshot.mjs");
1259
+ // THE READ-SIDE ACCESSOR, because this is a read: `config.poolRoot` throws on a box with no pool
1260
+ // configured, and `readFlagSnapshot(null)` already answers "no capture" for exactly that case.
1261
+ const snap = readFlagSnapshot(config.poolRootOrNull);
1262
+ const rows = snap
1263
+ ? postureDisagreement(snap, { flags: {}, engine: engineInventory(invEnv) })
1264
+ : null;
1265
+ // NULL IS NOT AGREEMENT and neither is an empty pool — a box with no capture has nothing to
1266
+ // disagree with, and saying so beats printing a clean bill nobody measured.
1267
+ const clash = (rows ?? []).find((r) => r.what === "engine program");
1268
+ if (clash) {
1269
+ problem(`The engine that last ran and this machine disagree about the engine program: the last run `
1270
+ + `recorded it as ${clash.capture}, this machine reads it as ${clash.live}. A NEW search will `
1271
+ + `refuse while that is true. Restart the engine service so it re-reads its PATH, or install the `
1272
+ + `CLI where the service can see it.`);
1273
+ }
1274
+ } catch (e) {
1275
+ // WHAT ACTUALLY REACHES THIS CATCH, established by driving it rather than by reading it.
1276
+ //
1277
+ // Not a box with no pool: `poolRootOrNull` answers null and `readFlagSnapshot(null)` answers null,
1278
+ // neither throwing. And NOT an unreadable capture either — `readFlagSnapshot` has its own try and
1279
+ // returns null for a corrupt file, so a damaged capture is already indistinguishable from an absent
1280
+ // one by the time this code sees it. That is worth knowing and is not this change's to fix.
1281
+ //
1282
+ // So this catch covers an import that has broken or an unexpected throw out of the comparison —
1283
+ // the check having stopped running. A doctor silent about its own failure is the defect the rest of
1284
+ // this change is about, an absence rendered as a clean bill, so it says so. A caution rather than a
1285
+ // problem: the engine may be perfectly fine and it is this check that is broken.
1286
+ info(`Could not compare this machine against what the engine last recorded (${e?.message ?? e}). `
1287
+ + `That comparison is what catches a settings page reading healthy while a search will not start, `
1288
+ + `so this run has not checked it either way.`);
1289
+ }
1290
+
1173
1291
  // item 5 — WHICH BILLING LANE, reported rather than left to be inferred from a variable's
1174
1292
  // absence. `--check` named the engine and its binary and never said how the box pays, so the two
1175
1293
  // states that matter — metered per token, or drawn against a subscription — were indistinguishable
@@ -1366,7 +1484,7 @@ export async function runCheck() {
1366
1484
  // Both lines were honest about their own source and neither said what it was. The first reads this
1367
1485
  // command's env file; the second reads THIS PROCESS's resolution, and a CLI is started by a login
1368
1486
  // shell that carries none of the units' `EnvironmentFile`. The deployment was correct and served
1369
- // zephyr, aurora and generic throughout.
1487
+ // the configured roster throughout.
1370
1488
  //
1371
1489
  // THE UNITS' ENV IS NOT PASSED INTO `profileStoreResolution`, deliberately. `PROFILES_OVERLAY_DIR`
1372
1490
  // is captured when profiles.mjs LOADS, so handing it the units' value would set `live` with no
@@ -1418,17 +1536,38 @@ export async function runCheck() {
1418
1536
  const demo = keys.filter((k) => roster.get(k)?.demoData === true);
1419
1537
  // `generic` is the universal fallback the module requires by name, not a brand owner somebody
1420
1538
  // onboarded — counting it would tell an operator with an empty store that they have one.
1421
- const owners = keys.filter((k) => k !== "generic");
1539
+ //
1540
+ // A DEMO ACCOUNT IS NOT AN ONBOARDED OWNER EITHER, and until this line it was counted as one. A
1541
+ // fresh install used to ship the demo account into every roster, so `doctor` reported "1 brand
1542
+ // owner(s) resolve here: demo-brand-owner (DEMO DATA)" on a machine where nobody had onboarded
1543
+ // anything — and never named `generic`, which is the account that actually rates a run there.
1544
+ // The reader is told they have a customer and not told what they are running on. Both halves
1545
+ // wrong from one list.
1546
+ //
1547
+ // SINCE 2026-09-08 A FRESH INSTALL RESOLVES `generic` ALONE (owner ruling): nobody should have to
1548
+ // clean demo material out of an environment they just created. So the demo branch below no longer
1549
+ // fires on a plain install — it fires inside the demo, which asks for its own account. It is kept
1550
+ // rather than deleted because it is still reachable, and a reader who meets the demo account
1551
+ // there is owed the same two facts: it is fiction, and a real clearance under it is refused at
1552
+ // the admission wall.
1553
+ //
1554
+ // Three states, told apart, because they mean three different things to whoever is reading:
1555
+ // an onboarded roster, the house default alone, and the house default beside what the demo
1556
+ // brought with it.
1557
+ const owners = keys.filter((k) => k !== "generic" && !demo.includes(k));
1422
1558
  if (!owners.length) {
1423
- info(`no brand owners resolve here only the \`generic\` fallback. An empty store is a working `
1424
- + `install on Generic defaults; it is also what a store pointed at the wrong directory looks like`);
1559
+ const base = "`generic` is the account this install rates under the house default, and the "
1560
+ + "only one a clean install has";
1561
+ if (demo.length) {
1562
+ info(`${base}. The demo brought one with it, marked DEMO DATA: ${demo.join(", ")} — fiction `
1563
+ + "rather than an account anybody onboarded, and a real clearance under one is refused");
1564
+ } else {
1565
+ info(`${base}. An empty store is a working install on Generic defaults; it is also what a `
1566
+ + "store pointed at the wrong directory looks like");
1567
+ }
1425
1568
  } else {
1426
- const marked = owners.map((k) => (demo.includes(k) ? `${k} (DEMO DATA)` : k)).join(", ");
1427
- const line = `${owners.length} brand owner(s) resolve here: ${marked}`;
1428
- // The demo marker is the member-level half: naming the store is not the
1429
- // same as saying the accounts in it are fiction, and a real clearance under one is refused at
1430
- // the admission wall — which an operator should learn here rather than from that refusal.
1431
- if (demo.length) info(`${line} — accounts marked DEMO DATA cannot start a real clearance`);
1569
+ const line = `${owners.length} brand owner(s) resolve here: ${owners.join(", ")}`;
1570
+ if (demo.length) info(`${line}. The demo brought one with it, marked DEMO DATA: ${demo.join(", ")} — not counted above, and a real clearance under one is refused`);
1432
1571
  else ok(line);
1433
1572
  }
1434
1573
  try {
@@ -2310,6 +2449,27 @@ export async function runCheck() {
2310
2449
  // unreachable; repeating it as a second failure teaches the reader that this section
2311
2450
  // double-counts. It still states what was NOT established, which is the whole job.
2312
2451
  else if (v.kind === "could-not-look") info(v.message);
2452
+ // NO ACCESS IN FRONT OF THE CLIENT DOOR IS A POSTURE, NOT A FAULT (owner ruling 2026-09-08).
2453
+ //
2454
+ // This is the CLIENT connector's address, and how a client reaches it is the client's decision:
2455
+ // "client access sitting behind OAuth is totally up to a client — plenty might just run it token
2456
+ // based on their own laptop." A door answering with its own Bearer challenge and no Access front
2457
+ // is a supported shape, not a misconfiguration, so raising it made `doctor` exit 1 on a healthy
2458
+ // deployment and taught its reader to skim the one command that must never be skimmed.
2459
+ //
2460
+ // It is still SAID rather than dropped, and it still states what was not established: an
2461
+ // audience that was never compared is not an audience that agreed. What changed is that the
2462
+ // sentence no longer calls a client's own arrangement a finding about this install.
2463
+ //
2464
+ // Scoped deliberately to `not-fronted` on THIS address. A configured audience that DISAGREES
2465
+ // with the one the edge issues is still a fault, and the portal and ops surfaces are untouched —
2466
+ // they are checked elsewhere and Access in front of them is not optional.
2467
+ else if (v.kind === "not-fronted") {
2468
+ info(`nothing is fronting this hostname with Access — ${read.why}. That is this client door's `
2469
+ + "posture rather than a fault: a client may reach it with its own token and no Access in "
2470
+ + "front, which is a supported shape. The configured audience was not compared against this "
2471
+ + "address, so nothing here says the two agree.");
2472
+ }
2313
2473
  else problem(v.message);
2314
2474
  }
2315
2475
  }
@@ -2534,13 +2694,13 @@ try {
2534
2694
 
2535
2695
  // 1 ── Node
2536
2696
  say(" Node");
2537
- const major = Number(process.versions.node.split(".")[0]);
2538
- if (major < NODE_FLOOR) {
2539
- problem(`node ${process.versions.node} — this engine needs >= ${NODE_FLOOR}. Upgrade Node and run setup again.`);
2697
+ const nodeV = nodeFloorVerdict();
2698
+ if (!nodeV.ok) {
2699
+ problem(`node ${nodeV.current} — this engine needs ${nodeV.required} or newer. Upgrade Node and run setup again.`);
2540
2700
  aborted = "node";
2541
2701
  throw new Error("node floor");
2542
2702
  }
2543
- ok(`node ${process.versions.node}`);
2703
+ ok(`node ${nodeV.current}`);
2544
2704
 
2545
2705
  // 2 ── the engine: which one, which binary, and PROOF that it can run a turn
2546
2706
  //
@@ -3175,6 +3335,22 @@ try {
3175
3335
  say(" fallback — falls through by name, so an empty store is a working install. Your own customers");
3176
3336
  say(" are added here by name; the bundled demo customers never show through into your roster.");
3177
3337
 
3338
+ // 7c ── WHO SIGNS IN, AND WHAT THAT ADDRESS GRANTS
3339
+ //
3340
+ // Setup never asked for an address, so `clearotron start` derived one — and derived a staff-domain
3341
+ // rule from everything after its `@`. The whole reasoning, and the install that granted a
3342
+ // documentation domain to a reader who had never been asked, is on `askSignIn` above.
3343
+ section("Who signs in");
3344
+ const localAccount = (() => {
3345
+ try { return userInfo().username || "user"; } catch { return "user"; }
3346
+ })();
3347
+ // ONE CALL SITE. The loop itself lives in `askSignIn` so that the branch that matters — a reader who
3348
+ // pressed Enter at the grant question, and a staff rule that must therefore NOT be written — is
3349
+ // reachable without a terminal. The same seam and the same reason as `offerUsptoSync`.
3350
+ Object.assign(candidate, await askSignIn(
3351
+ { askValue, confirm, say, ok, info, warn, problem },
3352
+ { localAccount, staffLabel: `${BRAND.name} staff`, envPath: ENV_PATH }));
3353
+
3178
3354
  // 8 ── the engine's own preflight over the whole candidate
3179
3355
  //
3180
3356
  // — THIS STEP CHECKS THE REGISTER PROVIDER AND NOTHING ELSE. `preflightCandidate` pins