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
@@ -1,99 +1,166 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
- // portal-access.mjs — identity → principal for the unified portal. The INNER
4
- // authorization boundary: CF Access (the edge) proves WHO; this module decides WHAT THEY SEE. Pure
5
- // decisions over injected inputs (grants object + staff domains) — fail-closed at every edge:
6
- // an unmapped identity gets NO principal (403 at the door), a cross-account request resolves to 404
7
- // semantics (never 403 — existence must not leak), and the grants substrate is the SAME file shape
8
- // the MCP faces read (`CLEAROTRON_ACCESS_FILE`, shared/scope.mjs loadGrants), so portal enrolment and MCP
9
- // grants stay one roster. Shape and semantics: INSTALL.md §8; examples/grants.example.json.
10
- import { accountsForEmail } from "../shared/scope.mjs";
3
+ // portal-access.mjs — identity → principal for the unified portal. The INNER authorization boundary:
4
+ // the sign-in door proves WHO; this module decides WHAT THEY SEE AND MAY DO. Pure decisions over the
5
+ // grants object — fail-closed at every edge: an unmapped identity gets NO principal (403 at the door), a
6
+ // request outside the person's access resolves to 404 semantics (never 403 — existence must not leak),
7
+ // and the grants substrate is the SAME file the connector reads (`CLEAROTRON_ACCESS_FILE`), resolved by
8
+ // the SAME function (`resolvePerson`, shared/scope.mjs), so the two doors cannot disagree about a person.
9
+ // Shape and semantics: INSTALL.md §8; examples/grants.example.json.
10
+ import { resolvePerson } from "../shared/scope.mjs";
11
11
 
12
- // LAST-@ semantics, matching the CF edge verifier (cf-access.mjs) and isFirmDomain (shared/scope.mjs)
13
- // — the privilege parse must never disagree with the edge parse (review 2026-07-18: a first-@ split
14
- // classified "x@firm.ch@evil.com" as staff while the edge saw evil.com).
15
- const domainOf = (email) => { const e = String(email ?? "").toLowerCase(); const i = e.lastIndexOf("@"); return i < 0 ? "" : e.slice(i + 1); };
12
+ /**
13
+ * makePrincipal({ email, grants }) → the person, or null when the address has no access anywhere.
14
+ *
15
+ * { email, everything, permissions: { run, manage }, access, accounts, organisations, genericOrgs, accountOrgs }
16
+ *
17
+ * There is no role. What a person may SEE is their access; what they may DO is two switches, asked by
18
+ * name through `seesEverything`, `mayRun` and `mayManage` below and never through a role word. Nothing
19
+ * about the part of an address after its `@` admits anyone: the staff-by-domain branch is deleted, and an
20
+ * address is admitted by its own entry in the grants file.
21
+ *
22
+ * `generic` is never in `accounts`. It is not a company: each organisation has its own, it is addressed as
23
+ * the pair (`account=generic`, `tenant=<organisation>`), and `genericOrgOf` decides it.
24
+ */
25
+ export function makePrincipal({ email, grants = null }) {
26
+ return resolvePerson(email, grants);
27
+ }
28
+
29
+ /** Access to the top of the tree: every organisation, every company, every person. */
30
+ export const seesEverything = (p) => p?.everything === true;
31
+ /** Run clearances: start and stop them, inside the person's access. */
32
+ export const mayRun = (p) => p?.permissions?.run === true;
33
+ /** Manage: add people, add companies, change settings, inside the person's access. */
34
+ export const mayManage = (p) => p?.permissions?.manage === true;
35
+
36
+ /** An organisation's display name: its `name` in the grants file, else its key. */
37
+ export function organisationName(grants, key) {
38
+ const n = grants?.tenants?.[key]?.name;
39
+ return typeof n === "string" && n.trim() ? n.trim() : key;
40
+ }
41
+
42
+ /** An access point as the portal shows it: names attached, keys kept. `everything` carries no key. */
43
+ export function namedPoint(p, grants, companyNames = {}) {
44
+ if (p.kind === "everything") return { kind: "everything" };
45
+ if (p.kind === "organisation") return { kind: "organisation", key: p.key, name: organisationName(grants, p.key) };
46
+ return { kind: "company", key: p.key, name: companyNames[p.key] ?? p.key, org: p.org };
47
+ }
16
48
 
17
49
  /**
18
- * makePrincipal({ email, grants, staffDomains }) →
19
- * { role: "staff", email, accounts: "*" } — firm identity: everything, acting-for allowed
20
- * | { role: "client", email, accounts: ["foxglade", …] } — enrolled client: exactly the granted accounts
21
- * | null — unknown identity: no portal (the door 403s)
22
- * Staff wins over an (accidental) grants row; a client row with a tenant-wide "*" grant is honored
23
- * but the role stays client (no staff surfaces).
50
+ * What `/portal/api/me` says about a person's reach and switches — the fields the screens read, so no
51
+ * screen derives a visibility rule of its own. `organisations` is every organisation the person sees
52
+ * anything in (a company's heading needs its organisation's name); `genericOrgs` is the ones whose
53
+ * Generic they see.
24
54
  */
25
- export function makePrincipal({ email, grants = null, staffDomains = [] }) {
26
- const e = String(email ?? "").trim().toLowerCase();
27
- if (!e || !e.includes("@")) return null;
28
- if (e.indexOf("@") !== e.lastIndexOf("@")) return null; // multi-@ identities are refused outright — no parse to disagree about
29
- if (staffDomains.map((d) => String(d).toLowerCase()).includes(domainOf(e)))
30
- return { role: "staff", email: e, accounts: "*" };
31
- const accounts = accountsForEmail(e, grants);
32
- if (accounts === "*") return { role: "client", email: e, accounts };
33
- if (Array.isArray(accounts) && accounts.length) {
34
- // `generic` is STAFF-ONLY and every route already enforces that (portal-service: the runs listing,
35
- // the report route and the LEAK-#9 rule all 404 it for a client). What no route did was stop it
36
- // being OFFERED: a tenant-wide grant expands to the full roster, `generic` is in the roster, and it
37
- // sorts first — so the brand-owner picker showed it at the top, the sidebar named it as the
38
- // client's own account, and choosing it produced "The list could not be loaded" every time.
39
- //
40
- // Found by resolving the real production grants for the one enrolled client rather than by reading
41
- // the route code, which looked correct in isolation. The routes WERE correct; the roster handed to
42
- // the picker was not, and a menu whose first item always fails is a defect wherever the refusal is
43
- // implemented.
44
- //
45
- // Filtered here, at the point the identity is decided, so the picker, the sidebar and every route
46
- // are working from one list. An explicit grant of `generic` is dropped too: it is not a thing a
47
- // client may hold, so honouring it in the menu would only defer the same 404.
48
- const visible = accounts.filter((a) => a !== "generic");
49
- if (visible.length) return { role: "client", email: e, accounts: visible };
50
- return null; // a client granted nothing BUT generic holds no visible account at all
51
- }
52
- return null;
55
+ export function principalView(principal, grants, companyNames = {}) {
56
+ return {
57
+ permissions: { run: mayRun(principal), manage: mayManage(principal) },
58
+ access: (principal.access ?? []).map((p) => namedPoint(p, grants, companyNames)),
59
+ organisations: (principal.organisations ?? []).map((key) => ({ key, name: organisationName(grants, key) })),
60
+ accountOrgs: { ...(principal.accountOrgs ?? {}) },
61
+ genericOrgs: [...(principal.genericOrgs ?? [])],
62
+ };
63
+ }
64
+
65
+ /**
66
+ * May this person read a run, given who it belongs to? The ONE answer for every run-scoped route: the
67
+ * listing, the report, the summary, the feedback form. `owner` is the run's company key (`generic` when it
68
+ * had none); `organisation` is the organisation a Generic run was filed under, null for one filed before
69
+ * organisations existed.
70
+ *
71
+ * A company's run: the company must be inside the person's access. A Generic run: the person must see
72
+ * that organisation's Generic — and an unfiled one is visible only to a person who sees everything, which
73
+ * is exactly who could read it before.
74
+ */
75
+ export function mayReadRun(principal, { owner, organisation = null }) {
76
+ if (!principal) return false;
77
+ if (owner !== "generic") return principal.accounts === "*" || (Array.isArray(principal.accounts) && principal.accounts.includes(owner));
78
+ if (seesEverything(principal)) return true;
79
+ return organisation != null && Array.isArray(principal.genericOrgs) && principal.genericOrgs.includes(organisation);
80
+ }
81
+
82
+ /**
83
+ * Does everything `other` holds sit inside `viewer`'s reach? Switches belong to the person, not to an
84
+ * access point, so a manager may set someone's switches only when that person's whole access is inside
85
+ * the manager's own — otherwise changing them would change what the person may do somewhere the manager
86
+ * cannot see.
87
+ */
88
+ export function reachCovers(viewer, other) {
89
+ if (seesEverything(viewer)) return true;
90
+ if (!other || seesEverything(other)) return false;
91
+ const orgs = viewer?.genericOrgs ?? [];
92
+ const companies = Array.isArray(viewer?.accounts) ? viewer.accounts : [];
93
+ return (other.access ?? []).every((p) => p.kind === "organisation" ? orgs.includes(p.key)
94
+ : p.kind === "company" ? orgs.includes(p.org) || companies.includes(p.key) : false);
53
95
  }
54
96
 
55
97
  export class PortalDeny extends Error {
56
98
  constructor(status, message) { super(message); this.name = "PortalDeny"; this.status = status; }
57
99
  }
58
100
 
101
+ /**
102
+ * Which organisation's Generic a request means — the organisation key, or null.
103
+ *
104
+ * named it must be one whose Generic this person sees (`genericOrgs`), else 404;
105
+ * unnamed the one organisation whose Generic they see, when there is exactly one;
106
+ * unnamed, for a person who sees everything and several organisations (or none): null — Generic filed
107
+ * under no organisation, which is how every Generic run was filed before organisations
108
+ * existed, and only a person who sees everything sees those runs;
109
+ * otherwise 404 when they see no Generic at all, 400 naming the field when they see several.
110
+ */
111
+ export function genericOrgOf(principal, tenant = null) {
112
+ const t = tenant == null || String(tenant).trim() === "" ? null : String(tenant).trim();
113
+ const orgs = Array.isArray(principal?.genericOrgs) ? principal.genericOrgs : [];
114
+ if (t != null) {
115
+ if (orgs.includes(t)) return t;
116
+ throw new PortalDeny(404, "not found");
117
+ }
118
+ if (orgs.length === 1) return orgs[0];
119
+ if (seesEverything(principal)) return null;
120
+ if (!orgs.length) throw new PortalDeny(404, "not found");
121
+ throw new PortalDeny(400, "name an organisation (?tenant=) — Generic belongs to an organisation, and this login sees several");
122
+ }
123
+
59
124
  /**
60
125
  * The ONE chokepoint every account-scoped route passes. Resolves the EFFECTIVE account for a request:
61
- * - staff: any account (the acting-for picker) — but an account must still be NAMED for
62
- * account-scoped routes (no accidental firm-wide writes);
63
- * - client: the named account must be inside the grant — a foreign account is a 404 (not 403:
64
- * existence never leaks), an unnamed account defaults to their only account (convenience) or
65
- * 404s when ambiguous.
66
- * staffOnly routes 404 for clients (the surface does not exist for them).
126
+ * - a person who sees everything: any account — but an account-scoped route must still NAME one (no
127
+ * accidental install-wide writes); unnamed resolves to null and the caller decides (list-all views);
128
+ * - anyone else: the named account must be inside their access — a foreign account is a 404, never a
129
+ * 403, because existence never leaks; unnamed defaults to their only company, or is a 400 when there
130
+ * are several or none;
131
+ * - `generic` is the pair, and `genericOrgOf` decides it here rather than per route.
132
+ *
133
+ * The gates — `everything` for install-wide surfaces, `manage`, `run` — each refuse with 404: the surface
134
+ * does not exist for this person, and a refusal that told "you may not" apart from "there is nothing
135
+ * here" would tell a stranger which endpoints exist.
136
+ *
137
+ * ORDERING GENERIC follows the rule for seeing it: a person who holds the organisation whole, and holds
138
+ * Run. Spending against it is bounded as a company's is — every organisation's Generic lane carries the
139
+ * daily cap (ruling 2026-09-10), counted by the runner in the lane of the organisation the job is
140
+ * filed under, which `genericOrgOf` has just decided.
67
141
  */
68
- export function assertPrincipal(principal, { staffOnly = false, account = null, door = false } = {}) {
142
+ export function assertPrincipal(principal, { account = null, tenant = null, door = false,
143
+ everything = false, manage = false, run = false, ...rest } = {}) {
144
+ if ("staffOnly" in rest) throw new TypeError("assertPrincipal: `staffOnly` is gone — gate on `everything`, `manage` or `run`");
69
145
  if (!principal) throw new PortalDeny(403, "no portal access for this identity");
70
- if (staffOnly && principal.role !== "staff") throw new PortalDeny(404, "not found");
146
+ if (everything && !seesEverything(principal)) throw new PortalDeny(404, "not found");
147
+ if (manage && !mayManage(principal)) throw new PortalDeny(404, "not found");
148
+ if (run && !mayRun(principal)) throw new PortalDeny(404, "not found");
71
149
  // door mode: the caller only needs "may this identity enter" — NEVER resolve an account (a
72
- // multi-account client must not 404 off the front door; review 2026-07-18)
150
+ // multi-account person must not 404 off the front door; review 2026-07-18)
73
151
  if (door) return null;
74
152
  if (account == null) {
75
- if (principal.role === "staff") return null; // staff without acting-for: caller decides (list-all views)
76
- if (Array.isArray(principal.accounts) && principal.accounts.length === 1) return principal.accounts[0];
77
153
  if (principal.accounts === "*") return null;
78
- throw new PortalDeny(400, "name an account (?account=) — this login covers several"); // multi-account client must name one — an actionable 400, never a lockout
154
+ if (Array.isArray(principal.accounts) && principal.accounts.length === 1) return principal.accounts[0];
155
+ throw new PortalDeny(400, principal.accounts?.length
156
+ ? "name an account (?account=) — this login covers several"
157
+ : "name an account (?account=) — this login holds no company of its own, only its organisation's Generic");
79
158
  }
80
159
  const a = String(account).trim().toLowerCase();
81
- if (principal.role === "staff") return a;
82
- // `generic` IS THE HOUSE ACCOUNT, AND IT IS REFUSED HERE RATHER THAN PER ROUTE.
83
- //
84
- // makePrincipal strips `generic` from a client's grant — but only on the ARRAY branch. A grant that
85
- // resolves to the literal "*" returns above it, untouched, and the wildcard test below then admits
86
- // `generic` like any other account. Three routes (the runs listing and the two report routes) carried
87
- // their own `role !== "staff"` check and closed the hole for themselves; POST /portal/api/run and
88
- // /run/plan never did. So the one path that spends money was the one path with no guard — against the
89
- // one account that is EXEMPT from the daily run cap (runner.mjs: `generic` is the neutral no-customer
90
- // profile and stays uncapped). Uncapped spend, reachable by a grant shape, is the worst combination
91
- // in this file.
92
- //
93
- // Refusing at the chokepoint fixes every account-scoped route at once, including the ones nobody has
94
- // written yet, which is the property the per-route checks could never have. Those three stay as they
95
- // are: they are cheap, and defence in depth on a spend boundary is not duplication.
96
- if (a === "generic") throw new PortalDeny(404, "not found");
160
+ if (a === "generic") {
161
+ genericOrgOf(principal, tenant);
162
+ return a;
163
+ }
97
164
  if (principal.accounts === "*" || (Array.isArray(principal.accounts) && principal.accounts.includes(a))) return a;
98
165
  throw new PortalDeny(404, "not found");
99
166
  }
@@ -10,7 +10,7 @@
10
10
  // ── THE FLAG PART ───────────────────────────────────────────────────────────────────────────────────
11
11
  //
12
12
  // This RENDERS the snapshot, NOT process.env, and that is still the rule — but the reason written here
13
- // until tracker issue 170 was measurably out of date, so it is restated rather than repeated. It said
13
+ // until it was measurably out of date, so it is restated rather than repeated. It said
14
14
  // "portal-service's unit deliberately carries no environment file". It does:
15
15
  // `driver/systemd/clearotron-portal.service` carries `EnvironmentFile=%h/.env`, and sets
16
16
  // `CLEAROTRON_NO_ENV_FILE=1` precisely because systemd has already supplied it.
@@ -42,10 +42,11 @@
42
42
  // so rather than implying it has the whole picture.
43
43
 
44
44
  import { statSync, openSync, readSync, closeSync } from "node:fs";
45
+ import { namedPoint } from "./portal-access.mjs";
45
46
 
46
47
  import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "./flag-snapshot.mjs";
47
- // `isStale` is deliberately NOT imported any more: the age banner is retired (owner ruling, tracker
48
- // issue 170). The function stays exported for other readers; this page no longer asks how old a
48
+ // `isStale` is deliberately NOT imported any more: the age banner is retired (ruling,
49
+ // 2026-09-05). The function stays exported for other readers; this page no longer asks how old a
49
50
  // reading is, because the question it was standing in for — does this still describe the box — now has
50
51
  // a direct answer in `lastRun.disagrees`.
51
52
  import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
@@ -110,7 +111,7 @@ function postureView(snap) {
110
111
  /**
111
112
  * The configuration view.
112
113
  *
113
- * THE ANSWER IS THE LIVE CONFIGURATION, ALWAYS — owner ruling 2026-09-05, on tracker issue 170:
114
+ * THE ANSWER IS THE LIVE CONFIGURATION, ALWAYS — ruling 2026-09-05:
114
115
  * "the global configuration page shows LIVE configuration, always. No run-time snapshot as the source of
115
116
  * truth — I don't see why it needs to take an old snapshot." Age banners go with it.
116
117
  *
@@ -269,87 +270,75 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
269
270
  }
270
271
 
271
272
  /**
272
- * Where the staff-domain rule was written, so a reader can go and undo it.
273
+ * The People page: who has access to what, narrowed to what the viewer may see, and where an enrolment
274
+ * is half done.
273
275
  *
274
- * ── WHY A PAGE THAT NAMES A RULE MUST ALSO NAME ITS ADDRESS ─────────────────────────────────────────
276
+ * `grants` is the parsed grants file and `viewer` the signed-in principal. A person is listed when at
277
+ * least one of their access points sits inside the viewer's own, and only those points are shown — a
278
+ * manager of one organisation sees a colleague's access to it and never that colleague's access
279
+ * elsewhere. A person who sees everything sees everyone. The narrowing is here, on the server, because
280
+ * a filter in the browser is a boundary drawn in markup.
275
281
  *
276
- * The People & access screen renders the rule — "Anyone at <domain> — a rule, not a person" — and said
277
- * nothing about where it came from. A reader who does not recognise the domain therefore learns that
278
- * strangers may hold an administrator's view of their instance and has no next step at all: the value
279
- * is in an environment variable, in one of two files depending on how the instance is run, and neither
280
- * is named anywhere on the screen. The one outside reader who met this reported it as a back door,
281
- * twice, which is the correct thing to do with an access rule you cannot trace.
282
- *
283
- * PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
284
- * what this process read, so the answer describes the process actually serving the page rather than
285
- * being composed from a path that some other process would have read — the distinction that module
286
- * exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
287
- * `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
288
- * the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
282
+ * `companies` maps each company key the profile store holds to its name. `localSignIn` says this install
283
+ * cannot hold a second person at all, which is what disables Add.
289
284
  */
290
- export function staffRuleSource({ name = "PORTAL_STAFF_DOMAINS", value = "", envLoad = null,
291
- unitEnvFile = null, cliEnvFile = null } = {}) {
292
- if (!String(value ?? "").trim()) return null;
293
- const reason = envLoad?.reason ?? null;
294
- const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
295
- if (reason === "read" && applied.includes(name))
296
- return { name, where: `read from ${envLoad.path}` };
297
- if (reason === "service-managed")
298
- return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
299
- if (reason === "opted-out")
300
- return { name, where: cliEnvFile
301
- ? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
302
- : "handed to this service by the command that started it" };
303
- return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
304
- }
305
-
306
- /**
307
- * The enrolment view: who is granted what, and where an enrolment is half done.
308
- *
309
- * `grants` is the parsed grants file. `staffDomains` are admitted by domain rather than by grant, so
310
- * they are reported separately — a staff member absent from the grants file is normal, not a fault,
311
- * and listing them as "unenrolled" would bury the real problems.
312
- */
313
- export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
285
+ export function accessView({ grants, viewer = null, companies = {}, grantsFile = null, localSignIn = false }) {
314
286
  const tenants = grants?.tenants ?? {};
315
- const known = new Set(knownAccounts);
316
- const people = [];
287
+ const everything = viewer?.everything === true;
288
+ const viewerOrgs = viewer?.genericOrgs ?? [];
289
+ const viewerCompanies = Array.isArray(viewer?.accounts) ? viewer.accounts : [];
290
+ const inside = (p) => everything || (p.kind === "organisation" ? viewerOrgs.includes(p.key)
291
+ : p.kind === "company" ? viewerOrgs.includes(p.org) || viewerCompanies.includes(p.key) : false);
292
+ const known = new Set(Object.keys(companies));
317
293
  const unknownAccounts = new Set();
294
+ const rows = new Map(); // address as written → { points, dangling }
295
+ const row = (email) => { if (!rows.has(email)) rows.set(email, { points: [], dangling: [] }); return rows.get(email); };
318
296
 
319
297
  for (const [tenant, t] of Object.entries(tenants)) {
320
298
  const accounts = Array.isArray(t?.accounts) ? t.accounts : [];
321
- for (const a of accounts) if (known.size && !known.has(a)) unknownAccounts.add(a);
322
-
299
+ for (const a of accounts) if (known.size && a !== "generic" && !known.has(a)) unknownAccounts.add(a);
323
300
  for (const [email, grant] of Object.entries(t?.users ?? {})) {
324
- // "*" means every account this TENANT holds — not every account on the system. Expanding it here
325
- // is what makes the page show what the person can actually reach, which is the question being
326
- // asked; showing a literal "*" would need the reader to know that rule.
327
- const resolved = grant === "*" ? accounts : Array.isArray(grant) ? grant : [];
328
- const dangling = resolved.filter((a) => !accounts.includes(a));
329
- people.push({
330
- email,
331
- tenant,
332
- accounts: resolved,
333
- // A grant naming an account its own tenant does not hold. Usually a typo, and it fails as a
334
- // silent 404 for that person with nothing in any log to explain it.
335
- dangling,
336
- wildcard: grant === "*",
337
- });
301
+ const r = row(email);
302
+ if (grant === "*") { r.points.push({ kind: "organisation", key: tenant }); continue; }
303
+ for (const a of Array.isArray(grant) ? grant : []) {
304
+ // A grant naming a company its own organisation does not hold. Usually a typo; it grants
305
+ // nothing, and the person meets a silent 404 with nothing in any log to explain it.
306
+ if (accounts.includes(a)) r.points.push({ kind: "company", key: a, org: tenant });
307
+ else if (everything || viewerOrgs.includes(tenant)) r.dangling.push(a);
308
+ }
338
309
  }
339
310
  }
311
+ const people = grants?.people && typeof grants.people === "object" ? grants.people : {};
312
+ for (const email of Object.keys(people)) row(email);
313
+
314
+ const list = [];
315
+ for (const [email, r] of rows) {
316
+ const key = Object.keys(people).find((k) => k.toLowerCase() === email.toLowerCase());
317
+ const entry = (key !== undefined ? people[key] : null) ?? {};
318
+ const pattern = email.startsWith("*@");
319
+ const all = entry.everything === true && !pattern ? [{ kind: "everything" }]
320
+ : r.points.filter((p) => p.kind === "organisation" || !r.points.some((q) => q.kind === "organisation" && q.key === p.org));
321
+ const shown = everything ? all : all.filter(inside);
322
+ if (!everything && !shown.length) continue;
323
+ list.push({
324
+ email,
325
+ permissions: { run: entry.run === true, manage: entry.manage === true && !pattern },
326
+ access: shown.map((p) => namedPoint(p, grants, companies)),
327
+ dangling: r.dangling,
328
+ });
329
+ }
340
330
 
341
331
  return {
342
- people: people.sort((a, b) => a.email.localeCompare(b.email)),
343
- staffDomains: [...staffDomains],
344
- // An ADDITIONAL field rather than a reshape of `staffDomains`: that array is parsed by the browser
345
- // contract and read by three screens' worth of arms, and a rule nobody can trace is a copy problem,
346
- // not a data-shape problem. Null when there is no rule, or when the source could not be told.
347
- staffRule,
348
- // Accounts named in grants that no profile matches — the other typo direction.
349
- unknownAccounts: [...unknownAccounts].sort(),
332
+ people: list.sort((a, b) => a.email.localeCompare(b.email)),
333
+ // Add is offered to a manager, and never where local sign-in holds the install to one person.
334
+ canAdd: viewer?.permissions?.manage === true && !localSignIn,
335
+ localSignIn,
336
+ // Companies named in grants that no profile matches — the other typo direction. Install-wide, so it
337
+ // is shown to a person who sees everything and to nobody else.
338
+ unknownAccounts: everything ? [...unknownAccounts].sort() : [],
350
339
  // Where to go to change any of this — a filename and a date, so "I want to add someone" has a
351
340
  // visible next step instead of ending at a page that only reports. Null when it cannot be stat'd.
352
- grantsFile,
341
+ grantsFile: everything ? grantsFile : null,
353
342
  // Stated plainly, because this view genuinely cannot see the other half.
354
343
  //
355
344
  // — IT NO LONGER NAMES ONE VENDOR, and it no longer asserts an edge that may not exist. This
@@ -145,7 +145,7 @@ const CHROME_RES = [
145
145
 
146
146
  // ── the engine's own scaffolding: out of the report, for EVERY reader ───────────────────────────────
147
147
  //
148
- // Owner ruling, 2026-07-27: "none of this should surface to anyone — only to the internal logs for
148
+ // Ruling, 2026-07-27: "none of this should surface to anyone — only to the internal logs for
149
149
  // analysis." Not a client cut and a staff cut; there is ONE report, and this material was never meant to
150
150
  // be in it for anybody. It reads as machinery in a document whose whole job is a legal opinion.
151
151
  //
@@ -525,7 +525,7 @@ const EMBED_JS = `
525
525
  queued=true;
526
526
  requestAnimationFrame(function(){queued=false;post();});
527
527
  }
528
- // WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS (tracker issue 1922).
528
+ // WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS.
529
529
  //
530
530
  // The command handler below answers "this report has no <verb>" for a verb the document does not
531
531
  // define. That reply is honest and it arrives too late: the shell had already drawn a menu item, the
@@ -890,7 +890,7 @@ export function reportsOf(meta) {
890
890
  * NOTHING COULD REACH IT. `meta.reports` lists the per-mark HTMLs only, so `resolveReportFile` below
891
891
  * matches nothing for it and the portal route 404s; the pool path is not one the edge serves either
892
892
  * (test/edge-routes.mjs — one legacy filename, and it is `report.html`). Good prose, composed on every
893
- * multi-mark run, delivered to nobody. Owner ruling 2026-08-26: the grouped page carries it.
893
+ * multi-mark run, delivered to nobody. Ruling 2026-08-26: the grouped page carries it.
894
894
  *
895
895
  * Returns PARAGRAPHS, split the way the document renderer splits them, with inline markdown left in
896
896
  * place — the model writes markdown because every surface it feeds renders markdown, and the client
@@ -917,7 +917,7 @@ export function batchSummaryOf(dir) {
917
917
  // reading to the end of the file would ship that list and this boundary is load-bearing.
918
918
  //
919
919
  // — IT USED TO TERMINATE ON ANY HEADING, /^#{1,6}\s/, AND THAT SILENTLY TRUNCATED THE PAGE.
920
- // The writer now emits sub-headers INSIDE the summary (owner ruling 2026-08-31, "keep the length, add
920
+ // The writer now emits sub-headers INSIDE the summary (ruling 2026-08-31, "keep the length, add
921
921
  // the structure"). Measured on a ten-line structured summary before the fix: the section ended at the
922
922
  // first `## <MARK>` and the grouped page — the report's entry point — rendered ONE sentence, with
923
923
  // every following mark dropped and nothing anywhere reporting a loss. Depth cannot mark this boundary