clearotron 0.3.1-beta.2 → 0.3.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/grant.mjs CHANGED
@@ -64,7 +64,7 @@
64
64
  import "../shared/env-local.mjs";
65
65
  import { readFileSync, existsSync } from "node:fs";
66
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
67
+ import { withPerson, withoutPerson, personPoints, withOrganisation, withCompany } from "../shared/grants-edit.mjs"; // — the People page's own editors
68
68
  import { basename } from "node:path";
69
69
  import { atomicWrite } from "../driver/progress.mjs";
70
70
  import { accessView } from "../driver/portal-config-view.mjs";
@@ -287,27 +287,26 @@ if (cmd === "remove") {
287
287
  const email = String(argv[1] ?? "").trim().toLowerCase();
288
288
  const only = flag("tenant");
289
289
  if (!email) usage();
290
- let removed = 0;
291
- for (const [name, t] of Object.entries(grants.tenants)) {
292
- if (only && name !== only) continue;
293
- if (t?.users && email in t.users) { delete t.users[email]; removed++; }
294
- // AN EMPTY `users` MAP IS NOT A DELETED TENANT, and this writes the former deliberately: the tenant
295
- // still exists and still holds its accounts, it simply has nobody on its guest list. Deleting the
296
- // tenant here would destroy configuration the operator never asked to remove, and it round-trips —
297
- // `remove-tenant` is the other verb, and it is explicit.
298
- }
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.`);
306
- atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
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.`);
290
+ // THROUGH THE SAME EDITOR THE PORTAL USES. This command used to delete the keys itself, and the two
291
+ // paths had drifted: `people` was matched without regard to case here and the tenant guest lists were
292
+ // matched exactly, so a hand-edited file holding `Dana@x` under an organisation kept that row through a
293
+ // removal that reported success. One function now answers for both faces.
294
+ const held = personPoints(grants, email);
295
+ const points = only ? held.points.filter((p) => p.tenant === only) : [];
296
+ if (!held.listed && !held.points.length)
297
+ die(`${email} is not on the guest list. Nothing written.`);
298
+ if (only && !points.length)
299
+ die(`${email} is not on the guest list for "${only}". Nothing written.`);
300
+ let after;
301
+ // A TENANT-SCOPED REMOVAL OF SOMEBODY WHO HOLDS EVERYTHING IS REFUSED, where it used to be done and
302
+ // then warned about. Access to everything lives under `people` and in no organisation, so striking one
303
+ // organisation's row changed nothing the person could see and the sentence saying they had been
304
+ // removed was printed first, with the correction below it.
305
+ try { after = withoutPerson(grants, { email, points, all: !only }); }
306
+ catch (e) { die(`${String(e?.message ?? e)} Nothing written.`); }
307
+ const orgs = only ? 1 : new Set(held.points.map((p) => p.tenant)).size;
308
+ atomicWrite(FILE, JSON.stringify(after, null, 2) + "\n");
309
+ out(`Removed ${email} from ${orgs} tenant(s)${only ? "" : held.listed ? ", and their permissions under people" : ""}. Written to ${basename(FILE)}.`);
311
310
  process.exit(0);
312
311
  }
313
312
 
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "3e6266f5960e7990e767edbdb95ca50831156b55",
3
- "version": "0.3.1-beta.2"
2
+ "commit": "7a1d63ae392a94c5f35609699ff9c52660586a5d",
3
+ "version": "0.3.1-beta.3"
4
4
  }
@@ -1,5 +1,30 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.1-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - d0d42f7: Fixed: A name in a knockout batch is no longer rated above every conflict found against it. Its rating now follows from the conflicts on its own page.
8
+
9
+ Fixed: Before, a rule forced any name made of everyday words off the lowest band, whatever the search found. That rule is gone for every client.
10
+ - 56a760f: Fixed: Giving somebody access to everything on the installation now works from the access form. It used to refuse. The message it refused with said you can only give access to what you hold yourself, which was not true of the person seeing it.
11
+ - 8c3bc3b: New: Ask AI on a report now opens Claude or ChatGPT with a question about that report already typed in. One press, in a new tab, and nothing is sent until you send it.
12
+
13
+ The button used to hand over a connector address and a question carrying the run's internal code, with no indication of which one you needed. The address belongs on the Use your own AI page, where you set the connector up once. It is no longer shown on reports at all.
14
+
15
+ If you have not connected an assistant yet, the button explains that in a line and offers to take you there.
16
+
17
+ For operators: the report's own "Ask your AI" band is gone, so there is one Ask AI control rather than two. Reports rendered before this upgrade keep the band in their own file, and it is hidden when the portal serves them.
18
+ - 56a760f: Fixed: `clearotron grant remove --tenant` now refuses when the person has access to everything on the installation. It used to remove the organisation and then warn that nothing they could see had changed.
19
+
20
+ Fixed: Removing somebody whose address is spelled with different capitalisation in different parts of the access file now removes all of them. Half of the entry used to survive, and the command reported success.
21
+ - 56a760f: New: Access can now be narrowed and taken away, not only added to. Somebody who manages one organisation removes that organisation alone. Somebody who can see all of a person removes their access to the installation, and withdraws the keys their AI assistant was using.
22
+
23
+ Fixed: A removal now says plainly when the connector cannot be told about it yet, instead of implying the assistant lost access too.
24
+ - f1c5925: Fixed: Where a search covers two ratified forms of a name, the report now reasons each form and says which conflicts differ between them.
25
+
26
+ Fixed: Before, both forms were searched but one combined read came back. When the forms read alike the report now says so, rather than leaving it unsaid.
27
+
3
28
  ## 0.3.1-beta.2
4
29
 
5
30
  ### Patch Changes
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.1-beta.2",
5
+ "version": "0.3.1-beta.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -43,6 +43,7 @@
43
43
 
44
44
  import { statSync, openSync, readSync, closeSync } from "node:fs";
45
45
  import { namedPoint } from "./portal-access.mjs";
46
+ import { recordedKeysFor } from "../shared/client-door.mjs"; // — how many issued keys a person holds, counted by the module that revokes them
46
47
 
47
48
  import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "./flag-snapshot.mjs";
48
49
  // `isStale` is deliberately NOT imported any more: the age banner is retired (ruling,
@@ -282,7 +283,8 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
282
283
  * `companies` maps each company key the profile store holds to its name. `localSignIn` says this install
283
284
  * cannot hold a second person at all, which is what disables Add.
284
285
  */
285
- export function accessView({ grants, viewer = null, companies = {}, grantsFile = null, localSignIn = false }) {
286
+ export function accessView({ grants, viewer = null, companies = {}, grantsFile = null, localSignIn = false,
287
+ keysRevocable = false }) {
286
288
  const tenants = grants?.tenants ?? {};
287
289
  const everything = viewer?.everything === true;
288
290
  const viewerOrgs = viewer?.genericOrgs ?? [];
@@ -330,6 +332,23 @@ export function accessView({ grants, viewer = null, companies = {}, grantsFile =
330
332
  // whose permissions were considered and set to none.
331
333
  listed: key !== undefined,
332
334
  access: shown.map((p) => namedPoint(p, grants, companies)),
335
+ // WHETHER THIS ROW IS THE WHOLE OF THIS PERSON, from where the viewer stands. Every other field
336
+ // here is narrowed silently — a manager of one organisation sees that organisation's half of
337
+ // somebody and nothing says it is a half — and that was serviceable while the page could only
338
+ // ADD. It is not serviceable for a page that changes and removes: permissions belong to the
339
+ // person and not to a point, so a narrowed viewer must be told they cannot change them, and a
340
+ // removal they order must say it takes away their organisation rather than the install.
341
+ //
342
+ // The same test the write routes apply (`reachCovers`), computed here from the same `inside`
343
+ // that narrowed the list, so the sentence the page draws and the refusal the server would give
344
+ // cannot disagree.
345
+ covered: everything || all.every(inside),
346
+ // HOW MANY ISSUED KEYS THIS PERSON HOLDS — counted by the module that revokes them rather than by
347
+ // reading `connectKeys` here, so the count and the revocation cannot come to different answers
348
+ // about who a key belongs to. The removal confirmation needs it BEFORE the press: what happens to
349
+ // somebody's assistant is part of what the reader is agreeing to, and a page that found out
350
+ // afterwards would be telling them about it too late to matter.
351
+ keys: recordedKeysFor(grants, email).length,
333
352
  dangling: r.dangling,
334
353
  });
335
354
  }
@@ -339,6 +358,12 @@ export function accessView({ grants, viewer = null, companies = {}, grantsFile =
339
358
  // Add is offered to a manager, and never where local sign-in holds the install to one person.
340
359
  canAdd: viewer?.permissions?.manage === true && !localSignIn,
341
360
  localSignIn,
361
+ // Can an issued key be withdrawn on this installation at all? A connector started without a
362
+ // revocation list never loaded one, so a key minted through it cannot be called back and dies at its
363
+ // own expiry — `disablePlan` calls that state `lateArm` and refuses to strike the record for it.
364
+ // Stated here so the confirmation can say which of the two removals this would be before it happens,
365
+ // rather than correcting itself in the answer.
366
+ keysRevocable,
342
367
  // Companies named in grants that no profile matches — the other typo direction. Install-wide, so it
343
368
  // is shown to a person who sees everything and to nobody else.
344
369
  unknownAccounts: everything ? [...unknownAccounts].sort() : [],
@@ -799,14 +799,28 @@ export function prepareReportForEmbed(html, { staff = false, poolRoot = null, fe
799
799
  let ratedUnderDropped = 0;
800
800
  out = out.replace(RATED_UNDER_RE, () => { ratedUnderDropped += 1; return ""; });
801
801
 
802
- // Staff keep their own connector block; a client must not see the staff host it points at.
802
+ // ── THE ASK-AI BAND COMES OUT FOR EVERY READER ──────────────────────────────────────────────────
803
+ //
804
+ // It used to come out for clients only, on the ground that the band names the STAFF host and staff may
805
+ // see it. That left two Ask-AI controls on one staff screen — this band with the staff address and its
806
+ // setup steps, and the shell's own header button — and the owner ruled on 2026-09-15 that there is one.
807
+ // The renderer stopped drawing it in that same change.
808
+ //
809
+ // SO THIS STRIP IS NOT DEAD CODE, AND THAT IS THE REASON IT RUNS UNCONDITIONALLY. Every report
810
+ // rendered before that commit is served from its baked bytes and still carries a band naming the staff
811
+ // host. Removing the strip on the argument that nothing emits one any more would put that host back in
812
+ // front of whoever opens an archived run, staff and client alike, and the archive is where most of the
813
+ // reports are.
803
814
  let mcpLeaks = 0;
804
815
  let internalTailsDropped = 0;
805
816
  let reviewerCodesDropped = 0;
817
+ out = stripBalanced(out, ASKAI_RE, "details", note).html;
806
818
  if (!staff) {
807
- out = stripBalanced(out, ASKAI_RE, "details", note).html;
808
819
  // Assert, do not assume. `.askband` markup is nested, so a non-greedy match could stop early and
809
820
  // leave the host behind in a sibling node. Anything surviving is redacted and counted.
821
+ //
822
+ // CLIENT-ONLY, and deliberately: this catches an MCP host anywhere in the document, not only inside
823
+ // the band, and the staff bytes elsewhere in a report are the designed rendering rather than a leak.
810
824
  out = out.replace(MCP_HOST_RE, () => {
811
825
  mcpLeaks += 1;
812
826
  return "";
@@ -89,7 +89,7 @@ export function opsTokenFor({ bootToken, roster, mint }) {
89
89
  // has held the signing secret on both start paths for as long as both have existed. The comment has been
90
90
  // corrected in place rather than left to be trusted.
91
91
  import { mintToken, loadGrants, resolvePerson, addressesInGrants } from "../shared/scope.mjs";
92
- import { withPerson, withCompany } from "../shared/grants-edit.mjs";
92
+ import { withPerson, withoutPerson, personPoints, withCompany } from "../shared/grants-edit.mjs";
93
93
  import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
94
94
  import { fileURLToPath } from "node:url";
95
95
 
@@ -1082,6 +1082,59 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
1082
1082
  return row;
1083
1083
  }
1084
1084
 
1085
+ /**
1086
+ * Revoking the connector keys a removed person holds — composed here, exported so it can be driven.
1087
+ *
1088
+ * It is the portal's half of an act `clearotron disconnect` also performs, through the same module, so
1089
+ * one author decides what revoking means. What differs is WHERE each runs, and the difference decides
1090
+ * what each may do: `disconnect` runs on the box beside the door, and this runs in a web request in a
1091
+ * different service with a different environment.
1092
+ */
1093
+ export function makeConnectorKeyRevoker({ env = process.env, home = null } = {}) {
1094
+ return async ({ email, grants: g }) => {
1095
+ const { recordedKeysFor, disablePlan, applyDisablePlan, denylistPathFor } = await import("../shared/client-door.mjs");
1096
+ const { homedir } = await import("node:os");
1097
+ const { existsSync, mkdirSync, writeFileSync, appendFileSync } = await import("node:fs");
1098
+ const { join, dirname } = await import("node:path");
1099
+ const base = home ?? homedir();
1100
+ const recorded = recordedKeysFor(g, email);
1101
+ const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
1102
+ identity: email, recorded, denylistPath: denylistPathFor(env, base) });
1103
+ if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
1104
+ if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
1105
+ // THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
1106
+ //
1107
+ // `disablePlan` decides `lateArm` from the environment it is handed, and the environment this
1108
+ // process has is the PORTAL's. The connector is a different unit with its own `EnvironmentFile`, so
1109
+ // a box where the portal names a revocation list and the door was started without one reads as
1110
+ // `lateArm: false` here — and the plan would then write the list AND strike the record, for a key
1111
+ // that still works. A record removed while its key works is the only trace of that key, gone: the
1112
+ // failure `client-door.mjs` states its ordering rule to prevent.
1113
+ //
1114
+ // So this process does the half it can vouch for. Writing the list is safe in both configurations —
1115
+ // it is the right act where the door reads it and a file nobody opens where it does not. Leaving the
1116
+ // record is safe in both too: `connectKeyReport` already judges a record valid, expired or revoked,
1117
+ // so a record of a revoked key is an accurate one, and `clearotron doctor` is where an operator sees
1118
+ // which. `clearotron disconnect` runs on the box, beside the door, and still strikes.
1119
+ //
1120
+ // The seam THROWS rather than doing nothing, so that a future change putting the ledger step back
1121
+ // fails here instead of quietly striking again.
1122
+ const revokeOnly = { ...plan, steps: plan.steps.filter((step) => step.id === "revoke") };
1123
+ applyDisablePlan(revokeOnly, {
1124
+ appendDenylist: (path, jtis) => {
1125
+ mkdirSync(dirname(path), { recursive: true });
1126
+ if (!existsSync(path)) writeFileSync(path, "# Revoked key ids, one jti per line. Read on every key check.\n", { mode: 0o600 });
1127
+ appendFileSync(path, jtis.map((j) => `${j}\n`).join(""));
1128
+ },
1129
+ strikeRecords: () => {
1130
+ throw new Error("the portal must not strike a key record: it cannot see the connector's environment, "
1131
+ + "so it cannot know whether the revocation list it just wrote is the one that door loaded");
1132
+ },
1133
+ });
1134
+ return { grants: g, revoked: plan.jtis.length, jtis: plan.jtis, lateArm: false, recordKept: true };
1135
+ };
1136
+ }
1137
+
1085
1138
  export function makePortalService({
1086
1139
  poolRoot, workspaceRoot, recipesDir = undefined, secret,
1087
1140
  grants = null,
@@ -1094,6 +1147,14 @@ export function makePortalService({
1094
1147
  // CLEAROTRON_ACCESS_FILE. Null means this service cannot write the file, and the routes that would
1095
1148
  // need to say so rather than pretend.
1096
1149
  writeGrants = null,
1150
+ // Revoking the connector keys a removed person holds. INJECTED for the reason `writeGrants` is: this
1151
+ // constructor stays pure over its inputs, an arm can watch the revocation without a denylist file on
1152
+ // disk, and boot is where the paths live. It takes the grants object the removal produced and returns
1153
+ // it with the struck records gone, so one write lands both facts.
1154
+ //
1155
+ // Null means this service cannot revoke, and the answer SAYS so — a removal that quietly left a live
1156
+ // key would be the exact failure the page's own sentence promises against.
1157
+ revokeConnectorKeys = null,
1097
1158
  // The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
1098
1159
  // and the quota pre-check read their ledger beside these, so they count what the wall counts (:
1099
1160
  // they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
@@ -2316,6 +2377,52 @@ export function makePortalService({
2316
2377
  // cache, and a failure that answers `null` rather than throwing — a page that cannot read its door says
2317
2378
  // so, which is the honest half of this change.
2318
2379
  let doorKindCache = { at: 0, url: null, kind: null };
2380
+ // ── WHETHER A READER HAS AN ASSISTANT ON THIS INSTALLATION ────────────────────────────────────────
2381
+ //
2382
+ // The only evidence either process holds is the connector's own access log: enrolment says a person MAY
2383
+ // connect, never that they did. `readConnections` is the connector's reader, imported rather than
2384
+ // rebuilt here — the path this log lives at is the connector's fact, and a portal deriving it from its
2385
+ // own environment is how a live key's record nearly got struck on a split install.
2386
+ //
2387
+ // TWO INSTALLATION SHAPES, AND THE SPLIT IS THE ONE THE ROUTE ALREADY MAKES. A hosted install has a
2388
+ // published client door and many signed-in people, so the question is answered per person: is this
2389
+ // email in the log. A local install has no client door, one reader, and a connector that cannot know
2390
+ // who it is serving — so the local route's own record answers for the only person who could be asking.
2391
+ // Reading a local record as an answer on a HOSTED install would mark every reader connected the moment
2392
+ // one member of staff ran the server by hand, which is why it is gated on there being no client door.
2393
+ //
2394
+ // Returns null, never false, when there was nothing to read. See the route for why that matters.
2395
+ //
2396
+ // CACHED FOR A MINUTE, the same as the door probe above and for the same reason: this is a page load,
2397
+ // not a check. It runs on every report a reader opens, and reading the tail of a log each time to
2398
+ // answer a question whose answer changes once, ever, would be paid on the client-facing screen.
2399
+ // KEYED ON THE FILES IT READ, not on time alone. The log's location is configuration and does not move
2400
+ // under a running service — but a cache that ignored it would answer about the wrong file for a minute
2401
+ // after it did, and it is what makes this cache testable at the route rather than only at the helper.
2402
+ let connectionsCache = { at: 0, key: null, seen: null };
2403
+ const CONNECTIONS_TTL_MS = 60_000;
2404
+
2405
+ async function readerHasConnectedAi(principal) {
2406
+ const email = String(principal?.email ?? "").trim().toLowerCase();
2407
+ let seen = null;
2408
+ // Imported here rather than at the top, the way every other reach into mcp-server/lib from this file
2409
+ // is: the driver does not depend on the connector, and the import-cycle walk is what keeps it so.
2410
+ let audit = null;
2411
+ try { audit = await import("../mcp-server/lib/audit.mjs"); }
2412
+ catch { return null; } // best-effort: never fail a page load
2413
+ const key = audit.auditPaths().join("\u0000");
2414
+ if (connectionsCache.seen && connectionsCache.key === key && Date.now() - connectionsCache.at < CONNECTIONS_TTL_MS) {
2415
+ seen = connectionsCache.seen;
2416
+ } else {
2417
+ try { seen = audit.readConnections(); } catch { return null; }
2418
+ connectionsCache = { at: Date.now(), key, seen };
2419
+ }
2420
+ if (!seen.available) return null;
2421
+ if (email && seen.emails.has(email)) return true;
2422
+ if (!process.env.CLEAROTRON_CLIENT_MCP_URL && seen.local) return true;
2423
+ return false;
2424
+ }
2425
+
2319
2426
  const DOOR_KIND_TTL_MS = 60_000;
2320
2427
  async function connectorDoorKind(url) {
2321
2428
  if (!url) return null;
@@ -2402,6 +2509,18 @@ async function connectorDoorKind(url) {
2402
2509
  email: principal.email ?? null, // the identity to sign in with — what they already use
2403
2510
  enabled: !!url,
2404
2511
  stdio, // the local route, or null for a client
2512
+ // ── HAS THIS READER ALREADY CONNECTED AN ASSISTANT? ──────────────────────────────────────
2513
+ //
2514
+ // Folded into this route rather than given its own, because the Ask-AI control on a report
2515
+ // already loads it and a second request per report open buys nothing.
2516
+ //
2517
+ // `null` IS A THIRD STATE AND THE SCREEN HAS TO DRAW IT. True, false and "no log to read" are
2518
+ // different facts: an installation whose access log has not been written yet, or cannot be
2519
+ // read, has not told us this reader never connected — it has told us nothing. The control
2520
+ // treats null the way it treats false, because offering the menu to somebody with no
2521
+ // assistant reproduces the defect this is fixing, and because the panel carries its own way
2522
+ // past ("Already connected? Ask anyway"). What it must not do is claim the measurement.
2523
+ aiConnected: await readerHasConnectedAi(principal),
2405
2524
  // Every client, already resolved: served or not, with what it needs or why it cannot be.
2406
2525
  //
2407
2526
  // ── `steps` COMES BACK, on the owner's 2026-09-03 ruling ─────────
@@ -3060,7 +3179,16 @@ async function connectorDoorKind(url) {
3060
3179
  const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
3061
3180
  if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
3062
3181
  } catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
3063
- return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn }) };
3182
+ // WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL, read the same way `disablePlan` reads it: the
3183
+ // door loaded a revocation list at start, or it did not and never will for the keys already
3184
+ // out. The variable's PRESENCE is the whole question — its value is a path, and this route
3185
+ // must not say where.
3186
+ // WHETHER A REVOCATION LIST IS NAMED FOR THIS PROCESS AT ALL — which is not the same question
3187
+ // as whether the connector loaded one, and the page's words are careful about the difference.
3188
+ // The connector is a separate unit with its own environment; this answers only for here.
3189
+ const keysRevocable = Boolean(revokeConnectorKeys)
3190
+ && String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
3191
+ return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
3064
3192
  }
3065
3193
  // /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
3066
3194
  //
@@ -3075,18 +3203,32 @@ async function connectorDoorKind(url) {
3075
3203
  const email = String(body?.email ?? "").trim().toLowerCase();
3076
3204
  if (!email || email.indexOf("@") <= 0 || email.indexOf("@") !== email.lastIndexOf("@"))
3077
3205
  return { status: 400, json: { error: "Enter one email address." } };
3206
+ // ACCESS TO EVERYTHING IS NOT A POINT, and the handler used to treat it as one. The form draws
3207
+ // "Everything on this Clearotron" only to somebody who holds it, and sent it as
3208
+ // `{kind:"everything"}` alongside the organisation and company points; nothing here matched
3209
+ // that kind, so it fell to the 404 below and the page rendered the refusal it keeps for a
3210
+ // point outside the adder's reach — "You can only give access to what you have access to
3211
+ // yourself" — to the one person on the install for whom that is false. The control had never
3212
+ // worked. It lives under `people` as a switch, not in any organisation, so it is read as one.
3078
3213
  const points = [];
3214
+ let wantsEverything = false;
3079
3215
  for (const a of Array.isArray(body?.access) ? body.access : []) {
3080
3216
  const key = typeof a?.key === "string" ? a.key : "";
3081
- if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
3217
+ if (a?.kind === "everything" && seesEverything(principal)) wantsEverything = true;
3218
+ else if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
3082
3219
  else if (a?.kind === "company" && principal.accountOrgs?.[key]) points.push({ tenant: principal.accountOrgs[key], account: key });
3083
3220
  else return { status: 404, json: { error: "not_found" } };
3084
3221
  }
3085
- if (!points.length) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
3086
- const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true };
3222
+ if (!points.length && !wantsEverything) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
3223
+ const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true, everything: wantsEverything };
3087
3224
  if (want.run && !mayRun(principal)) return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
3088
3225
  const existing = resolvePerson(email, grantsHere);
3089
3226
  const setSwitches = email !== principal.email && (!existing || reachCovers(principal, existing));
3227
+ // THE SWITCHES ARE WHERE ACCESS TO EVERYTHING IS WRITTEN, so a grant of it that cannot write
3228
+ // them writes nothing at all — and `withPerson` would return unchanged grants and this route a
3229
+ // 201 naming a person who gained nothing. Said instead of returned.
3230
+ if (wantsEverything && !setSwitches)
3231
+ return { status: 400, json: { error: "Access to everything is part of what a person may do, and that cannot be set from here for this address. Nothing was saved." } };
3090
3232
  let next;
3091
3233
  try { next = withPerson(grantsHere, { email, points, switches: want, setSwitches }); }
3092
3234
  catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
@@ -3101,6 +3243,132 @@ async function connectorDoorKind(url) {
3101
3243
  const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email === email) ?? null;
3102
3244
  return { status: 201, json: { person, switchesApplied: setSwitches } };
3103
3245
  }
3246
+
3247
+ // /portal/admin/people/change — change what somebody may do and see.
3248
+ //
3249
+ // THE DIFF IS COMPUTED HERE, FROM THE FILE, and never taken from the request. The page sends the
3250
+ // state it wants; this reads what the file holds right now, narrows that to the part of the
3251
+ // person the caller can see, and works out what to add and what to take away between the two. A
3252
+ // page that had been open while somebody else was edited would otherwise write its own stale
3253
+ // copy back over them — and the half it would overwrite is the half outside its own view, which
3254
+ // nobody looking at either screen could see happen.
3255
+ //
3256
+ // NOBODY CHANGES THEMSELVES. The Add form already refuses to set the adder's own switches; this
3257
+ // refuses the whole act, because a manager who can take their own Manage away can lock the
3258
+ // install's last manager out of it with one press, and the way back is a text editor on the box.
3259
+ if (parts[2] === "people" && parts[3] === "change" && parts.length === 4 && method === "POST") {
3260
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in" } };
3261
+ if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3262
+ const email = String(body?.email ?? "").trim().toLowerCase();
3263
+ if (email === principal.email) return { status: 400, json: { error: "You cannot change your own access. Somebody else who manages this install can." } };
3264
+ const existing = resolvePerson(email, grantsHere);
3265
+ const held = personPoints(grantsHere, email);
3266
+ if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
3267
+
3268
+ // What the caller can see of this person, and what they asked for — both in the same shape, so
3269
+ // the difference between them is the change.
3270
+ const inReach = (pt) => pt.account == null
3271
+ ? (principal.genericOrgs ?? []).includes(pt.tenant)
3272
+ : principal.accountOrgs?.[pt.account] === pt.tenant;
3273
+ const mine = held.points.filter(inReach);
3274
+ const want = [];
3275
+ for (const a of Array.isArray(body?.access) ? body.access : []) {
3276
+ const key = typeof a?.key === "string" ? a.key : "";
3277
+ if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) want.push({ tenant: key, account: null });
3278
+ else if (a?.kind === "company" && principal.accountOrgs?.[key]) want.push({ tenant: principal.accountOrgs[key], account: key });
3279
+ else return { status: 404, json: { error: "not_found" } };
3280
+ }
3281
+ const id = (pt) => `${pt.tenant}/${pt.account ?? "*"}`;
3282
+ const wanted = new Set(want.map(id));
3283
+ const kept = new Set(mine.map(id));
3284
+ const drop = mine.filter((pt) => !wanted.has(id(pt)));
3285
+ const add = want.filter((pt) => !kept.has(id(pt)));
3286
+ // TAKING THE WHOLE OF SOMEBODY'S VISIBLE ACCESS AWAY IS REMOVAL, and it has its own route, its
3287
+ // own confirmation and its own revocation. Reaching it by unticking every row would do half of
3288
+ // that act under the word "save".
3289
+ if (!want.length) return { status: 400, json: { error: "Leave them at least one organisation or company, or remove them instead." } };
3290
+
3291
+ const switches = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true,
3292
+ everything: held.switches.everything };
3293
+ if (switches.run && !held.switches.run && !mayRun(principal))
3294
+ return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
3295
+ // Switches belong to the person and not to a point, so they are set only when the whole of this
3296
+ // person sits inside the caller's reach. Otherwise the points move and the switches stay, and
3297
+ // the answer says which happened — the same contract adding already has.
3298
+ const setSwitches = reachCovers(principal, existing);
3299
+ let next;
3300
+ try {
3301
+ next = drop.length ? withoutPerson(grantsHere, { email, points: drop }) : grantsHere;
3302
+ next = withPerson(next, { email, points: add, switches, setSwitches });
3303
+ } catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
3304
+ try { await writeGrants(next); }
3305
+ catch (e) {
3306
+ audit({ event: "person-change", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
3307
+ return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
3308
+ }
3309
+ audit({ event: "person-change", by: principal.email, person: email, added: add.length, removed: drop.length,
3310
+ switchesApplied: setSwitches, ok: true, status: 200 });
3311
+ const { loadProfiles } = await import("./profiles.mjs");
3312
+ const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
3313
+ const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email.toLowerCase() === email) ?? null;
3314
+ return { status: 200, json: { person, switchesApplied: setSwitches, added: add.length, removed: drop.length } };
3315
+ }
3316
+
3317
+ // /portal/admin/people/remove — take their access away.
3318
+ //
3319
+ // HOW FAR IT REACHES IS THE SERVER'S ANSWER, NOT THE REQUEST'S. A caller who can see the whole of
3320
+ // this person removes them from the install; a caller who can see one organisation's worth of
3321
+ // them takes away that organisation and nothing else. The page draws a different button for each
3322
+ // — it reads the same `covered` the view computes — but the request carries no scope to get
3323
+ // wrong, so a stale page cannot ask for more than the person pressing it can see.
3324
+ if (parts[2] === "people" && parts[3] === "remove" && parts.length === 4 && method === "POST") {
3325
+ if (localSignIn) return { status: 409, json: { error: "local_sign_in" } };
3326
+ if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
3327
+ const email = String(body?.email ?? "").trim().toLowerCase();
3328
+ if (email === principal.email) return { status: 400, json: { error: "You cannot remove your own access. Somebody else who manages this install can." } };
3329
+ const existing = resolvePerson(email, grantsHere);
3330
+ const held = personPoints(grantsHere, email);
3331
+ if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
3332
+ const whole = reachCovers(principal, existing);
3333
+ const inReach = (pt) => pt.account == null
3334
+ ? (principal.genericOrgs ?? []).includes(pt.tenant)
3335
+ : principal.accountOrgs?.[pt.account] === pt.tenant;
3336
+ const mine = held.points.filter(inReach);
3337
+ if (!whole && !mine.length) return { status: 404, json: { error: "not_found" } };
3338
+
3339
+ let next;
3340
+ try { next = withoutPerson(grantsHere, whole ? { email, all: true } : { email, points: mine }); }
3341
+ catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
3342
+
3343
+ // THE KEYS, AND ONLY ON A WHOLE REMOVAL. A narrowing leaves the person on the install with
3344
+ // access somewhere else, and their connector key is how they reach that.
3345
+ let keys = { checked: false };
3346
+ if (whole) {
3347
+ if (!revokeConnectorKeys) keys = { checked: false, note: "this installation cannot revoke issued keys from here" };
3348
+ else {
3349
+ try {
3350
+ const { grants: written, ...said } = await revokeConnectorKeys({ email, grants: next });
3351
+ next = written ?? next;
3352
+ keys = { checked: true, ...said };
3353
+ }
3354
+ // A REVOCATION THAT FAILED MUST NOT LOOK LIKE ONE THAT HAPPENED, and it must not take the
3355
+ // removal with it either: the access record is the gate every surface reads, and leaving
3356
+ // it in place because a key file could not be appended would be the larger failure.
3357
+ catch (e) { keys = { checked: true, failed: true, note: String(e?.message ?? e).slice(0, 200) }; }
3358
+ }
3359
+ }
3360
+
3361
+ try { await writeGrants(next); }
3362
+ catch (e) {
3363
+ audit({ event: "person-remove", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
3364
+ return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
3365
+ }
3366
+ audit({ event: "person-remove", by: principal.email, person: email, scope: whole ? "install" : "organisations",
3367
+ organisations: whole ? null : [...new Set(mine.map((pt) => pt.tenant))].length,
3368
+ keysRevoked: keys.revoked ?? 0, keysUnrevokable: keys.lateArm ? (keys.jtis?.length ?? 0) : 0, ok: true, status: 200 });
3369
+ return { status: 200, json: { removed: whole ? "install" : "organisations",
3370
+ organisations: whole ? [] : [...new Set(mine.map((pt) => pt.tenant))], keys } };
3371
+ }
3104
3372
  // /portal/admin/observed — who has actually USED this instance lately, from the audit log.
3105
3373
  //
3106
3374
  // ALWAYS 200, even when the log is missing or unreadable, with an `available` boolean — the
@@ -4302,6 +4570,18 @@ const PORT = PORT_CHOICE.port;
4302
4570
  atomicWrite(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"), `${JSON.stringify(g, null, 2)}\n`);
4303
4571
  };
4304
4572
 
4573
+ // Revoking a removed person's connector keys, from the same module `clearotron disconnect` revokes
4574
+ // through — one author for what revoking means, and one place the ordering rule lives: the id reaches
4575
+ // the denylist before its record is struck, because a record removed first leaves a live key with no
4576
+ // trace it ever existed.
4577
+ //
4578
+ // IT NEVER NAMES THE LIST IN `.env`. `applyDisablePlan` will do that when a plan is `lateArm` — the
4579
+ // door was started without a revocation list, so the running process never loaded one — and that step
4580
+ // belongs to an operator at a terminal, not to a web request rewriting the install's environment. So
4581
+ // a lateArm plan is NOT applied: nothing is written, and the answer says the keys stay live until they
4582
+ // expire. Striking the records instead would delete the only record of a working key.
4583
+ const revokeConnectorKeys = makeConnectorKeyRevoker({ env: process.env });
4584
+
4305
4585
  const { config } = await import("./driver.config.mjs");
4306
4586
  const { appendFileSync: append } = await import("node:fs");
4307
4587
  const auditPath = process.env.PORTAL_AUDIT || join(HERE, "..", "portal-audit.log");
@@ -4805,7 +5085,7 @@ const PORT = PORT_CHOICE.port;
4805
5085
  const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
4806
5086
  // Re-read per request (a getter that rescans), so a workspace created after boot is counted.
4807
5087
  queueDirs: () => config.queueDirs,
4808
- secret, grants, localSignIn: LOCAL_MODE, writeGrants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
5088
+ secret, grants, localSignIn: LOCAL_MODE, writeGrants, revokeConnectorKeys, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
4809
5089
  // — the ONLY place the environment is read for this. `bin/start.mjs` is the
4810
5090
  // only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
4811
5091
  // environment through, so a stray `.env` can neither put a live install into demo mode nor take a