clearotron 0.3.1-beta.2 → 0.3.1-beta.4
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 +33 -0
- package/driver/citation-census.json +3 -3
- package/driver/common-law-receipts.mjs +11 -1
- package/driver/connotation-search.mjs +56 -5
- package/driver/contract-e3-backlog.mjs +31 -31
- package/driver/contract-vocabulary.mjs +59 -58
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/gateway.mjs +29 -2
- package/driver/matter-frame-record.mjs +54 -1
- 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 +327 -8
- 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 +19 -5
- package/driver/suite-census.json +112 -16
- package/driver/verify-knockout.mjs +58 -0
- package/driver/verify.mjs +83 -7
- package/mcp-server/CHANGELOG.md +11 -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-ChIQsMYp.js} +1375 -817
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-DBIs21e4.css} +37 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/scripts/citation-line-check.mjs +53 -2
- package/scripts/deprecate-below.mjs +146 -0
- package/scripts/test-run.mjs +24 -1
- package/shared/connect-clients.mjs +52 -16
- package/shared/driver-dir.mjs +1 -1
- package/shared/grants-edit.mjs +99 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
+
//
|
|
5
|
+
// deprecate-below.mjs — put one warning on every published version below a named stable.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/deprecate-below.mjs --below 0.3.1 [--dry-run]
|
|
8
|
+
//
|
|
9
|
+
// ── WHY THIS IS A SCRIPT IN CI AND NOT SOMETHING A PERSON RUNS ──────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Nobody on a development box can do it: the publish credential lives only in CI, `npm whoami` is 401
|
|
12
|
+
// there by design, and that design is not something to work around. So the work is a script, and the way
|
|
13
|
+
// a person asks for it is a workflow dispatch.
|
|
14
|
+
//
|
|
15
|
+
// ── WHAT IT REFUSES, AND WHY EACH REFUSAL IS CHEAPER THAN THE MISTAKE ───────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// `npm deprecate` takes a RANGE, and a range is the dangerous part: `npm deprecate pkg "<1.0.0"` is one
|
|
18
|
+
// command that can warn every version a project ever shipped, and the undo is another command per
|
|
19
|
+
// version. So no range reaches npm from here. The versions are enumerated, compared one at a time, and
|
|
20
|
+
// deprecated one at a time, which is slower and is the point.
|
|
21
|
+
//
|
|
22
|
+
// THE NAMED STABLE IS NEVER TOUCHED, nor is anything above it. A message telling a reader to upgrade to
|
|
23
|
+
// the version they are already on is worse than no message: it reads as a defect in the version they
|
|
24
|
+
// just chose.
|
|
25
|
+
//
|
|
26
|
+
// ALREADY-DEPRECATED VERSIONS ARE LEFT ALONE. Re-deprecating replaces one message with another, and the
|
|
27
|
+
// existing one may be more specific than this one — a security note, say.
|
|
28
|
+
//
|
|
29
|
+
// ── THE READ-BACK IS PER VERSION, AND THAT IS NOT A STYLE CHOICE ────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// `npm view <pkg> deprecated --json` over a range returns a value whose SHAPE depends on how many
|
|
32
|
+
// versions matched: a string for one, an object keyed by version for several, and nothing at all for
|
|
33
|
+
// none. Reading that as a map is how a verification passes over versions it never checked. So each
|
|
34
|
+
// version is read back on its own, by its own exact spec, and the answer is a string or it is absent.
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
|
|
37
|
+
const PKG = "clearotron";
|
|
38
|
+
|
|
39
|
+
// NOTHING HAPPENS AT IMPORT. The argument parsing and its refusal used to sit here at the top, so
|
|
40
|
+
// importing this module to test its comparison ran the refusal and exited 2 before a single arm ran. A
|
|
41
|
+
// CLI module that does work when required is the same defect this repository has met with a top-level
|
|
42
|
+
// await, and the same fix: the body belongs in `main`, and `main` runs only from the entry check.
|
|
43
|
+
|
|
44
|
+
/** Compare two semver-ish versions. A pre-release sorts BELOW its own release, which is what npm means. */
|
|
45
|
+
export function compareVersions(a, b) {
|
|
46
|
+
const split = (v) => {
|
|
47
|
+
const [core, pre = ""] = String(v).split("-");
|
|
48
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre };
|
|
49
|
+
};
|
|
50
|
+
const A = split(a), B = split(b);
|
|
51
|
+
for (let i = 0; i < 3; i++) {
|
|
52
|
+
if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) < (B.nums[i] ?? 0) ? -1 : 1;
|
|
53
|
+
}
|
|
54
|
+
// Same core. No pre-release outranks any pre-release; two pre-releases compare as text, which is
|
|
55
|
+
// right for `beta.2` against `beta.10` only up to ten — so the identifiers are compared numerically
|
|
56
|
+
// where they are numbers.
|
|
57
|
+
if (A.pre === B.pre) return 0;
|
|
58
|
+
if (!A.pre) return 1;
|
|
59
|
+
if (!B.pre) return -1;
|
|
60
|
+
const ap = A.pre.split("."), bp = B.pre.split(".");
|
|
61
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
62
|
+
const x = ap[i], y = bp[i];
|
|
63
|
+
if (x === y) continue;
|
|
64
|
+
if (x === undefined) return -1;
|
|
65
|
+
if (y === undefined) return 1;
|
|
66
|
+
const nx = Number(x), ny = Number(y);
|
|
67
|
+
if (Number.isInteger(nx) && Number.isInteger(ny)) return nx < ny ? -1 : 1;
|
|
68
|
+
return x < y ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const npm = (...args) => execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
74
|
+
|
|
75
|
+
/** Every published version, and the deprecation message each carries. Read once, as a list. */
|
|
76
|
+
function published() {
|
|
77
|
+
const versions = JSON.parse(npm("view", PKG, "versions", "--json"));
|
|
78
|
+
return Array.isArray(versions) ? versions : [versions];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One version's deprecation message, read by its own exact spec. Null when it carries none. */
|
|
82
|
+
function deprecationOf(version) {
|
|
83
|
+
// `--json` on a field that is absent prints nothing at all, which JSON.parse refuses. An empty read is
|
|
84
|
+
// "this version carries no message", and it is a different fact from a read that failed — so a failure
|
|
85
|
+
// throws and a caller decides, rather than being folded into "not deprecated".
|
|
86
|
+
const out = npm("view", `${PKG}@${version}`, "deprecated", "--json").trim();
|
|
87
|
+
if (!out) return null;
|
|
88
|
+
const v = JSON.parse(out);
|
|
89
|
+
return typeof v === "string" && v.trim() ? v : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function main() {
|
|
93
|
+
const argv = process.argv.slice(2);
|
|
94
|
+
const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
95
|
+
const DRY = argv.includes("--dry-run");
|
|
96
|
+
const below = at("--below");
|
|
97
|
+
if (!below) {
|
|
98
|
+
console.error("deprecate-below: --below <version> is required — the stable everything under it points at.");
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
const MESSAGE = `This version is superseded. Please upgrade to ${below} or later: npm install ${PKG}@${below}`;
|
|
102
|
+
|
|
103
|
+
const all = published();
|
|
104
|
+
if (!all.length) {
|
|
105
|
+
console.error("deprecate-below: the registry listed no versions — refusing rather than reporting zero to deprecate.");
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
if (!all.includes(below)) {
|
|
109
|
+
console.error(`deprecate-below: ${below} is not a published version of ${PKG}. `
|
|
110
|
+
+ "Refusing: pointing readers at a version that does not exist is worse than leaving them unwarned.");
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const candidates = all.filter((v) => compareVersions(v, below) < 0);
|
|
115
|
+
console.log(`deprecate-below: ${all.length} published version(s); ${candidates.length} below ${below}.`);
|
|
116
|
+
|
|
117
|
+
let done = 0, skipped = 0, failed = 0;
|
|
118
|
+
for (const v of candidates) {
|
|
119
|
+
let existing;
|
|
120
|
+
try { existing = deprecationOf(v); }
|
|
121
|
+
catch (e) {
|
|
122
|
+
console.error(` ${v}: could NOT be read (${String(e.message).split("\n")[0]}) — left alone.`);
|
|
123
|
+
failed += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (existing) { console.log(` ${v}: already deprecated — left alone ("${existing.slice(0, 60)}")`); skipped += 1; continue; }
|
|
127
|
+
if (DRY) { console.log(` ${v}: would deprecate`); done += 1; continue; }
|
|
128
|
+
try {
|
|
129
|
+
npm("deprecate", `${PKG}@${v}`, MESSAGE);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(` ${v}: deprecate FAILED — ${String(e.stderr ?? e.message).split("\n")[0]}`);
|
|
132
|
+
failed += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// READ BACK, PER VERSION, and report what the registry says rather than that the command exited 0.
|
|
136
|
+
let now = null;
|
|
137
|
+
try { now = deprecationOf(v); } catch { now = null; }
|
|
138
|
+
if (now) { console.log(` ${v}: deprecated — registry reads back "${now.slice(0, 60)}"`); done += 1; }
|
|
139
|
+
else { console.error(` ${v}: deprecate reported success and the registry reads back NOTHING.`); failed += 1; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`deprecate-below: ${done} deprecated, ${skipped} already carried a message, ${failed} failed.`);
|
|
143
|
+
return failed ? 1 : 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (process.argv[1] && process.argv[1].endsWith("deprecate-below.mjs")) process.exit(main());
|
package/scripts/test-run.mjs
CHANGED
|
@@ -93,6 +93,24 @@ const PREFIX = "ct-testrun-";
|
|
|
93
93
|
// inside its parent's root, or the parent's cleanup removes a live child's fixtures.
|
|
94
94
|
const REAL_TMP = process.env.CT_TEST_TMP_BASE || tmpdir();
|
|
95
95
|
|
|
96
|
+
// ── THE MACHINE'S OWN TEMP ROOT, WHICH IS NOT THE SAME QUESTION AS `REAL_TMP` ──────────────────────
|
|
97
|
+
//
|
|
98
|
+
// `REAL_TMP` answers "where does THIS run put its root", and a caller may redirect it — the runner's own
|
|
99
|
+
// tests do, giving each case a sandbox base so they never read or write the real temp directory. That is
|
|
100
|
+
// correct for rooting and wrong for CONTAINMENT: redirecting it also removed the machine's temp
|
|
101
|
+
// directory from the containment list, and a nested run was then refused for being pointed at a sibling
|
|
102
|
+
// temp root that is perfectly contained.
|
|
103
|
+
//
|
|
104
|
+
// IT ONLY EVER WORKED BY WAY OF THE LITERAL `/tmp`. In CI `TMPDIR` is unset, every root is under `/tmp`,
|
|
105
|
+
// and the literal in `PLATFORM_TMP` below covered the sibling. On a box whose temp directory is anywhere
|
|
106
|
+
// else — this one exports `TMPDIR=/mnt/datadisk1/tmp` — nothing in the list covered it, so ten arms that
|
|
107
|
+
// drive the runner recursively failed here and passed in CI on the same commit. The guard was not
|
|
108
|
+
// protecting a differently-rooted box at all; it was refusing it.
|
|
109
|
+
//
|
|
110
|
+
// So this is read ONCE, at the outermost run, before `TMPDIR` is rewritten, and passed down untouched.
|
|
111
|
+
// A caller's sandbox base does not displace it, because it is not answering that caller's question.
|
|
112
|
+
const MACHINE_TMP = String(process.env.CT_TEST_MACHINE_TMP ?? "").trim() || tmpdir();
|
|
113
|
+
|
|
96
114
|
// A run killed with SIGKILL (or a machine that reboots) never reaches its own cleanup and leaves one
|
|
97
115
|
// directory behind. One per killed run is a tractable number, but it should not accumulate forever, so
|
|
98
116
|
// each run clears the abandoned roots of previous ones. Bounded three ways: the exact prefix we own, an
|
|
@@ -372,7 +390,7 @@ const DATA_PLANE_VARS = Object.freeze([
|
|
|
372
390
|
// or /srv, never a temp filesystem.
|
|
373
391
|
const PLATFORM_TMP = process.platform === "win32" ? [] : ["/tmp"];
|
|
374
392
|
const CONTAINMENT_ROOTS = Object.freeze([...new Set(
|
|
375
|
-
[REAL_TMP, tmpdir(), ...PLATFORM_TMP].map((p) => resolve(p)),
|
|
393
|
+
[REAL_TMP, MACHINE_TMP, tmpdir(), ...PLATFORM_TMP].map((p) => resolve(p)),
|
|
376
394
|
)]);
|
|
377
395
|
const isContained = (value) => {
|
|
378
396
|
const p = resolve(value);
|
|
@@ -689,6 +707,11 @@ child = spawn(argv[0], argv.slice(1), {
|
|
|
689
707
|
// the one the top of this file reads before TMPDIR is rewritten, so the chain stays anchored to the
|
|
690
708
|
// real temp directory however deep the nesting goes. A caller's own value wins, as everywhere else.
|
|
691
709
|
CT_TEST_TMP_BASE: String(process.env.CT_TEST_TMP_BASE ?? "").trim() || REAL_TMP,
|
|
710
|
+
// AND THE MACHINE'S TEMP ROOT, carried down unchanged however deep the nesting goes. Unlike the line
|
|
711
|
+
// above it this one is NOT a caller's to redirect: a caller redirecting where a run roots itself is
|
|
712
|
+
// saying nothing about which filesystem locations count as temporary, and conflating the two is what
|
|
713
|
+
// made a correctly contained sibling read as somebody's live estate.
|
|
714
|
+
CT_TEST_MACHINE_TMP: MACHINE_TMP,
|
|
692
715
|
TRADEMARK_MCP_AUDIT_LOG: String(process.env.TRADEMARK_MCP_AUDIT_LOG ?? "").trim()
|
|
693
716
|
|| join(root, "mcp-access.jsonl"),
|
|
694
717
|
// AND THE PORTAL'S AUDIT LOG, for the same reason and by the same rule as the line above it.
|
|
@@ -105,9 +105,11 @@ const BRIEF = "ask it to brief you on your clearances.";
|
|
|
105
105
|
export const DOOR_KINDS = Object.freeze(["sign-in", "key"]);
|
|
106
106
|
const SIGNIN_HINT = "No key: this connector signs you in through your browser, and the sign-in is the "
|
|
107
107
|
+ "authentication your assistant is asking about.";
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// THE UNKNOWN DOOR IS THE PAGE'S SENTENCE NOW, NOT A STEP HINT. This hint said "both ways are shown"
|
|
109
|
+
// while one set was drawn — a promise the page could not keep — and it said it in the words that screen
|
|
110
|
+
// is not allowed to show a reader. The panel states it once, above both lists, and the lists make it
|
|
111
|
+
// true. A hint under step one repeating it would be the same sentence twice, the second time in
|
|
112
|
+
// vocabulary the reader did not ask for.
|
|
111
113
|
|
|
112
114
|
/**
|
|
113
115
|
* Every app we can speak to, and the steps for each route. Adding one is a row.
|
|
@@ -159,7 +161,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
159
161
|
] : [
|
|
160
162
|
// THE SIGN-IN DOOR. No key is minted and no header is set: the warning the old steps told the
|
|
161
163
|
// reader to ignore IS the sign-in, and following it is the whole of the connection.
|
|
162
|
-
{ text: "Copy the address.", copy: "address", hint: door === null ?
|
|
164
|
+
{ text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
|
|
163
165
|
{ text: "In Claude, open **Settings → Connectors → Add custom connector**." },
|
|
164
166
|
{ text: "Paste the address and press **Add**." },
|
|
165
167
|
{ text: `Sign in when the browser opens — use ${operator ?? "your work email"}.` },
|
|
@@ -185,7 +187,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
185
187
|
{ text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
|
|
186
188
|
] : [
|
|
187
189
|
// The command carries no header, because a door that signs its reader in never honours one.
|
|
188
|
-
{ text: "Copy this command.", copy: "claude-cli-http-signin", hint: door === null ?
|
|
190
|
+
{ text: "Copy this command.", copy: "claude-cli-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
|
|
189
191
|
{ text: "Paste it into a terminal and press Enter." },
|
|
190
192
|
{ text: "Sign in when the browser opens." },
|
|
191
193
|
{ text: `Start Claude Code and ${BRIEF}`, hint: CHECK_HINT },
|
|
@@ -220,7 +222,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
220
222
|
{ text: "Add a custom connector and paste the address." },
|
|
221
223
|
{ text: "Give the key as the connector's bearer token — the second line." },
|
|
222
224
|
] : [
|
|
223
|
-
{ text: "Copy the address.", copy: "address", hint:
|
|
225
|
+
{ text: "Copy the address.", copy: "address", hint: undefined },
|
|
224
226
|
{ text: "In ChatGPT on the web, turn on **Settings → Security and login → Developer mode**.",
|
|
225
227
|
hint: "Needs a Plus, Pro, Business, Enterprise or Edu plan. On a company plan, your admin may have to allow it." },
|
|
226
228
|
{ text: "Add a custom connector and paste the address." },
|
|
@@ -252,7 +254,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
252
254
|
hint: "Codex reads the key from there, so it never sits in the settings file." },
|
|
253
255
|
{ text: `Restart Codex and ${BRIEF}` },
|
|
254
256
|
] : [
|
|
255
|
-
{ text: "Copy this.", copy: "codex-toml-http-signin", hint: door === null ?
|
|
257
|
+
{ text: "Copy this.", copy: "codex-toml-http-signin", hint: door === null ? undefined : SIGNIN_HINT },
|
|
256
258
|
{ text: "Open `~/.codex/config.toml` and paste it at the end." },
|
|
257
259
|
// ITS OWN STEP, not a hint on the one before it. The sign-in IS the connection here, and a
|
|
258
260
|
// reader skimming numbered steps does not read the small print under one of them.
|
|
@@ -282,7 +284,7 @@ export const CONNECT_CLIENTS = Object.freeze([
|
|
|
282
284
|
{ text: "Paste the address and the key wherever your app adds a custom MCP server.",
|
|
283
285
|
hint: "It may call them “server URL” and “bearer token”." },
|
|
284
286
|
] : [
|
|
285
|
-
{ text: "Copy the address.", copy: "address", hint: door === null ?
|
|
287
|
+
{ text: "Copy the address.", copy: "address", hint: door === null ? undefined : SIGNIN_HINT },
|
|
286
288
|
{ text: "Paste it wherever your app adds a custom MCP server, and sign in when the browser opens.",
|
|
287
289
|
hint: "It may call the address the “server URL”. There is no token to give it." },
|
|
288
290
|
]),
|
|
@@ -311,14 +313,24 @@ export const offersForWire = (offers) =>
|
|
|
311
313
|
id: client.id,
|
|
312
314
|
name: client.name,
|
|
313
315
|
...(client.sub ? { sub: client.sub } : {}),
|
|
314
|
-
steps: (
|
|
315
|
-
text: s.text,
|
|
316
|
-
...(s.hint ? { hint: s.hint } : {}),
|
|
317
|
-
...(s.copy ? { copy: s.copy.kind === "secret"
|
|
318
|
-
? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
|
|
319
|
-
: { kind: "block", text: s.copy.text } } : {}),
|
|
320
|
-
})),
|
|
316
|
+
steps: stepsForWire(steps),
|
|
321
317
|
...rest,
|
|
318
|
+
// THE ALTERNATIVE TRAVELS, AND SO DOES THE FACT THAT THE DOOR IS UNKNOWN. `...rest` carried neither:
|
|
319
|
+
// `altSteps` needs the same copy-shape mapping the primary set gets, and `door` was dropped on the
|
|
320
|
+
// floor, so the page had nothing to branch on and drew one set of steps as though the door had been
|
|
321
|
+
// read. Both are stated after the spread so a row cannot pass its own raw shape through.
|
|
322
|
+
...(Array.isArray(rest.altSteps) ? { altSteps: stepsForWire(rest.altSteps) } : {}),
|
|
323
|
+
...(Object.hasOwn(rest, "door") ? { door: rest.door ?? null } : {}),
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
/** One route's steps, in the shape the browser reads. The alternative set gets the same mapping. */
|
|
327
|
+
const stepsForWire = (steps) =>
|
|
328
|
+
(Array.isArray(steps) ? steps : []).map((s) => ({
|
|
329
|
+
text: s.text,
|
|
330
|
+
...(s.hint ? { hint: s.hint } : {}),
|
|
331
|
+
...(s.copy ? { copy: s.copy.kind === "secret"
|
|
332
|
+
? { kind: "secret", label: s.copy.label, template: s.copy.template, slot: s.copy.slot }
|
|
333
|
+
: { kind: "block", text: s.copy.text } } : {}),
|
|
322
334
|
}));
|
|
323
335
|
|
|
324
336
|
const ALIAS_ROUTE = new Map(CONNECT_CLIENTS.flatMap((c) =>
|
|
@@ -395,6 +407,30 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
|
|
|
395
407
|
const asked = author.steps({ operator, door: route === "public-http" ? door : null });
|
|
396
408
|
const steps = asked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }));
|
|
397
409
|
const resolved = steps.every((s, i) => !asked[i].copy || s.copy);
|
|
410
|
+
|
|
411
|
+
// ── WHEN THE DOOR COULD NOT BE READ, BOTH WAYS ARE ACTUALLY SHOWN ────────────────────────────────
|
|
412
|
+
//
|
|
413
|
+
// `doorKind` returns null for "not known" and its own contract says the caller offers both rather than
|
|
414
|
+
// guessing. The steps above already carry the sentence that says so — and it reads "both ways are
|
|
415
|
+
// shown" while one set was rendered. A page that promises the reader the alternative and then does not
|
|
416
|
+
// draw it is worse than one that never mentioned it: the reader goes looking for what they were told
|
|
417
|
+
// is there.
|
|
418
|
+
//
|
|
419
|
+
// So the OTHER door's steps are composed here and ride beside them. The sign-in set leads, because
|
|
420
|
+
// that is what every hosted connector answers and what the hint tells the reader to try first; the key
|
|
421
|
+
// set is the alternative rather than a second equal choice. Composed by asking the same author with
|
|
422
|
+
// the other answer, never by a second copy of the steps — one table, one author, as everything else in
|
|
423
|
+
// this file.
|
|
424
|
+
const altAsked = route === "public-http" && door === null
|
|
425
|
+
? author.steps({ operator, door: "key" })
|
|
426
|
+
: null;
|
|
427
|
+
const altSteps = altAsked
|
|
428
|
+
? altAsked.map((s) => (s.copy ? { ...s, copy: resolve(s.copy) } : { ...s }))
|
|
429
|
+
: null;
|
|
430
|
+
// A step whose copy this deployment cannot produce is not offered at all — the same rule the primary
|
|
431
|
+
// set follows two lines up. Half an alternative is a reader following steps that stop.
|
|
432
|
+
const altResolved = !altAsked || altSteps.every((s, i) => !altAsked[i].copy || s.copy);
|
|
433
|
+
const alt = altResolved && altSteps?.length ? { door: null, altSteps } : { ...(route === "public-http" ? { door } : {}) };
|
|
398
434
|
const evidence = { ...(author.verifiedOn ? { verifiedOn: author.verifiedOn } : {}), ...(author.by ? { by: author.by } : {}) };
|
|
399
435
|
|
|
400
436
|
if (route === "disk") {
|
|
@@ -431,7 +467,7 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
|
|
|
431
467
|
fix: "whoever installed it can put it online — it takes about a minute and needs no account",
|
|
432
468
|
operatorFix: "put it online and set CLEAROTRON_CLIENT_MCP_URL to the public URL of this install — INSTALL.md §7 walks the tunnel" };
|
|
433
469
|
}
|
|
434
|
-
return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
|
|
470
|
+
return { client, served: true, route, steps, ...alt, launch: client.launch ?? null, enables: null, ...evidence,
|
|
435
471
|
command: null, stdio: null, address: publicAddress, key: "issued",
|
|
436
472
|
note: "This assistant connects through its maker's service, so it reaches this installation at its web address rather than from your machine." };
|
|
437
473
|
}
|
package/shared/driver-dir.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// its own population needs an exception list — which would rebuild this issue's defect inside its fix:
|
|
36
36
|
//
|
|
37
37
|
// · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
|
|
38
|
-
// · `driver/pipeline.mjs:
|
|
38
|
+
// · `driver/pipeline.mjs:15755 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
|
|
39
39
|
// directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
|
|
40
40
|
//
|
|
41
41
|
// ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────
|
package/shared/grants-edit.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
// grants-edit.mjs — the changes a person makes to the grants file: give someone access,
|
|
4
|
-
// company under its organisation.
|
|
3
|
+
// grants-edit.mjs — the changes a person makes to the grants file: give someone access, take access
|
|
4
|
+
// away, and file a new company under its organisation.
|
|
5
5
|
//
|
|
6
6
|
// PURE. Each function takes the parsed grants object and returns a new one; the caller reads the file,
|
|
7
7
|
// applies the change and writes it atomically. One function per change, shared by every writer — the
|
|
@@ -47,6 +47,103 @@ export function withPerson(grants, { email, points = [], switches = {}, setSwitc
|
|
|
47
47
|
return g;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What a person holds now, read straight out of the file: `{ points, switches, listed }`.
|
|
52
|
+
*
|
|
53
|
+
* The form that changes somebody has to open filled in, and "filled in" means the file's own answer, not
|
|
54
|
+
* the page's copy of it. `points` are the same `[{ tenant, account }]` shape `withPerson` takes, so a
|
|
55
|
+
* caller can diff what it was handed against what is there and hand each half to the right function.
|
|
56
|
+
*
|
|
57
|
+
* ADDRESSES ARE MATCHED WITHOUT REGARD TO CASE, in both places a person can appear. Every writer in this
|
|
58
|
+
* module lowercases on the way in, but the grants file is one an operator edits by hand, and a file
|
|
59
|
+
* holding `Dana@x.example` under `people` and `dana@x.example` under a tenant is a file this product
|
|
60
|
+
* loads and serves. A reader that matched exactly would report half of such a person and miss the half
|
|
61
|
+
* carrying their permissions.
|
|
62
|
+
*/
|
|
63
|
+
export function personPoints(grants, email) {
|
|
64
|
+
const e = String(email ?? "").trim().toLowerCase();
|
|
65
|
+
const g = grants ?? {};
|
|
66
|
+
const points = [];
|
|
67
|
+
for (const [tenant, t] of Object.entries(g.tenants ?? {})) {
|
|
68
|
+
const key = Object.keys(t?.users ?? {}).find((k) => String(k).toLowerCase() === e);
|
|
69
|
+
if (key === undefined) continue;
|
|
70
|
+
const held = t.users[key];
|
|
71
|
+
if (held === "*") { points.push({ tenant, account: null }); continue; }
|
|
72
|
+
for (const account of Array.isArray(held) ? held : []) points.push({ tenant, account });
|
|
73
|
+
}
|
|
74
|
+
const entry = Object.entries(g.people ?? {}).find(([k]) => String(k).toLowerCase() === e)?.[1];
|
|
75
|
+
return {
|
|
76
|
+
points,
|
|
77
|
+
switches: { run: entry?.run === true, manage: entry?.manage === true, everything: entry?.everything === true },
|
|
78
|
+
listed: entry !== undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Take access away — the other half of `withPerson`, and deliberately its neighbour: one file decides what
|
|
84
|
+
* a person IS, so the page and `clearotron grant` cannot drift into two answers.
|
|
85
|
+
*
|
|
86
|
+
* Two shapes, and the difference is the whole of the safety here.
|
|
87
|
+
*
|
|
88
|
+
* `all: true` removes the person from the install: every tenant's guest list AND their entry under
|
|
89
|
+
* `people`, which is where their permissions and any access to everything live.
|
|
90
|
+
*
|
|
91
|
+
* `points` NARROWS instead, and never touches `people`. That restraint is the point: a manager who holds
|
|
92
|
+
* one organisation can see only that organisation's half of somebody, so a narrowing they order must not
|
|
93
|
+
* reach an entry they cannot read. It also means a narrowing CANNOT be honest about a person who holds
|
|
94
|
+
* everything — that access does not live in any tenant, so striking tenant rows would leave them seeing
|
|
95
|
+
* exactly what they saw before, under a sentence saying otherwise. That case is refused rather than
|
|
96
|
+
* warned about: a warning printed after the write is read by whoever is already looking.
|
|
97
|
+
*
|
|
98
|
+
* A company inside an organisation the person holds WHOLE is refused too. `"*"` means "this organisation,
|
|
99
|
+
* including companies added later", and the nearest expressible narrowing — today's list of companies,
|
|
100
|
+
* minus one — is a different grant wearing the same shape. The caller offers the organisation instead.
|
|
101
|
+
*/
|
|
102
|
+
export function withoutPerson(grants, { email, points = [], all = false }) {
|
|
103
|
+
const e = String(email ?? "").trim().toLowerCase();
|
|
104
|
+
if (!e) throw new Error("removing access needs an email address");
|
|
105
|
+
const g = structuredClone(grants ?? { tenants: {} });
|
|
106
|
+
g.tenants ??= {};
|
|
107
|
+
const held = personPoints(g, e);
|
|
108
|
+
if (!all && !points.length) throw new Error("nothing was named to take away");
|
|
109
|
+
if (!all && held.switches.everything)
|
|
110
|
+
throw new Error(`${e} has access to everything on this install, which is not held in any organisation`
|
|
111
|
+
+ " — taking away one organisation would change nothing they can see. Remove them from the install instead.");
|
|
112
|
+
|
|
113
|
+
const userKeyIn = (t) => Object.keys(t?.users ?? {}).find((k) => String(k).toLowerCase() === e);
|
|
114
|
+
|
|
115
|
+
if (all) {
|
|
116
|
+
for (const t of Object.values(g.tenants)) {
|
|
117
|
+
const key = userKeyIn(t);
|
|
118
|
+
// AN EMPTY `users` MAP IS NOT A DELETED TENANT. The organisation still exists and still holds its
|
|
119
|
+
// companies; it simply has nobody on its guest list.
|
|
120
|
+
if (key !== undefined) { t.users = { ...t.users }; delete t.users[key]; }
|
|
121
|
+
}
|
|
122
|
+
const peopleKey = Object.keys(g.people ?? {}).find((k) => String(k).toLowerCase() === e);
|
|
123
|
+
if (peopleKey !== undefined) { g.people = { ...g.people }; delete g.people[peopleKey]; }
|
|
124
|
+
assertGrantsShape(g, "the grants file after this change");
|
|
125
|
+
return g;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const { tenant, account = null } of points) {
|
|
129
|
+
const t = g.tenants[tenant];
|
|
130
|
+
if (!t) throw new Error(`there is no organisation "${tenant}"`);
|
|
131
|
+
const key = userKeyIn(t);
|
|
132
|
+
if (key === undefined) continue;
|
|
133
|
+
const have = t.users[key];
|
|
134
|
+
t.users = { ...t.users };
|
|
135
|
+
if (account == null) { delete t.users[key]; continue; }
|
|
136
|
+
if (have === "*")
|
|
137
|
+
throw new Error(`${e} holds the whole of "${tenant}", including companies added to it later,`
|
|
138
|
+
+ ` so "${account}" cannot be taken away on its own. Take away the organisation instead.`);
|
|
139
|
+
const left = (Array.isArray(have) ? have : []).filter((a) => a !== account);
|
|
140
|
+
if (left.length) t.users[key] = left;
|
|
141
|
+
else delete t.users[key];
|
|
142
|
+
}
|
|
143
|
+
assertGrantsShape(g, "the grants file after this change");
|
|
144
|
+
return g;
|
|
145
|
+
}
|
|
146
|
+
|
|
50
147
|
/** File a company under the organisation that holds it. A company belongs to exactly one organisation. */
|
|
51
148
|
export function withCompany(grants, { tenant, account }) {
|
|
52
149
|
const g = structuredClone(grants ?? { tenants: {} });
|