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 +21 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +25 -0
- package/driver/package.json +1 -1
- package/driver/portal-config-view.mjs +26 -1
- package/driver/portal-report.mjs +16 -2
- package/driver/portal-service.mjs +286 -6
- package/driver/publish/render.mjs +0 -45
- package/driver/publish/templates/report.css +1 -30
- package/driver/skills/knockout-assess/SKILL.md +35 -6
- package/driver/skills/knockout-frame/SKILL.md +2 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
- package/driver/stages.mjs +2 -2
- package/driver/suite-census.json +75 -9
- package/driver/verify-knockout.mjs +58 -0
- package/mcp-server/CHANGELOG.md +7 -0
- package/mcp-server/lib/audit.mjs +96 -3
- package/mcp-server/lib/http-handler.mjs +76 -3
- package/mcp-server/lib/runs.mjs +42 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +10 -6
- package/mcp-server/server.mjs +37 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-C-QysgZM.js} +1082 -630
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-CaSZbEMb.css} +15 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/shared/grants-edit.mjs +99 -2
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
package/driver/CHANGELOG.md
CHANGED
|
@@ -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
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.1-beta.
|
|
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() : [],
|
package/driver/portal-report.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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 === "
|
|
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
|