clearotron 0.2.4 → 0.3.0-beta.1

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 (235) hide show
  1. package/.env.example +13 -2
  2. package/CONTRIBUTING.md +1 -1
  3. package/INSTALL.md +62 -38
  4. package/bin/brandowner.mjs +18 -169
  5. package/bin/clearotron.mjs +3 -1
  6. package/bin/connect.mjs +28 -19
  7. package/bin/disconnect.mjs +3 -3
  8. package/bin/example.mjs +7 -7
  9. package/bin/framework-preflight.mjs +49 -0
  10. package/bin/grant.mjs +151 -93
  11. package/bin/onboard.mjs +220 -138
  12. package/bin/start.mjs +146 -137
  13. package/bin/stop.mjs +2 -2
  14. package/bin/update.mjs +1 -1
  15. package/build-info.json +2 -2
  16. package/docs/CLIENT-MCP.md +2 -2
  17. package/docs/E2E.md +12 -2
  18. package/docs/ONBOARDING.md +1 -1
  19. package/docs/PORTAL.md +14 -13
  20. package/docs/SECURITY.md +23 -24
  21. package/docs/architecture/04-configuration-reference.md +12 -5
  22. package/docs/architecture/05-config-governance.md +7 -7
  23. package/docs/architecture/07-quality-and-audit.md +1 -1
  24. package/docs/architecture/08-development-guide.md +5 -0
  25. package/docs/configuration.md +118 -0
  26. package/docs/decisions/0004-documentation-structure.md +2 -2
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
  28. package/driver/CHANGELOG.md +70 -0
  29. package/driver/ask-ledger.mjs +2 -2
  30. package/driver/cancel.mjs +27 -0
  31. package/driver/case-law-sources.mjs +3 -3
  32. package/driver/company-bundle.mjs +261 -0
  33. package/driver/compare.mjs +1 -1
  34. package/driver/compose-read.mjs +2 -2
  35. package/driver/config-inventory.mjs +2 -2
  36. package/driver/contract-audit.mjs +1 -1
  37. package/driver/contract-e3-backlog.mjs +1 -1
  38. package/driver/contract-vocabulary.mjs +1 -1
  39. package/driver/declination-call.mjs +1 -1
  40. package/driver/deliver-trigger.sh +3 -3
  41. package/driver/dev-portal.mjs +3 -1
  42. package/driver/digest-queue.mjs +1 -1
  43. package/driver/disposition-tool.mjs +1 -1
  44. package/driver/doc-constants.mjs +1 -1
  45. package/driver/drain-posture.mjs +2 -2
  46. package/driver/drainer-identity.mjs +1 -1
  47. package/driver/driver.config.mjs +44 -9
  48. package/driver/effective-scope.mjs +30 -1
  49. package/driver/effort-model.mjs +6 -6
  50. package/driver/engine/CONTRACT.md +2 -2
  51. package/driver/engine/anthropic-agent.mjs +11 -11
  52. package/driver/engine/jx-turn.mjs +1 -1
  53. package/driver/engine/mcp/gather-config.mjs +29 -4
  54. package/driver/engine/mcp/recording-server.mjs +73 -1
  55. package/driver/engine/openai-agent.mjs +1 -1
  56. package/driver/engine/probe.mjs +28 -4
  57. package/driver/enqueue-schema.mjs +23 -3
  58. package/driver/findings-model.mjs +2 -2
  59. package/driver/flag-snapshot.mjs +2 -2
  60. package/driver/floor-duty.mjs +2 -2
  61. package/driver/frame-diff-model.mjs +1 -1
  62. package/driver/framework-preflight.mjs +143 -0
  63. package/driver/gateway.mjs +9 -1
  64. package/driver/hit-list.mjs +1 -1
  65. package/driver/jx-lanes.mjs +1 -1
  66. package/driver/jx.mjs +1 -1
  67. package/driver/knockout-assess-record.mjs +1 -1
  68. package/driver/knockout-review-record.mjs +435 -0
  69. package/driver/order-probe.mjs +1 -1
  70. package/driver/outbox-backoff.mjs +2 -2
  71. package/driver/owner-use-check.mjs +2 -2
  72. package/driver/package.json +1 -1
  73. package/driver/pipeline-knockout.mjs +105 -8
  74. package/driver/pipeline.mjs +81 -30
  75. package/driver/plain-register.mjs +77 -3
  76. package/driver/portal-access.mjs +141 -74
  77. package/driver/portal-config-view.mjs +59 -70
  78. package/driver/portal-report.mjs +4 -4
  79. package/driver/portal-service.mjs +348 -141
  80. package/driver/portal-upstream.mjs +105 -17
  81. package/driver/predelivery-lint.mjs +43 -18
  82. package/driver/product-rows.mjs +1 -1
  83. package/driver/products.mjs +1 -1
  84. package/driver/profile-page.html +30 -5
  85. package/driver/profile-service.mjs +197 -26
  86. package/driver/profiles.mjs +48 -1
  87. package/driver/publish/index.mjs +31 -13
  88. package/driver/publish/knockout.mjs +9 -5
  89. package/driver/publish/office-record-links.mjs +189 -0
  90. package/driver/publish/parse.mjs +3 -3
  91. package/driver/publish/publish-inputs.mjs +26 -0
  92. package/driver/publish/render-knockout.mjs +42 -42
  93. package/driver/publish/render.mjs +29 -4
  94. package/driver/publish/report-data.mjs +2 -2
  95. package/driver/publish/seed-pool.mjs +1 -1
  96. package/driver/publish/templates/report.css +8 -8
  97. package/driver/publish/xlsx.mjs +49 -7
  98. package/driver/queue-watch-verdict.mjs +2 -2
  99. package/driver/recipe-service.mjs +1 -1
  100. package/driver/record-carry.mjs +1 -1
  101. package/driver/reference-score.mjs +1 -1
  102. package/driver/reference-strip-signatures.mjs +1 -1
  103. package/driver/register-availability.mjs +4 -3
  104. package/driver/register-count.mjs +3 -3
  105. package/driver/register-records.mjs +1 -1
  106. package/driver/repair-composers.mjs +1 -1
  107. package/driver/repairs.mjs +3 -3
  108. package/driver/replay-archive.mjs +1 -1
  109. package/driver/report-card-record.mjs +1 -1
  110. package/driver/result-noun-fields.mjs +5 -0
  111. package/driver/roster-verdict.mjs +48 -5
  112. package/driver/run-activity.mjs +1 -1
  113. package/driver/run-requirements.mjs +18 -5
  114. package/driver/runner.mjs +24 -15
  115. package/driver/search-policy.mjs +8 -8
  116. package/driver/senior-rights.mjs +1 -1
  117. package/driver/skills/prelim-search/delivery-contract.md +1 -1
  118. package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
  119. package/driver/stages-knockout.mjs +72 -6
  120. package/driver/stages.mjs +10 -10
  121. package/driver/suite-census.json +238 -70
  122. package/driver/synthesis-record.mjs +2 -2
  123. package/driver/systemd/clearotron-client-mcp.service +3 -3
  124. package/driver/systemd/clearotron-deploy.service +2 -2
  125. package/driver/systemd/clearotron-mcp-face.service +1 -1
  126. package/driver/systemd/clearotron-portal.service +3 -3
  127. package/driver/systemd/clearotron-worker.service +5 -5
  128. package/driver/systemd/install-census.mjs +1 -1
  129. package/driver/systemd/render-units.mjs +9 -9
  130. package/driver/terminal-clamp.mjs +1 -1
  131. package/driver/trigger-cap.mjs +18 -2
  132. package/driver/unit-inventory.mjs +8 -8
  133. package/driver/usage-ledger.mjs +5 -3
  134. package/driver/verify-knockout.mjs +7 -7
  135. package/driver/verify.mjs +5 -5
  136. package/driver/whatif-memo-run.mjs +1 -1
  137. package/driver/whatif-queue.mjs +3 -3
  138. package/driver/whatif-worker.mjs +2 -2
  139. package/examples/README.md +1 -1
  140. package/examples/grants.example.json +25 -24
  141. package/mcp-server/CHANGELOG.md +10 -0
  142. package/mcp-server/http-server.mjs +3 -3
  143. package/mcp-server/key-socket.mjs +1 -1
  144. package/mcp-server/lib/audit-view.mjs +3 -3
  145. package/mcp-server/lib/brief.mjs +3 -3
  146. package/mcp-server/lib/driver.mjs +1 -1
  147. package/mcp-server/lib/events.mjs +1 -1
  148. package/mcp-server/lib/http-handler.mjs +2 -2
  149. package/mcp-server/lib/instructions.mjs +2 -2
  150. package/mcp-server/lib/knockout.mjs +1 -1
  151. package/mcp-server/lib/ops.mjs +6 -3
  152. package/mcp-server/lib/options.mjs +15 -4
  153. package/mcp-server/lib/plan.mjs +7 -6
  154. package/mcp-server/lib/runs.mjs +10 -0
  155. package/mcp-server/lib/whatif.mjs +5 -5
  156. package/mcp-server/package.json +1 -1
  157. package/mcp-server/packs/README.md +1 -1
  158. package/mcp-server/remote/client-mcp-apikey.service +1 -1
  159. package/mcp-server/remote/client-mcp.service +2 -2
  160. package/mcp-server/remote/trademark-artifacts-http.service +1 -1
  161. package/mcp-server/server.mjs +38 -24
  162. package/package.json +2 -2
  163. package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
  164. package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
  165. package/portal-ui/dist/index.html +2 -2
  166. package/portal-ui/package.json +1 -1
  167. package/providers/_shared/lane-probe.mjs +9 -3
  168. package/providers/jx-subclass/lookup.mjs +1 -1
  169. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  170. package/providers/oauth-mcp-bridge/package.json +1 -1
  171. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  172. package/scripts/citation-drift-report.mjs +1 -1
  173. package/scripts/citation-line-check.mjs +2 -2
  174. package/scripts/drive-env-check.mjs +1 -1
  175. package/scripts/e2e.mjs +5 -5
  176. package/scripts/env-audit.mjs +13 -1
  177. package/scripts/headless-page.mjs +5 -5
  178. package/scripts/live-surface-check.mjs +26 -2
  179. package/scripts/mint-names-in-force.mjs +19 -5
  180. package/scripts/mint-reference-strip-backlog.mjs +1 -1
  181. package/scripts/mint-suite-census.mjs +37 -10
  182. package/scripts/pack-publishable.mjs +1 -1
  183. package/scripts/preinstall-node-check.mjs +1 -1
  184. package/scripts/release-await-cut.mjs +3 -3
  185. package/scripts/release-cut-decision.mjs +1 -1
  186. package/scripts/release-dist-tag.mjs +1 -1
  187. package/scripts/release-install-check.mjs +1 -1
  188. package/scripts/release-notes-lint.mjs +1 -1
  189. package/scripts/release-publish-guard.mjs +1 -1
  190. package/scripts/release-version-pr-checks.mjs +2 -2
  191. package/scripts/release-version.mjs +61 -5
  192. package/scripts/render-brand-banner.mjs +1 -1
  193. package/scripts/render-check.mjs +2 -2
  194. package/scripts/repo-writes.mjs +1 -1
  195. package/scripts/report-frame-check.mjs +1 -1
  196. package/scripts/report-screenshot.mjs +2 -2
  197. package/scripts/retire-bare-refs.mjs +1 -1
  198. package/scripts/revisit-render-check.mjs +1 -1
  199. package/scripts/score.mjs +1 -1
  200. package/scripts/strip-titles-and-attributions.mjs +389 -0
  201. package/scripts/strip-tracker-citations.mjs +122 -5
  202. package/scripts/test-run.mjs +4 -4
  203. package/scripts/third-party-notices.mjs +1 -1
  204. package/scripts/verify-publishable.mjs +1 -1
  205. package/shared/access-audience.mjs +2 -2
  206. package/shared/anon-overlay.mjs +1 -1
  207. package/shared/brand.mjs +15 -1
  208. package/shared/bundle-freshness.mjs +1 -1
  209. package/shared/bundle-rebuild.mjs +1 -1
  210. package/shared/checkout-move.mjs +2 -2
  211. package/shared/client-door.mjs +8 -8
  212. package/shared/connect-clients.mjs +7 -7
  213. package/shared/connector-signin-probe.mjs +1 -1
  214. package/shared/env-aliases.mjs +1 -1
  215. package/shared/env-local.mjs +5 -5
  216. package/shared/grants-edit.mjs +76 -0
  217. package/shared/install-auth.mjs +1 -1
  218. package/shared/listen.mjs +3 -3
  219. package/shared/mcp-challenge.mjs +1 -1
  220. package/shared/names-in-force.mjs +2 -1
  221. package/shared/onboarding-store.mjs +19 -2
  222. package/shared/reference-guard-classes.mjs +44 -2
  223. package/shared/register-selection.mjs +1 -1
  224. package/shared/scope.mjs +223 -56
  225. package/shared/secret-file.mjs +1 -1
  226. package/shared/server-units.mjs +1 -1
  227. package/shared/staff-domain.mjs +45 -78
  228. package/shared/summary-blocks.mjs +2 -2
  229. package/shared/systemd-failure.mjs +3 -3
  230. package/shared/tracked-files.mjs +1 -1
  231. package/shared/trigger-lane.mjs +1 -1
  232. package/shared/tty-style.mjs +1 -1
  233. package/shared/usage-block.mjs +1 -1
  234. package/shared/vacuous-pass.mjs +1 -1
  235. package/shared/verb-shim.mjs +1 -1
@@ -11,11 +11,11 @@
11
11
  // is listening. The person most likely to believe "I turned it off" is the one who just stopped the
12
12
  // service.
13
13
  //
14
- // Owner ruling, 2026-08-31: record key IDs never secrets; the id goes to the denylist; the record is
14
+ // Ruling, 2026-08-31: record key IDs never secrets; the id goes to the denylist; the record is
15
15
  // struck. REVOCATION COMES FIRST in the apply order — if this dies halfway, the half that must already
16
16
  // have happened is the credential being dead.
17
17
  //
18
- // ── WHAT THIS VERB STOPPED DOING ON 2026-09-03 (owner ruling, Q3) ─────────────
18
+ // ── WHAT THIS VERB STOPPED DOING ON 2026-09-03 (ruling, Q3) ─────────────
19
19
  //
20
20
  // It used to stop and REMOVE the unit, and turn `CLIENT_MCP_ACCOUNT_ACCESS` back off. Both were right
21
21
  // while the door existed only because a reader had asked for it. The door now comes up with the
@@ -257,7 +257,7 @@ async function cutEveryoneOff({ dryRun }) {
257
257
  return 0;
258
258
  }
259
259
 
260
- // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND (tracker issue 183). Importing a verb to reach
260
+ // THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Importing a verb to reach
261
261
  // something inside it must read a module, not start a command — `bin/connect.mjs` opened its interactive
262
262
  // prompt and hung a suite when an arm imported it for one message helper.
263
263
  //
package/bin/example.mjs CHANGED
@@ -50,7 +50,7 @@ import { isFrozen, demoChildren, publishSource } from "../driver/demo-container.
50
50
 
51
51
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
52
52
 
53
- import { usageBlock } from "../shared/usage-block.mjs"; // tracker issues 1861/1882
53
+ import { usageBlock } from "../shared/usage-block.mjs";
54
54
  const argv = process.argv.slice(2);
55
55
  const flag = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
56
56
  const has = (n) => argv.includes(n);
@@ -65,11 +65,11 @@ if (has("--help") || has("-h")) {
65
65
  //
66
66
  // Until now the flag was discoverable only by getting it wrong: `--help` listed --run-dir, --base,
67
67
  // --port and --no-open and stopped, so three of the four shipped demos were invisible from the
68
- // command written to give somebody their first look (tracker issue 201).
68
+ // command written to give somebody their first look.
69
69
  const shipped = demoChildren(join(REPO, "demo"));
70
70
  if (shipped.length) {
71
71
  console.log(`\n --product takes one of:`);
72
- // NO "DEFAULT" MARKER (tracker issue 277). This printed "(the default, when --product is not given)"
72
+ // NO "DEFAULT" MARKER. This printed "(the default, when --product is not given)"
73
73
  // beside the first id, and kept printing it after the default became ALL of them — so `--help` taught
74
74
  // a reader the exact belief the change removed, in the command it was reported against.
75
75
  for (const id of shipped) console.log(` ${id}`);
@@ -110,7 +110,7 @@ const DEMO_ROOT = join(REPO, "demo");
110
110
  const wanted = flag("--product");
111
111
  const children = demoChildren(DEMO_ROOT);
112
112
 
113
- // ── ALL OF THEM, UNLESS THE CALLER NARROWED IT (tracker issue 277) ──────────────────────────────────
113
+ // ── ALL OF THEM, UNLESS THE CALLER NARROWED IT ──────────────────────────────────────────────────────
114
114
  //
115
115
  // This used to replay `children[0]` and stop. The package ships one finished report per product, and a
116
116
  // first-time reader met one of them with nothing on screen saying the other three existed — so three
@@ -129,8 +129,8 @@ const sampleDirs = flag("--run-dir")
129
129
  // the container itself is what it looks at, which is what it always did when `demo/` was empty.
130
130
  const sampleDir = sampleDirs[0] ?? resolve(DEMO_ROOT);
131
131
  if (!sampleDirs.length || !isFrozen(sampleDir)) {
132
- // AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; tracker issue
133
- // 2193 reported it exiting 0, which did not reproduce at v0.1.0 or at main's tip. An arm pins it.
132
+ // AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; a report of
133
+ // it exiting 0 did not reproduce at v0.1.0 or at main's tip. An arm pins it.
134
134
  die(
135
135
  `demo: no frozen demo at ${sampleDir}`,
136
136
  "",
@@ -231,7 +231,7 @@ console.log(` reports folder: ${poolRoot}\n`);
231
231
  mkdirSync(poolRoot, { recursive: true });
232
232
  const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publish", "report-registry.mjs")).href);
233
233
 
234
- // ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT (tracker issue 277) ─────────
234
+ // ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT ─────────────────────────────
235
235
  //
236
236
  // Publishing stopped at the first error, which was right when there was one demo and is wrong now: a
237
237
  // reader whose knockout capture is unreadable should still get the other three, and should be TOLD which
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // clearotron framework — read a risk framework and its manifest, and say what they declare.
6
+ //
7
+ // The command a customer's counsel runs after writing the pair and before pointing a company at it.
8
+ // It creates nothing, rates nothing and calls nobody: it opens the two files exactly as a run would,
9
+ // reports the ladder in the framework's own order, and where the deck and the manifest disagree it
10
+ // names the band and says what the deck did not do.
11
+ //
12
+ // WHY IT IS ITS OWN COMMAND rather than a flag on the onboarding one. `brandowner add --dry-run`
13
+ // answers "would this create the company I meant", and it does check the framework loads — but a person
14
+ // writing a rubric has no company to create yet, and telling them to phrase their question as a
15
+ // half-finished onboarding is how a check goes unrun. The onboarding command prints this same report,
16
+ // from this same module, so the two can never disagree about what a valid framework is.
17
+
18
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
19
+ import { invocationPrefix } from "../shared/invocation.mjs";
20
+ import { preflightFramework, formatPreflight } from "../driver/framework-preflight.mjs";
21
+
22
+ const USAGE = (cmd) => `
23
+ ${cmd} framework <skills/prelim-search/your-framework.md>
24
+
25
+ Reads a risk framework deck and the manifest beside it, and reports what they declare —
26
+ the ladder, the company the deck names, the shape, and which file answered where.
27
+ Creates nothing. Rates nothing. Contacts nobody.
28
+
29
+ The path is the one a profile carries: it starts with skills/ and is resolved the way a
30
+ run resolves it, so this reads the file a matter would actually be rated under.
31
+
32
+ Exit codes: 0 the deck and the manifest agree · 1 not ready, with the reason · 2 usage
33
+ `;
34
+
35
+ export function preflightMain(argv, { out = console.log, err = console.error } = {}) {
36
+ const cmd = invocationPrefix();
37
+ const args = argv.filter((a) => a !== "--");
38
+ if (args.includes("--help") || args.includes("-h")) { out(USAGE(cmd)); return 0; }
39
+ if (args.length !== 1 || args[0].startsWith("-")) { err(USAGE(cmd)); return 2; }
40
+
41
+ const report = preflightFramework(args[0]);
42
+ out(formatPreflight(report));
43
+ // A substitution is not a refusal — the pair may be perfectly valid — but it is the finding this
44
+ // command exists to surface, so it is never the difference between exit 0 and exit 1 and is never
45
+ // silent either. The line is printed above by the formatter.
46
+ return report.ok ? 0 : 1;
47
+ }
48
+
49
+ if (isEntrypoint(import.meta.url)) process.exit(preflightMain(process.argv.slice(2)));
package/bin/grant.mjs CHANGED
@@ -1,37 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
- // grant.mjs — put someone on the guest list, take them off, or see who is on it ( item 2).
4
+ // grant.mjs — give someone access, take it away, or see who has it ( item 2).
5
5
  //
6
6
  // ── WHY THIS EXISTS ────────────────────────────────────────────────────────────────────────────────
7
7
  //
8
- // The guest list was a JSON file edited by hand. `bin/start.mjs` writes an empty one at install and
9
- // nothing added a person to it, so for anyone self-hosting the only way to let a second person in was a
10
- // text editor — and a mistake in that file does not error. `accountsForEmail` simply matches nothing,
11
- // and the person signs in to an empty world with nothing in any log to say why.
8
+ // The guest list was a JSON file edited by hand. `bin/start.mjs` writes one at install and nothing
9
+ // added a second person to it, so for anyone self-hosting the only way to let a colleague in was a text
10
+ // editor — and a mistake in that file does not error. `resolvePerson` simply matches nothing, and the
11
+ // person signs in to an empty world with nothing in any log to say why.
12
12
  //
13
13
  // `npm start` and `npm run setup` exist so that installing is not that. Enrolment is the very next thing
14
14
  // the same reader does, and it dropped straight back to the editor.
15
15
  //
16
16
  // ── ONE OPINION ABOUT VALIDITY, NOT TWO ───────────────────────────────────────────────────────────
17
17
  //
18
- // The product already knew what a mistake looked like: `accessView` (driver/portal-config-view.mjs)
19
- // computes BOTH typo directions for the People & access screen a grant naming an account its own
20
- // tenant does not hold, and an account named in a grant that no customer bundle matches. It computed
21
- // them AFTER the mistake shipped.
18
+ // The change itself is made by `withPerson` (shared/grants-edit.mjs), the editor the portal's People
19
+ // page writes through, so the two writers cannot produce two shapes of one fact and a company the
20
+ // organisation does not hold is refused there, by the same sentence the page would get.
22
21
  //
23
- // So this command does not grow a second opinion. It builds the grants object it is about to write,
24
- // runs `accessView` OVER THAT PROPOSED STATE, and refuses if the change would introduce either finding.
25
- // The screen's report and this refusal are the same code by construction they cannot drift, and there
26
- // is no second definition of "a good grant" to keep in step.
22
+ // The product also knew the other typo direction: `accessView` (driver/portal-config-view.mjs) reports a
23
+ // company that no customer bundle matches. It reported it AFTER the mistake shipped. So this command runs
24
+ // `accessView` OVER THE PROPOSED STATE, seen from the top of the tree, and refuses if the change would
25
+ // introduce that finding. The screen's report and this refusal are the same code by construction.
27
26
  //
28
27
  // The email rule is borrowed the same way: `makePrincipal` refuses a multi-`@` identity outright, so a
29
28
  // grant for one could never resolve. Rather than restate the rule, this asks `makePrincipal`.
30
29
  //
30
+ // ── A PERSON IS ACCESS PLUS TWO PERMISSIONS ───────────────────────────────────────────────────────
31
+ //
32
+ // Access is where on the tree a person may look: a whole organisation (`--accounts '*'`) or companies
33
+ // it holds. What they may DO is two switches under `people`: `--run` (start and stop clearances) and
34
+ // `--manage` (add people and companies, change settings). Neither flag is the view-only person, and the
35
+ // output says so in a sentence — someone who can read a company's reports and start nothing is a real
36
+ // posture, not a mistake, but it must never be the surprise.
37
+ //
38
+ // Access to everything is not granted here. The person who installs holds it (`clearotron start`
39
+ // writes that entry), and it is CARRIED through every change this command makes to their entry, never
40
+ // dropped: `withPerson` replaces the entry it writes, and a person who held everything and was then
41
+ // given Run here would otherwise lose the whole install with nothing said.
42
+ //
31
43
  // ── THE FILE STAYS AUTHORITATIVE ──────────────────────────────────────────────────────────────────
32
44
  //
33
45
  // This is an editor for that file and never a second store. It stays hand-editable, and anything this
34
- // command writes a human can read and change back. No browser write path is added here or anywhere.
46
+ // command writes a human can read and change back.
35
47
  //
36
48
  // WRITTEN ATOMICALLY, because the portal reads this file PER REQUEST — that is what makes a grant land
37
49
  // without a restart, and it is also what makes a non-atomic write dangerous: a reader catching a
@@ -52,6 +64,7 @@
52
64
  import "../shared/env-local.mjs";
53
65
  import { readFileSync, existsSync } from "node:fs";
54
66
  import { assertGrantsShape } from "../shared/scope.mjs"; // — one shape check, not a second opinion
67
+ import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs"; // — the People page's own editors
55
68
  import { basename } from "node:path";
56
69
  import { atomicWrite } from "../driver/progress.mjs";
57
70
  import { accessView } from "../driver/portal-config-view.mjs";
@@ -74,7 +87,7 @@ const out = (msg) => console.log(msg);
74
87
  /** The grants file, or a stated refusal. NEVER an invented empty one — a typo in the path would then
75
88
  * silently create a second guest list nobody reads. */
76
89
  function readGrants() {
77
- if (!existsSync(FILE)) die(`No guest list at ${FILE}. \`clearotron start\` writes an empty one the first time it runs — start the product once, or set CLEAROTRON_ACCESS_FILE if your roster lives elsewhere.`);
90
+ if (!existsSync(FILE)) die(`No guest list at ${FILE}. \`clearotron start\` writes one the first time it runs — start the product once, or set CLEAROTRON_ACCESS_FILE if your roster lives elsewhere.`);
78
91
  let g;
79
92
  try { g = JSON.parse(readFileSync(FILE, "utf8")); }
80
93
  catch (e) { die(`${basename(FILE)} is not valid JSON (${e.message}). Refusing to touch it — fix it by hand, or the portal will 500 on every request until you do.`); }
@@ -90,49 +103,66 @@ function readGrants() {
90
103
  }
91
104
 
92
105
  /**
93
- * Every account key a customer bundle actually holds, or NULL when that cannot be established.
106
+ * Every company a customer bundle actually holds, as `{ key: name }`, or NULL when that cannot be
107
+ * established.
94
108
  *
95
- * EMPTY IS NULL HERE, DELIBERATELY. `accessView` guards its unknown-account check with
96
- * `if (known.size && …)` — so an empty set means the check DOES NOT RUN and reports nothing, which is
97
- * indistinguishable from running and finding nothing. Collapsing empty into null makes the command say
98
- * "not checked" instead of implying "checked and clean": not probed is not passed.
109
+ * EMPTY IS NULL HERE, DELIBERATELY. `accessView` guards its unknown-company check with `known.size` — so
110
+ * an empty map means the check DOES NOT RUN and reports nothing, which is indistinguishable from running
111
+ * and finding nothing. Collapsing empty into null makes the command say "not checked" instead of implying
112
+ * "checked and clean": not probed is not passed.
99
113
  */
100
- async function knownAccounts() {
114
+ async function knownCompanies() {
101
115
  try {
102
116
  const { loadProfiles } = await import("../driver/profiles.mjs");
103
- const keys = [...loadProfiles({ force: true }).keys()];
104
- return keys.length ? keys : null;
117
+ const named = [...loadProfiles({ force: true }).entries()]
118
+ .map(([key, p]) => [key, typeof p?.name === "string" && p.name.trim() ? p.name.trim() : key]);
119
+ return named.length ? Object.fromEntries(named) : null;
105
120
  } catch { return null; }
106
121
  }
107
122
 
108
123
  /**
109
124
  * Would this proposed state be a good one? Asked of `accessView`, so the answer is the screen's answer.
110
125
  *
126
+ * SEEN FROM THE TOP OF THE TREE. The screen hides what its viewer cannot see; this command is run by
127
+ * whoever administers the box, and a finding hidden from it would be a check that did not run.
128
+ *
111
129
  * Returns the findings ATTRIBUTABLE TO THIS CHANGE only. A guest list that already contains someone
112
130
  * else's dangling grant is not this operator's problem to fix before they can add a colleague, and
113
131
  * refusing on it would make the command unusable on exactly the messy file it exists to tidy.
114
132
  */
115
- function faultsIntroduced(before, after, known, email) {
116
- const view = (g) => accessView({ grants: g, knownAccounts: known ?? [], grantsFile: null });
133
+ function faultsIntroduced(before, after, companies, email) {
134
+ const view = (g) => accessView({ grants: g, viewer: { everything: true }, companies: companies ?? {} });
117
135
  const b = view(before), a = view(after);
118
- const rowFor = (v) => v.people.filter((p) => p.email === email);
119
- const dangling = rowFor(a).flatMap((p) => p.dangling);
136
+ const dangling = a.people.filter((p) => p.email === email).flatMap((p) => p.dangling);
120
137
  const newUnknown = a.unknownAccounts.filter((x) => !b.unknownAccounts.includes(x));
121
138
  return { dangling: [...new Set(dangling)], unknownAccounts: newUnknown };
122
139
  }
123
140
 
141
+ /** What a person may do, in a sentence — the view-only case said as plainly as the others. */
142
+ function mayLine(email, entry) {
143
+ const run = entry?.run === true, manage = entry?.manage === true;
144
+ const whole = entry?.everything === true ? ", with access to everything on this install" : "";
145
+ if (!run && !manage)
146
+ return `${email} can view what this gives them and do nothing else: no clearance can be started, and nobody added${whole}. Add --run, --manage or both to change that.`;
147
+ return `${email} may ${[run && "run clearances", manage && "manage (add people and companies, change settings)"].filter(Boolean).join(" and ")}${whole}.`;
148
+ }
149
+
124
150
  function usage(code = 1) {
125
151
  // — HELP ASKED FOR GOES TO STDOUT; usage printed as a REFUSAL goes to stderr.
126
152
  // This wrote to stderr either way, and it was the only verb of nine that did: `clearotron grant
127
153
  // --help | less` showed nothing, and neither did any redirect a reader would try. The exit code
128
154
  // already carries the distinction, so it decides the stream.
129
155
  const out = code === 0 ? console.log : console.error;
130
- out(`clearotron grant — edit the guest list the portal reads
156
+ out(`clearotron grant — edit who may use this install
131
157
 
132
158
  grant list
133
- grant add <email> --tenant <name> --accounts <key,key|*>
134
- grant remove <email> [--tenant <name>]
135
- grant remove-tenant <name>
159
+ grant add <email> --tenant <organisation> --accounts <key,key|*> [--run] [--manage]
160
+ grant remove <email> [--tenant <organisation>]
161
+ grant remove-tenant <organisation>
162
+
163
+ --accounts * is the whole organisation, including companies filed under it later. --run lets the
164
+ person start and stop clearances; --manage lets them add people and companies and change settings.
165
+ With neither they can view what they have access to and nothing else.
136
166
 
137
167
  The file (${FILE || "CLEAROTRON_ACCESS_FILE unset"}) stays authoritative and hand-editable; this is an
138
168
  editor for it. The portal re-reads it per request, so a change lands with no restart.`);
@@ -145,18 +175,21 @@ const flag = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? ar
145
175
 
146
176
  if (!cmd || cmd === "--help" || cmd === "-h") usage(0);
147
177
 
148
- const grants = readGrants();
149
- const tenants = grants.tenants;
178
+ let grants = readGrants();
150
179
 
151
180
  if (cmd === "list") {
152
- const known = await knownAccounts();
153
- const v = accessView({ grants, knownAccounts: known ?? [], grantsFile: null });
154
- if (!v.people.length) out("The guest list is empty. Staff arrive by domain and are not listed here.");
181
+ const companies = await knownCompanies();
182
+ const v = accessView({ grants, viewer: { everything: true }, companies: companies ?? {} });
183
+ if (!v.people.length) out("Nobody has access yet. `clearotron start` gives the person who installs access to everything; `grant add` gives anyone else theirs.");
155
184
  for (const p of v.people) {
156
- const reach = p.wildcard ? `* (every account ${p.tenant} holds: ${p.accounts.join(", ") || "none"})` : p.accounts.join(", ") || "nothing";
157
- out(`${p.email}\n tenant ${p.tenant}\n reaches ${reach}${p.dangling.length ? `\n ⚠ DANGLING ${p.dangling.join(", ")} — ${p.tenant} does not hold ${p.dangling.length > 1 ? "these" : "this"}, so it resolves to nothing` : ""}`);
185
+ const reach = p.access.map((pt) => pt.kind === "everything" ? "everything on this install"
186
+ : pt.kind === "organisation"
187
+ ? `the whole of ${pt.name}${pt.name !== pt.key ? ` (${pt.key})` : ""} — every company it holds: ${(grants.tenants[pt.key]?.accounts ?? []).filter((a) => a !== "generic").join(", ") || "none yet"}`
188
+ : `${pt.key}, in ${pt.org}`).join("; ") || "nothing";
189
+ const may = [p.permissions.run && "run clearances", p.permissions.manage && "manage"].filter(Boolean).join(" and ") || "view only";
190
+ out(`${p.email}\n access ${reach}\n may ${may}${p.dangling.length ? `\n ⚠ DANGLING ${p.dangling.join(", ")} — the organisation that grants ${p.dangling.length > 1 ? "these does not hold them" : "this does not hold it"}, so it resolves to nothing` : ""}`);
158
191
  }
159
- if (known === null) out("\nNote: customer bundles could not be read, so accounts were NOT checked against them.");
192
+ if (companies === null) out("\nNote: customer bundles could not be read, so companies were NOT checked against them.");
160
193
  else if (v.unknownAccounts.length) out(`\n⚠ Named in grants but matching no customer bundle: ${v.unknownAccounts.join(", ")}`);
161
194
  process.exit(0);
162
195
  }
@@ -165,72 +198,88 @@ if (cmd === "add") {
165
198
  const email = String(argv[1] ?? "").trim().toLowerCase();
166
199
  const tenant = flag("tenant");
167
200
  const accountsArg = flag("accounts");
201
+ const run = argv.includes("--run");
202
+ const manage = argv.includes("--manage");
168
203
  if (!email || !tenant || !accountsArg) usage();
169
204
 
170
- // THE EMAIL RULE IS makePrincipal's, asked rather than restated. A multi-@ identity is refused there
171
- // outright, so a grant for one could never resolve writing it would be writing a line that cannot
172
- // ever match, which is the silent-uselessness this command exists to prevent.
173
- if (!makePrincipal({ email, grants: { tenants: {} }, staffDomains: [] }) && email.indexOf("@") !== email.lastIndexOf("@"))
174
- die(`"${email}" carries more than one @. The portal refuses those identities outright, so this grant could never match anyone.`);
175
- if (!email.includes("@")) die(`"${email}" is not an email address.`);
205
+ // THE EMAIL RULE IS makePrincipal's, asked rather than restated. Asked over a file in which this address
206
+ // would see everything, so the only way it resolves to nobody is that the resolver refused the address
207
+ // itself which it does, outright, for a multi-@ identity. Writing that grant would be writing a line
208
+ // that cannot ever match, which is the silent-uselessness this command exists to prevent.
209
+ if (!makePrincipal({ email, grants: { tenants: {}, people: { [email]: { everything: true } } } })) {
210
+ if (email.indexOf("@") !== email.lastIndexOf("@"))
211
+ die(`"${email}" carries more than one @. The portal refuses those identities outright, so this grant could never match anyone.`);
212
+ die(`"${email}" is not an email address.`);
213
+ }
214
+ const list = accountsArg === "*" ? "*" : accountsArg.split(",").map((s) => s.trim()).filter(Boolean);
215
+ if (list !== "*" && !list.length) die("--accounts named nothing. Use a comma-separated list of account keys, or * for the whole organisation.");
176
216
 
177
217
  // ── A REFUSAL THAT NAMES THE ROUTE ( — F38) ─────────────────────────────
178
218
  //
179
219
  // This used to end at "it does not create one", and NOTHING IN THE CLI CREATES ONE: `brandowner`
180
- // never writes a tenant, this command refuses to, and `connect` sends the reader here. On a fresh
181
- // install the file is {"tenants":{}}, so the documented first run was connect → "run grant" → grant →
220
+ // never writes a tenant, this command refused to, and `connect` sends the reader here. On a fresh
221
+ // install the file named no tenant, so the documented first run was connect → "run grant" → grant →
182
222
  // "a tenant must already exist" → nothing. A dead end reached by following instructions.
183
223
  //
184
- // The route exists it is the file and `grant --help` documents it. What was missing is that the
185
- // refusal which STOPS you did not carry it, so the shape of the object is printed here, against this
186
- // install's own path, rather than left to a reader to find in another command's help.
187
- // — found in review. THE FIRST TENANT IS CREATED HERE. Before this, enrolling anyone
188
- // on a fresh install ran out of road: the file is {"tenants":{}}, `brandowner` does not write tenants,
189
- // and this command refused because none existed. 2176's F38 fix printed the JSON shape into the
190
- // refusal, which is better than a bare stop and still asks a person to hand-edit the file the product
191
- // is meant to manage.
224
+ // THE FIRST TENANT IS CREATED HERE ONLY WHEN THE ROSTER HOLDS NONE, and that limit is the point.
225
+ // Creating on any unknown name would mean a typo mints an organisation nobody meant, silently, in the
226
+ // file the door reads so once there is a roster to typo against, the refusal stays and lists what
227
+ // exists.
192
228
  //
193
- // ONLY WHEN THE ROSTER IS EMPTY, and that limit is the point. Creating on any unknown name would mean
194
- // a typo mints a tenant nobody meant, silently, in the file the door reads so once there is a roster
195
- // to typo against, the refusal stays and lists what exists.
196
- const firstTenant = !tenants[tenant] && Object.keys(tenants).length === 0;
197
- if (firstTenant) {
198
- // `--accounts` is already required by the usage check above, so there is no second guard for it
199
- // here: a branch that cannot be reached reads as protection and provides none.
200
- //
201
- // SEEDED FROM --accounts, not created empty. Created empty, the very next check — "tenant does not
202
- // hold <key>" — refuses, nothing is written, and the first tenant can never be granted anything:
203
- // the dead end moves one line down rather than closing. Found by driving the enrolment rather than
204
- // reading the diff. For a tenant that does not exist yet, the accounts this grant names ARE the
205
- // accounts the tenant covers; a narrower set for the user comes from editing it afterwards.
206
- tenants[tenant] = {
207
- accounts: accountsArg === "*" ? "*" : accountsArg.split(",").map((x) => x.trim()).filter(Boolean),
208
- users: {},
209
- };
210
- out(`Creating the first tenant "${tenant}" in ${FILE}, holding: ${accountsArg}`);
229
+ // THROUGH THE SHARED EDITORS. `withOrganisation` derives the key from the name, so the key is taken
230
+ // from what it returns rather than assumed equal to what was typed. SEEDED FROM --accounts, not
231
+ // created empty: for an organisation that does not exist yet, the companies this grant names ARE the
232
+ // companies it holds. `*` names none, so the organisation starts holding no company and the person gets
233
+ // the whole of it — a legitimate state, and the only one: an organisation's list is never "*".
234
+ let key = tenant;
235
+ if (!grants.tenants[tenant] && Object.keys(grants.tenants).length === 0) {
236
+ try {
237
+ const made = withOrganisation(grants, { name: tenant });
238
+ key = made.key;
239
+ grants = made.grants;
240
+ for (const account of list === "*" ? [] : list) grants = withCompany(grants, { tenant: key, account });
241
+ } catch (e) { die(`Refusing: ${e.message}. Nothing written.`); }
242
+ out(`Creating the first tenant "${key}" in ${FILE}, holding: ${list === "*" ? "no company yet — * gives the person the whole organisation, including companies filed under it later" : list.join(", ")}`);
211
243
  }
212
- if (!tenants[tenant]) die(`No tenant "${tenant}". It must already exist with its accounts — this command grants access to a tenant, and it creates one only when there are none at all, so that a typo cannot mint a tenant nobody meant.
213
- Tenants: ${Object.keys(tenants).join(", ")}
244
+ if (!grants.tenants[key]) die(`No tenant "${tenant}". It must already exist with its companies — this command grants access to a tenant, and it creates one only when there are none at all, so that a typo cannot mint a tenant nobody meant.
245
+ Tenants: ${Object.keys(grants.tenants).join(", ")}
214
246
 
215
247
  To add another, put it in ${FILE} — the shape is:
216
- { "tenants": { "${tenant}": { "accounts": ["<brand-owner-key>"], "users": {} } } }
217
- "accounts" holds the brand-owner keys this tenant may act for; \`clearotron doctor\` names the ones this install has, under "brand owner(s) resolve here". Then run this command again.`);
248
+ { "tenants": { "${tenant}": { "name": "<its name>", "accounts": ["<company-key>"], "users": {} } } }
249
+ "accounts" holds the company keys this tenant holds, and a company belongs to one tenant only; \`clearotron doctor\` names the ones this install has, under "brand owner(s) resolve here". Then run this command again.`);
218
250
 
219
- const before = JSON.parse(JSON.stringify(grants));
220
- const value = accountsArg === "*" ? "*" : accountsArg.split(",").map((s) => s.trim()).filter(Boolean);
221
- if (value !== "*" && !value.length) die("--accounts named nothing. Use a comma-separated list of account keys, or * for every account the tenant holds.");
222
- tenants[tenant].users = { ...(tenants[tenant].users ?? {}), [email]: value };
251
+ const before = structuredClone(grants);
252
+ const held = (Array.isArray(grants.tenants[key].accounts) ? grants.tenants[key].accounts : []).join(", ") || "(none)";
253
+ const points = list === "*" ? [{ tenant: key }] : list.map((account) => ({ tenant: key, account }));
254
+ const peopleKey = Object.keys(grants.people ?? {}).find((k) => k.toLowerCase() === email);
255
+ const had = peopleKey === undefined ? null : grants.people[peopleKey];
256
+ // THE SWITCHES ARE WRITTEN ONLY WHEN ONE IS ASKED FOR. A person with no entry is already view-only, so
257
+ // writing `{ run: false, manage: false }` for them adds a line that says nothing; and a person who
258
+ // already holds Run must not lose it because a company was added without repeating the flag.
259
+ const setSwitches = run || manage;
260
+ let after;
261
+ try {
262
+ after = withPerson(grants, { email, points, switches: { run, manage, everything: had?.everything === true }, setSwitches });
263
+ } catch (e) {
264
+ if (/does not hold/.test(e.message))
265
+ die(`Refusing: ${e.message}.\nThat grant would resolve to nothing and fail as a silent 404 for ${email}.\nAccounts "${key}" holds: ${held}. Nothing written.`);
266
+ die(`Refusing: ${e.message}. Nothing written.`);
267
+ }
223
268
 
224
- const known = await knownAccounts();
225
- const faults = faultsIntroduced(before, grants, known, email);
269
+ const companies = await knownCompanies();
270
+ const faults = faultsIntroduced(before, after, companies, email);
226
271
  if (faults.dangling.length)
227
- die(`Refusing: tenant "${tenant}" does not hold ${faults.dangling.join(", ")}.\nThat grant would resolve to nothing and fail as a silent 404 for ${email}.\nAccounts "${tenant}" holds: ${(tenants[tenant].accounts ?? []).join(", ") || "(none)"}`);
272
+ die(`Refusing: tenant "${key}" does not hold ${faults.dangling.join(", ")}.\nThat grant would resolve to nothing and fail as a silent 404 for ${email}.\nAccounts "${key}" holds: ${held}`);
228
273
  if (faults.unknownAccounts.length)
229
274
  die(`Refusing: ${faults.unknownAccounts.join(", ")} ${faults.unknownAccounts.length > 1 ? "match" : "matches"} no customer bundle.\nThe grant would be written and reach nothing. Check the account key, or add the bundle first.`);
230
- if (known === null) console.error("Note: customer bundles could not be read, so the account keys were NOT checked against them.");
275
+ if (companies === null) console.error("Note: customer bundles could not be read, so the account keys were NOT checked against them.");
231
276
 
232
- atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
233
- out(`${email} → ${tenant} (${value === "*" ? "every account the tenant holds" : value.join(", ")})\nWritten to ${basename(FILE)}. The portal re-reads per request — no restart.`);
277
+ atomicWrite(FILE, JSON.stringify(after, null, 2) + "\n");
278
+ out(`${email} → ${key} (${list === "*" ? "the whole organisation" : list.join(", ")})`);
279
+ if (setSwitches) out(mayLine(email, after.people?.[email]));
280
+ else if (had) out(`${mayLine(email, had)} Their permissions are unchanged; pass --run and --manage to set them.`);
281
+ else out(mayLine(email, null));
282
+ out(`Written to ${basename(FILE)}. The portal re-reads per request — no restart.`);
234
283
  process.exit(0);
235
284
  }
236
285
 
@@ -239,7 +288,7 @@ if (cmd === "remove") {
239
288
  const only = flag("tenant");
240
289
  if (!email) usage();
241
290
  let removed = 0;
242
- for (const [name, t] of Object.entries(tenants)) {
291
+ for (const [name, t] of Object.entries(grants.tenants)) {
243
292
  if (only && name !== only) continue;
244
293
  if (t?.users && email in t.users) { delete t.users[email]; removed++; }
245
294
  // AN EMPTY `users` MAP IS NOT A DELETED TENANT, and this writes the former deliberately: the tenant
@@ -247,18 +296,27 @@ if (cmd === "remove") {
247
296
  // tenant here would destroy configuration the operator never asked to remove, and it round-trips —
248
297
  // `remove-tenant` is the other verb, and it is explicit.
249
298
  }
250
- if (!removed) die(`${email} is not on the guest list${only ? ` for "${only}"` : ""}. Nothing written.`);
299
+ // AND THE PERSON'S OWN ENTRY, when they are removed from everywhere. Their permissions and their access
300
+ // to everything live under `people`, not in any tenant, so a removal that left that entry would leave a
301
+ // person who sees everything still seeing everything. Removed from one tenant, they may still hold
302
+ // access elsewhere, and their entry stays.
303
+ const peopleKey = only ? undefined : Object.keys(grants.people ?? {}).find((k) => k.toLowerCase() === email);
304
+ if (peopleKey !== undefined) delete grants.people[peopleKey];
305
+ if (!removed && peopleKey === undefined) die(`${email} is not on the guest list${only ? ` for "${only}"` : ""}. Nothing written.`);
251
306
  atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
252
- out(`Removed ${email} from ${removed} tenant(s). Written to ${basename(FILE)}.`);
307
+ out(`Removed ${email} from ${removed} tenant(s)${peopleKey !== undefined ? ", and their permissions under people" : ""}. Written to ${basename(FILE)}.`);
308
+ const kept = Object.entries(grants.people ?? {}).find(([k]) => k.toLowerCase() === email)?.[1];
309
+ if (kept?.everything === true)
310
+ out(`${email} still has access to everything through their entry under people. \`grant remove ${email}\` without --tenant takes that away too.`);
253
311
  process.exit(0);
254
312
  }
255
313
 
256
314
  if (cmd === "remove-tenant") {
257
315
  const name = String(argv[1] ?? "").trim();
258
316
  if (!name) usage();
259
- if (!tenants[name]) die(`No tenant "${name}". Nothing written.`);
260
- const people = Object.keys(tenants[name].users ?? {}).length;
261
- delete tenants[name];
317
+ if (!grants.tenants[name]) die(`No tenant "${name}". Nothing written.`);
318
+ const people = Object.keys(grants.tenants[name].users ?? {}).length;
319
+ delete grants.tenants[name];
262
320
  atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
263
321
  out(`Removed tenant "${name}"${people ? ` and the ${people} grant(s) it held` : ""}. Written to ${basename(FILE)}.`);
264
322
  process.exit(0);