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
package/shared/scope.mjs CHANGED
@@ -64,7 +64,7 @@ export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_
64
64
  *
65
65
  * NOT AN AUTHENTICATOR. This parses without verifying, which is safe for exactly one job: reading the
66
66
  * revocation handle out of our own `mintToken` output so `clearotron connect` can write it down
67
- * (owner ruling: record key IDs, never secrets). Anything answering "is this token
67
+ * (ruling: record key IDs, never secrets). Anything answering "is this token
68
68
  * good" goes through `verifyToken`; a caller handing this function a token from the WIRE is the defect.
69
69
  *
70
70
  * Returns null rather than throwing on a malformed string — the caller is recording, and a record of
@@ -212,7 +212,7 @@ export const TOOL_SCOPES = {
212
212
  // an account session is answered from ITS OWN grant, never from the customer list.
213
213
  // `passthrough`, flagged with plan_run — same posture, same open question.
214
214
  describe_options: { crossRun: true, accountSafe: true, present: "passthrough" },
215
- // ---- WHAT-IF, OPENED TO A CLIENT ACCOUNT (owner ruling 2026-08-27) -------------------------------
215
+ // ---- WHAT-IF, OPENED TO A CLIENT ACCOUNT (ruling 2026-08-27) -------------------------------
216
216
  //
217
217
  // `write: true` stays on BOTH, and on what_if_plan that is deliberate rather than inherited. The flag's
218
218
  // stated meaning is "mutates state or spends", and a plan does neither — but the ONLY thing that reads
@@ -230,7 +230,7 @@ export const TOOL_SCOPES = {
230
230
  // note and drops the other two. The enqueue acknowledgement is projected for the same reason every
231
231
  // client-reachable result is — so a field added to it later cannot arrive unruled.
232
232
  //
233
- // `readOnly` IS SET HERE BECAUSE `write` IS THE WRONG ANSWER FOR THIS ONE TOOL (tracker issue 148).
233
+ // `readOnly` IS SET HERE BECAUSE `write` IS THE WRONG ANSWER FOR THIS ONE TOOL.
234
234
  // The MCP `readOnlyHint` annotation is derived from `write` so there is one source of truth and not
235
235
  // two names for one fact — but the paragraph above says plainly that `write: true` sits on
236
236
  // what_if_plan for the OPS ALLOWLIST's sake, not because it mutates or spends. Deriving the hint
@@ -263,7 +263,7 @@ export const TOOL_SCOPES = {
263
263
  // status.json, …) is internal and stays sealed from a user token.
264
264
  // Exported so the server's Resources surface (ListResources/ReadResource) gates to the SAME set.
265
265
  /**
266
- * Does this tool only READ? — the source of MCP's `readOnlyHint` annotation (tracker issue 148).
266
+ * Does this tool only READ? — the source of MCP's `readOnlyHint` annotation.
267
267
  *
268
268
  * WHY IT IS DERIVED. Without annotations a client cannot tell `brief` from `start_run`, so it asks
269
269
  * before every call — the owner, driving the ops connector: "it prompts all the time." The cost is not
@@ -286,7 +286,7 @@ export function readOnlyFor(toolName) {
286
286
 
287
287
  export const USER_ARTIFACTS = new Set(["report"]);
288
288
 
289
- // ---- THE AUDIT CHAIN, OPENED TO A CLIENT ACCOUNT (owner ruling 2026-08-27) -----------------------
289
+ // ---- THE AUDIT CHAIN, OPENED TO A CLIENT ACCOUNT (ruling 2026-08-27) -----------------------
290
290
  //
291
291
  // "I don't see why we don't open it or just give it to clients. Ignore the call spend." That ruling
292
292
  // widens the line the TOOL_SCOPES header draws above — the one that read "every engineering read stays
@@ -401,20 +401,47 @@ export function loadGrants({ grantsPath = envFrom(process.env, "CLEAROTRON_ACCES
401
401
  // can still fix it, not on the next client's request. The message names the path INTO the file, what was
402
402
  // found, and what the shape is — a reader has to be able to go straight to the line.
403
403
  //
404
- // Note the two legal shapes and that "*" is one of them: `accounts` is "*" or an array of keys, and a
405
- // user maps to "*" (the tenant's whole grant) or an array. Anything else is refused rather than coerced,
406
- // for the reason the file's own header gives — a configured guest list must never fail open.
404
+ // THE TREE. A tenant is an organisation, and it lists the companies (account keys) it holds. A company
405
+ // belongs to exactly one organisation, so a key listed under two tenants is refused: two organisations
406
+ // are invisible to each other because they are sibling branches, and a company under both branches has
407
+ // no single place in the tree. Access to the whole install is a property of a PERSON, not of a tenant,
408
+ // so a tenant's `accounts` is an array and never "*".
409
+ //
410
+ // A user maps to "*" (the whole organisation) or an array of the keys that organisation holds. Anything
411
+ // else is refused rather than coerced, for the reason the file's own header gives — a configured guest
412
+ // list must never fail open.
413
+ //
414
+ // THE PEOPLE. A top-level `people` section, keyed by address, holds each person's two switches — `run`
415
+ // (start and stop clearances) and `manage` (add people, add companies, change settings) — and
416
+ // `everything`, access to the top of the tree. A sibling of `tenants` for the reason `connectKeys` is
417
+ // one: every editor that checks only `tenants` passes it through. A person with no entry holds both
418
+ // switches off, which is the view-only person; nothing is granted by leaving a line out.
419
+ const PERSON_FIELDS = ["run", "manage", "everything"];
407
420
  export function assertGrantsShape(g, where) {
408
421
  const at = (...parts) => `${where}: tenants.${parts.join(".")}`;
409
422
  const keys = (v) => Array.isArray(v) && v.every((k) => typeof k === "string");
423
+ const holder = new Map();
410
424
  for (const [tenant, t] of Object.entries(g.tenants ?? {})) {
411
425
  if (!t || typeof t !== "object" || Array.isArray(t)) {
412
426
  throw new Error(`${at(tenant)} is ${describe(t)} — each tenant must be an object with "accounts" and "users".`);
413
427
  }
414
- if (t.accounts !== undefined && t.accounts !== "*" && !keys(t.accounts)) {
415
- throw new Error(`${at(tenant, "accounts")} is ${describe(t.accounts)} — it must be "*" or an array of account keys, `
428
+ if (t.name !== undefined && (typeof t.name !== "string" || !t.name.trim())) {
429
+ throw new Error(`${at(tenant, "name")} is ${describe(t.name)} — it must be the organisation's name, as text.`);
430
+ }
431
+ if (t.accounts === "*") throw new Error(wildcardTenant(`${where}: tenants.${tenant}.accounts`));
432
+ if (t.accounts !== undefined && !keys(t.accounts)) {
433
+ throw new Error(`${at(tenant, "accounts")} is ${describe(t.accounts)} — it must be an array of account keys, `
416
434
  + `for example ["${tenant}"].`);
417
435
  }
436
+ for (const a of t.accounts ?? []) {
437
+ if (a === "generic") continue; // the house default, not a company — every organisation has its own
438
+ if (holder.has(a)) {
439
+ throw new Error(`${where}: account "${a}" is listed under both tenants.${holder.get(a)} and tenants.${tenant} — `
440
+ + `a company belongs to exactly one organisation. Keep it under one, and give the people of the other `
441
+ + `access to it there.`);
442
+ }
443
+ holder.set(a, tenant);
444
+ }
418
445
  if (t.users !== undefined && (!t.users || typeof t.users !== "object" || Array.isArray(t.users))) {
419
446
  throw new Error(`${at(tenant, "users")} is ${describe(t.users)} — it must be an object mapping each email `
420
447
  + `(or "*@domain") to "*" or an array of account keys.`);
@@ -426,6 +453,37 @@ export function assertGrantsShape(g, where) {
426
453
  }
427
454
  }
428
455
  }
456
+ if (g.people === undefined) return;
457
+ if (!g.people || typeof g.people !== "object" || Array.isArray(g.people)) {
458
+ throw new Error(`${where}: people is ${describe(g.people)} — it must be an object mapping each email to that `
459
+ + `person's switches, for example {"you@example.com": {"run": true, "manage": true}}.`);
460
+ }
461
+ for (const [who, entry] of Object.entries(g.people)) {
462
+ const here = `${where}: people.${JSON.stringify(who)}`;
463
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
464
+ throw new Error(`${here} is ${describe(entry)} — it must be an object holding "run", "manage" and "everything", `
465
+ + `each true or false.`);
466
+ }
467
+ for (const [k, v] of Object.entries(entry)) {
468
+ if (!PERSON_FIELDS.includes(k)) {
469
+ throw new Error(`${here}.${k} is not a field of a person — a person holds ${PERSON_FIELDS.map((f) => `"${f}"`).join(", ")}, `
470
+ + `each true or false. A misspelt switch would read as off, so it is refused rather than ignored.`);
471
+ }
472
+ if (typeof v !== "boolean") throw new Error(`${here}.${k} is ${describe(v)} — it must be true or false.`);
473
+ }
474
+ if (String(who).startsWith("*@") && (entry.manage || entry.everything)) {
475
+ throw new Error(`${here} gives a whole email domain ${entry.everything ? "access to everything" : "Manage"} — `
476
+ + `that is the staff-by-domain rule this product removed. Name each person who should hold it.`);
477
+ }
478
+ }
479
+ }
480
+
481
+ // THE EDIT FIRST. An organisation left on "*" is what a guest list written before organisations carries
482
+ // when the upgrade was not followed, so the refusal says what to change before it says why.
483
+ function wildcardTenant(path) {
484
+ return `${path} is "*". Replace "*" with the list of companies this organisation holds, for example `
485
+ + `["acme-main", "acme-eu"]; a company belongs to exactly one organisation. For a person who should see `
486
+ + `every company, set "everything": true on their entry under "people".`;
429
487
  }
430
488
 
431
489
  // What a reader sees, in the words of the file they wrote — "an object", not "[object Object]". The
@@ -438,46 +496,108 @@ function describe(v) {
438
496
  return `${typeof v} ${JSON.stringify(v)}`;
439
497
  }
440
498
 
441
- // Resolve an authenticated email's granted accounts: "*" (everything), [keys], or [] (authenticated but
442
- // granted nothing). A user entry of "*" means "the tenant's whole grant"; a `*@domain` key matches every
443
- // email on that domain; an email in several tenants gets the union.
444
- export function accountsForEmail(email, grants) {
445
- if (!grants) return "*";
446
- const e = String(email ?? "").toLowerCase();
447
- const domain = e.includes("@") ? e.split("@")[1] : "";
448
- let all = false;
449
- const set = new Set();
450
- for (const t of Object.values(grants.tenants ?? {})) {
451
- for (const [pat, acc] of Object.entries(t.users ?? {})) {
452
- const p = String(pat).toLowerCase();
453
- if (p !== e && !(p.startsWith("*@") && domain && p.slice(2) === domain)) continue;
454
- const eff = acc === "*" ? t.accounts : acc;
455
- if (eff === "*") { all = true; continue; }
456
- // — refuse by name, never by TypeError. `loadGrants` checks this shape at
457
- // the read, which is where an operator can still fix it, but grants also arrive here from callers
458
- // that never went through it (an injected fixture, a store read elsewhere), so the resolver states
459
- // the same fault rather than iterating whatever it was handed.
460
- if (eff !== undefined && eff !== null && !Array.isArray(eff)) {
461
- throw new Error(`grants are malformed: the entry for "${pat}" resolves to ${describe(eff)} — it must be "*" `
499
+ /**
500
+ * Resolve an authenticated email to the PERSON: where on the tree they have access, and their two
501
+ * switches. The ONE resolver both doors read — the portal's `makePrincipal` and the connector's
502
+ * `resolveScope` — so the two cannot disagree about who someone is.
503
+ *
504
+ * null — no access point anywhere: no portal, and no connector reach
505
+ * { email, everything, permissions: { run, manage }, access, accounts, organisations, genericOrgs, accountOrgs }
506
+ *
507
+ * `access` is the points the person was given, collapsed: everything subsumes the rest, and an
508
+ * organisation subsumes the companies under it. `accounts` is every company they see, "*" for
509
+ * everything, and never `generic`, which is not a company. `organisations` is every organisation they see
510
+ * anything in; `genericOrgs` is the ones whose Generic they see — an organisation-level point or
511
+ * everything, never a company-level point. `accountOrgs` maps each visible company to its organisation.
512
+ *
513
+ * A user entry of "*" is the whole organisation; a `*@domain` key matches every email on that domain; an
514
+ * email in several tenants gets the union. A company point counts only under the tenant that HOLDS the
515
+ * company: a row naming a key its own tenant does not hold has no place in the tree and grants nothing,
516
+ * which is what the People page's "dangling" has always said it does.
517
+ */
518
+ export function resolvePerson(email, grants) {
519
+ if (!grants) return null;
520
+ const e = String(email ?? "").trim().toLowerCase();
521
+ const at = e.lastIndexOf("@");
522
+ if (at <= 0 || e.indexOf("@") !== at) return null; // multi-@ is refused outright — no parse to disagree about
523
+ const domain = e.slice(at + 1);
524
+ const matches = (pat) => { const p = String(pat).toLowerCase(); return p === e || (p.startsWith("*@") && p.slice(2) === domain); };
525
+ const tenants = grants.tenants ?? {};
526
+ const holder = new Map();
527
+ for (const [t, v] of Object.entries(tenants)) {
528
+ // Stated here as well as at the read: grants also arrive from callers that never went through
529
+ // `loadGrants` (an injected fixture, a store read elsewhere), and a wildcard tenant would otherwise
530
+ // be read as "holds nothing" — quietly narrower, and silent about why.
531
+ if (v?.accounts === "*") throw new Error(`grants are malformed: ${wildcardTenant(`tenants.${t}.accounts`)}`);
532
+ for (const a of Array.isArray(v?.accounts) ? v.accounts : []) if (a !== "generic" && !holder.has(a)) holder.set(a, t);
533
+ }
534
+ // The switches. An exact address wins over a `*@domain` entry, and no entry at all is both off. A domain
535
+ // never holds Manage or everything, re-stated here for grants that never passed `assertGrantsShape`.
536
+ const people = grants.people && typeof grants.people === "object" ? grants.people : {};
537
+ const exact = Object.keys(people).find((k) => k.toLowerCase() === e);
538
+ const pattern = exact === undefined ? Object.keys(people).find((k) => k.toLowerCase() === `*@${domain}`) : undefined;
539
+ const key = exact ?? pattern;
540
+ const entry = key === undefined ? {} : (people[key] ?? {});
541
+ const everything = entry.everything === true && exact !== undefined;
542
+ const permissions = { run: entry.run === true, manage: entry.manage === true && exact !== undefined };
543
+
544
+ const orgs = [];
545
+ const companies = [];
546
+ for (const [t, v] of Object.entries(tenants)) {
547
+ for (const [pat, acc] of Object.entries(v?.users ?? {})) {
548
+ if (!matches(pat)) continue;
549
+ if (acc === "*") { if (!orgs.includes(t)) orgs.push(t); continue; }
550
+ // — refuse by name, never by TypeError, for the same reason as the wildcard above.
551
+ if (acc !== undefined && acc !== null && !Array.isArray(acc)) {
552
+ throw new Error(`grants are malformed: the entry for "${pat}" resolves to ${describe(acc)} — it must be "*" `
462
553
  + `or an array of account keys. Fix the grants file (CLEAROTRON_ACCESS_FILE) and try again.`);
463
554
  }
464
- for (const a of eff ?? []) set.add(a);
555
+ for (const k of acc ?? []) if (holder.get(k) === t && !companies.includes(k)) companies.push(k);
465
556
  }
466
557
  }
467
- return all ? "*" : [...set];
558
+ if (!everything && !orgs.length && !companies.length) return null;
559
+
560
+ const all = Object.keys(tenants);
561
+ if (everything) {
562
+ return { email: e, everything, permissions, access: [{ kind: "everything" }], accounts: "*",
563
+ organisations: all, genericOrgs: all, accountOrgs: Object.fromEntries(holder) };
564
+ }
565
+ const loose = companies.filter((k) => !orgs.includes(holder.get(k)));
566
+ const accounts = [];
567
+ for (const t of orgs) for (const a of tenants[t].accounts ?? []) if (holder.get(a) === t && !accounts.includes(a)) accounts.push(a);
568
+ for (const k of loose) if (!accounts.includes(k)) accounts.push(k);
569
+ const organisations = [...orgs];
570
+ for (const k of loose) if (!organisations.includes(holder.get(k))) organisations.push(holder.get(k));
571
+ return { email: e, everything, permissions,
572
+ access: [...orgs.map((k) => ({ kind: "organisation", key: k })), ...loose.map((k) => ({ kind: "company", key: k, org: holder.get(k) }))],
573
+ accounts, organisations, genericOrgs: [...orgs],
574
+ accountOrgs: Object.fromEntries(accounts.map((a) => [a, holder.get(a)])) };
575
+ }
576
+
577
+ // The flat view of the same answer, for the callers that only ask which companies: "*" (everything),
578
+ // [keys], or [] (authenticated but granted nothing). No grants file at all still answers "*" — every
579
+ // caller that must not read that as "every customer" refuses it by name (personScope, the boot gates).
580
+ export function accountsForEmail(email, grants) {
581
+ if (!grants) return "*";
582
+ return resolvePerson(email, grants)?.accounts ?? [];
468
583
  }
469
584
 
470
585
  // The account gate. scope.accounts: null|"*" ⇒ full visibility (enforcement off / full grant); [keys] ⇒
471
586
  // only those accounts. A run with NO account tag (pre-grants history) is visible only to full grants.
472
- export function assertAccountAccess(scope, accountKey, what = "this run") {
587
+ // A Generic run is its organisation's: visible to a session that sees that organisation's Generic
588
+ // (`genericOrgs`), and one filed under no organisation only to a full-grant session. The portal asks the
589
+ // same question in `mayReadRun` (driver/portal-access.mjs).
590
+ export function assertAccountAccess(scope, accountKey, what = "this run", organisation = null) {
473
591
  const acc = scope?.accounts;
474
592
  if (acc == null || acc === "*") return;
475
593
  if (!Array.isArray(acc)) throw new Error("malformed scope.accounts");
476
594
  if (accountKey == null) throw new Error(`${what} carries no account tag — visible only to full-grant sessions`);
595
+ if (accountKey === "generic" && organisation != null && Array.isArray(scope?.genericOrgs)
596
+ && scope.genericOrgs.includes(organisation)) return;
477
597
  if (!acc.includes(accountKey)) throw new Error(`your grant does not include account "${accountKey}"`);
478
598
  }
479
- export function accountVisible(scope, accountKey) {
480
- try { assertAccountAccess(scope, accountKey); return true; } catch { return false; }
599
+ export function accountVisible(scope, accountKey, organisation = null) {
600
+ try { assertAccountAccess(scope, accountKey, "this run", organisation); return true; } catch { return false; }
481
601
  }
482
602
 
483
603
  // Ops-token issuance (INSTALL.md §8): `sub` names the PRINCIPAL the token was minted
@@ -562,17 +682,41 @@ export function verifyToken(token, { now = Date.now() } = {}) {
562
682
  // (403, not an empty read-all); and `cap` — an account token's optional accounts[] — can only NARROW the
563
683
  // grant, so a key whose cap no longer intersects its identity's grant resolves to nothing and is refused
564
684
  // rather than silently widening back to the full grant.
565
- function grantedAccounts(identity, cap = null) {
566
- const granted = accountsForEmail(identity, loadGrants());
567
- if (!Array.isArray(granted))
685
+ //
686
+ // THE KEY PROVES WHO; THE PERSON'S ENTRY DECIDES WHAT, AT THE MOMENT OF THE CALL. The scope carries the
687
+ // person's reach and switches from `resolvePerson` — the resolver the portal reads — so a key held by a
688
+ // person with access to everything reaches everything, a view-only person's key reads and never writes,
689
+ // and the two doors cannot disagree about one address.
690
+ function personScope(identity, cap = null) {
691
+ const grants = loadGrants();
692
+ if (!grants)
568
693
  throw new Error("forbidden: client account access requires a configured grants file (refusing an unscoped wildcard)");
569
- if (granted.length === 0)
694
+ const person = resolvePerson(identity, grants);
695
+ if (!person)
570
696
  throw new Error("forbidden: this identity is not granted any account");
571
- if (!Array.isArray(cap) || !cap.length) return granted;
572
- const narrowed = granted.filter((a) => cap.includes(a));
697
+ const whole = { accounts: person.accounts, everything: person.everything, permissions: person.permissions,
698
+ genericOrgs: person.genericOrgs };
699
+ if (!Array.isArray(cap) || !cap.length) return whole;
700
+ // A cap names companies, so it narrows to companies: Generic is not one, and a capped key reaches none.
701
+ const narrowed = person.accounts === "*" ? [...cap] : person.accounts.filter((a) => cap.includes(a));
573
702
  if (!narrowed.length)
574
703
  throw new Error("forbidden: this key is capped to accounts its identity is no longer granted");
575
- return narrowed;
704
+ return { ...whole, accounts: narrowed, everything: false, genericOrgs: [] };
705
+ }
706
+
707
+ // Which organisation's Generic a connector job means, on a Generic run only: the one named, which the
708
+ // person must see, or the one organisation they see. Neither is a job filed under none, which only a
709
+ // person who sees everything can order.
710
+ function tenantStamp(scope, asked) {
711
+ const orgs = Array.isArray(scope?.genericOrgs) ? scope.genericOrgs : [];
712
+ const t = typeof asked === "string" && asked.trim() ? asked.trim() : null;
713
+ if (t != null) {
714
+ if (!orgs.includes(t)) throw new Error(`your access does not include organisation "${t}"`);
715
+ return { tenant: t };
716
+ }
717
+ if (orgs.length === 1) return { tenant: orgs[0] };
718
+ if (scope?.everything === true) return {};
719
+ throw new Error(`name the organisation whose Generic this is (tenant) — your access covers ${orgs.length}`);
576
720
  }
577
721
 
578
722
  // True iff `email`'s domain (the part after the final '@') is one of firmDomains. PURE (no jose), so the HTTP
@@ -619,7 +763,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
619
763
  if (!innerToken) {
620
764
  if (!accountAccess)
621
765
  throw new Error("forbidden: the client surface requires a run-scoped token (no read-all/internal access)");
622
- return { kind: "account", runId: null, sub: email ?? null, verbs: null, accounts: grantedAccounts(email) };
766
+ return { kind: "account", runId: null, sub: email ?? null, verbs: null, ...personScope(email) };
623
767
  }
624
768
  const t = verifyToken(innerToken, { now });
625
769
  // An ACCOUNT token — the API key. Same principal as the CF-signed-in client above, reached with a
@@ -629,7 +773,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
629
773
  if (t.scope === "account") {
630
774
  if (!accountAccess)
631
775
  throw new Error("forbidden: client account access is not enabled on this door");
632
- return { kind: "account", runId: null, sub: t.sub, verbs: null, accounts: grantedAccounts(t.sub, t.accounts) };
776
+ return { kind: "account", runId: null, sub: t.sub, verbs: null, ...personScope(t.sub, t.accounts) };
633
777
  }
634
778
  if (t.scope !== "user") throw new Error("forbidden: the client surface accepts only a run-scoped user token or an account key");
635
779
  return { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
@@ -648,7 +792,19 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
648
792
  // `sub` carries the VERIFIED identity for every other principal (attribution, the audit log, the
649
793
  // forwarder stamp) and was the one arm that dropped it — a CF-authed staff member's email was known
650
794
  // here and thrown away, which is why a staff plan_run had no identity to stamp a forwarder from.
651
- if (firmStaff) return { kind: "internal", runId: null, sub: email ?? null, verbs: null, accounts: accountsForEmail(email, loadGrants()) };
795
+ //
796
+ // THE STAFF FACE READS THE SAME PERSON THE PORTAL DOES. `firmStaff` is the sign-in allowlist in front
797
+ // of this face; the reach behind it is the person's entry in the grants file, resolved as the portal
798
+ // resolves it. An allowed address with no entry reaches nothing, and no grants file at all is the
799
+ // enforcement-off posture it always was.
800
+ if (firmStaff) {
801
+ const grants = loadGrants();
802
+ const person = grants ? resolvePerson(email, grants) : null;
803
+ return { kind: "internal", runId: null, sub: email ?? null, verbs: null,
804
+ accounts: !grants ? "*" : person ? person.accounts : [],
805
+ everything: !grants || person?.everything === true, genericOrgs: person?.genericOrgs ?? [],
806
+ permissions: person?.permissions ?? { run: false, manage: false } };
807
+ }
652
808
  throw new Error("forbidden: no run-scoped token and not a firm-staff identity — refusing (internal read-all requires proven firm staff)");
653
809
  }
654
810
 
@@ -776,7 +932,7 @@ export function authorize(scope, toolName, args = {}) {
776
932
  if (kind === "account") {
777
933
  if (!rule.accountSafe)
778
934
  throw new Error(`tool "${toolName}" is not available to a client account session`);
779
- // THE AUDIT CHAIN (owner ruling 2026-08-27) — the account layer reads it, the report-link token below
935
+ // THE AUDIT CHAIN (ruling 2026-08-27) — the account layer reads it, the report-link token below
780
936
  // does not. Both gates were one line on USER_ARTIFACTS; they are two sets now, for the reason stated
781
937
  // at ACCOUNT_ARTIFACTS. The Resources surface in server.mjs gates on the SAME pair — two surfaces
782
938
  // disagreeing about one grant is the defect this file cites twice.
@@ -791,7 +947,7 @@ export function authorize(scope, toolName, args = {}) {
791
947
  throw new Error(`a client may only list findings by curated group (on-field | off-field | out-of-scope) — pass \`kind\` for the raw audit trail`);
792
948
  return { runId: args.runId, group: args.group }; // cards path: drop the raw-view args
793
949
  }
794
- // WHAT-IF (owner ruling 2026-08-27). Two rules, and each closes something the ruling did not open.
950
+ // WHAT-IF (ruling 2026-08-27). Two rules, and each closes something the ruling did not open.
795
951
  //
796
952
  // A CLIENT DOES NOT PICK THE MODEL. `model` is both cost and method — the tier that runs a stage is
797
953
  // the firm's cost structure, sealed with get_telemetry above — and offering it on the one tool that
@@ -807,13 +963,21 @@ export function authorize(scope, toolName, args = {}) {
807
963
  // the name, so neither half can be satisfied alone.
808
964
  if (toolName === "what_if_run" && !args?.runId)
809
965
  throw new Error(`what_if_run: pass the runId of the run you planned against — a client session must name the run it is changing.`);
966
+ // RUN CLEARANCES IS A SWITCH ON THE PERSON. Every write this layer reaches — start, stop, what-if —
967
+ // spends or ends a clearance, and the preview is the first step of one, so a view-only person's key
968
+ // reads and never writes. The portal gates the same routes on the same switch.
969
+ if ((rule.write || toolName === "plan_run") && scope.permissions?.run !== true)
970
+ throw new Error(`tool "${toolName}" needs Run clearances, which this person does not hold`);
810
971
  if (toolName === "start_run" || toolName === "plan_run") {
811
- // The grant bounds which account a client may spend against. `generic` is the neutral profile a job
812
- // with no profileKey runs under, so it has to be granted explicitly like any other key — otherwise
813
- // omitting the field would be a way out of the grant.
972
+ // The access bounds which company a person may spend against. `generic` is the neutral profile a
973
+ // job with no profileKey runs under, and it is not a company: it is an organisation's own lane,
974
+ // ordered by a person who holds that organisation whole (or everything) and capped per organisation
975
+ // like any company (ruling 2026-09-10). So omitting the field is still no way out of the access.
814
976
  const key = args?.profileKey ?? "generic";
815
- if (!scope.accounts.includes(key))
816
- throw new Error(`your grant [${scope.accounts.join(", ")}] does not include account "${key}" — ${toolName} refused`);
977
+ const reach = scope.accounts === "*" ? "everything" : (Array.isArray(scope.accounts) ? scope.accounts.join(", ") : "");
978
+ if (key === "generic" ? !(scope.everything === true || (Array.isArray(scope.genericOrgs) && scope.genericOrgs.length))
979
+ : !(scope.accounts === "*" || (Array.isArray(scope.accounts) && scope.accounts.includes(key))))
980
+ throw new Error(`your grant [${reach}] does not include account "${key}" — ${toolName} refused`);
817
981
  }
818
982
  if (toolName === "start_run" || toolName === "plan_run") {
819
983
  // WHO IS ASKING is server-stamped from the CF-verified identity, never caller-supplied. Both the
@@ -822,8 +986,10 @@ export function authorize(scope, toolName, args = {}) {
822
986
  // rides the delivery packet (docs/DELIVERY.md)". A client's assistant cannot act on that — it names
823
987
  // an internal doc and a concept the client has no reason to know — and it broke the FREE preview,
824
988
  // the one call a client is most likely to make first.
825
- const stamped = { ...args, forwarder: args.forwarder || scope.sub || "client-mcp",
826
- forwarderEmail: scope.sub ?? args.forwarderEmail };
989
+ const { tenant: askedTenant, ...rest } = args ?? {};
990
+ const stamped = { ...rest, forwarder: args.forwarder || scope.sub || "client-mcp",
991
+ forwarderEmail: scope.sub ?? args.forwarderEmail,
992
+ ...((args?.profileKey ?? "generic") === "generic" ? tenantStamp(scope, askedTenant) : {}) };
827
993
  // THE DAILY ALLOWANCE, and the reason this branch exists at all. runCaps.dailyRuns is enforced in
828
994
  // the runner ONLY for jobs stamped clientPrincipal:true, and that stamp is deliberately POSITIVE-ONLY
829
995
  // — absence means UNCAPPED (see checkRunCaps for why every inferred alternative fails dangerously).
@@ -833,7 +999,8 @@ export function authorize(scope, toolName, args = {}) {
833
999
  // plan_run does NOT carry it: it spends nothing, so stamping it would let a free preview burn a
834
1000
  // day's allowance (the runner counts ledger rows, and a previewed job that never runs must not sit
835
1001
  // in that count).
836
- return toolName === "start_run" ? { ...stamped, clientPrincipal: true } : stamped;
1002
+ // Everyone but a person who sees everything is capped — the same line the portal draws.
1003
+ return toolName === "start_run" && scope.everything !== true ? { ...stamped, clientPrincipal: true } : stamped;
837
1004
  }
838
1005
  return args;
839
1006
  }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // THREE COPIES OF THIS EXISTED AND ONE OF THEM SHIPPED A BLOCKER. The wizard, the launcher and the
7
7
  // launcher again each composed the same tmp-write, chmod, rename dance beside their own path. When
8
- // `.env` moved to `~/.config/clearotron/.env` (tracker issue 159) the directory had to be created before
8
+ // `.env` moved to `~/.config/clearotron/.env` the directory had to be created before
9
9
  // the write, the wizard's copy learned it, and the launcher's did not — so a fresh install could not
10
10
  // start at all:
11
11
  //
@@ -86,7 +86,7 @@ export const SERVER_INSTALL_SET = Object.freeze([
86
86
 
87
87
  /**
88
88
  * Installed and enabled ONLY by `clearotron connect`, never by an install path (
89
- * owner ruling 2026-08-31). Named here so a reader of this file learns it exists and learns it is
89
+ * ruling 2026-08-31). Named here so a reader of this file learns it exists and learns it is
90
90
  * excluded on purpose — an absence with no reason beside it is the thing this module is against.
91
91
  */
92
92
  // SUPERSEDED 2026-09-03 and deliberately kept EMPTY rather than deleted. The