clearotron 0.2.2 → 0.2.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/.env.example +64 -37
- package/.nvmrc +1 -1
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +36 -14
- package/README.md +18 -22
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +10 -5
- package/bin/onboard.mjs +370 -28
- package/bin/start.mjs +220 -12
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/GLOSSARY.md +85 -0
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +6 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +19 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +90 -0
- package/driver/README.md +25 -6
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/dev-portal.mjs +2 -2
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/engine/probe.mjs +45 -5
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +20 -3
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +211 -106
- package/driver/plain-register.mjs +134 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +70 -2
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +79 -9
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +151 -2
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +111 -16
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +3 -3
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +54 -7
- package/driver/suite-census.json +220 -58
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +214 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +31 -19
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/e2e.mjs +2 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/generated-files-are-current.mjs +125 -0
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/mint-names-in-force.mjs +4 -2
- package/scripts/mint-public-residue.mjs +74 -0
- package/scripts/mint-reference-strip-backlog.mjs +12 -1
- package/scripts/mint-suite-census.mjs +5 -2
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/render-check.mjs +13 -2
- package/scripts/report-frame-check.mjs +8 -1
- package/scripts/report-screenshot.mjs +6 -3
- package/scripts/revisit-render-check.mjs +6 -3
- package/scripts/score.mjs +1 -1
- package/scripts/strip-tracker-citations.mjs +38 -7
- package/scripts/test-full.mjs +226 -0
- package/scripts/test-run.mjs +16 -0
- package/shared/README.md +2 -1
- package/shared/anon-overlay.mjs +3 -3
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +33 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +6 -1
- package/shared/node-floor.mjs +97 -0
- package/shared/os-advice.mjs +91 -0
- package/shared/reap-on-exit.mjs +42 -0
- package/shared/reference-guard-classes.mjs +351 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/shared/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE TWO-REGISTER RULE, AS THE REVIEWER READS IT — tracker issue 333.
|
|
5
|
+
//
|
|
6
|
+
// The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
|
|
7
|
+
// The band, the summary, the basis line and the one-liners are the whole product for the second reader,
|
|
8
|
+
// and they were the hardest lines on it: single sentences of seventy-odd words in the lawyer's
|
|
9
|
+
// vocabulary. The owner's ruling is that default-visible text carries no legal or engine vocabulary at
|
|
10
|
+
// all, and that inside a fold the lawyer's words are allowed where a plain one would lose precision.
|
|
11
|
+
//
|
|
12
|
+
// THIS IS ADVISORY AND MUST STAY ADVISORY. A hit is a rewrite of that line — never a disclosure to the
|
|
13
|
+
// client, never a run failure. The rule is presentation: it changes no band, no evidence and nothing
|
|
14
|
+
// that is searched.
|
|
15
|
+
//
|
|
16
|
+
// — AND IT MUST NOT FIRE ON THE MARK IT IS CLEARING.
|
|
17
|
+
//
|
|
18
|
+
// This is the defect `coverage-form.mjs` records one level in: a refusal that cannot tell a mark from
|
|
19
|
+
// engine vocabulary blocked a clearance on the mark SLICE, and a render-time substitution turned "AXIS
|
|
20
|
+
// Bank filed in class 36" into "group Bank filed in class 36" on a report clearing AXIS. Half the words
|
|
21
|
+
// here are ordinary English and several are plausible marks — PREVAIL, SENIOR, SPECIFICATION. A check
|
|
22
|
+
// that flagged the mark under clearance would put noise on exactly the report that matters most, so
|
|
23
|
+
// every term the run is about is excluded before the text is read.
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The lawyer's vocabulary, as WORKED EXAMPLES with the plain form beside each. Not a ban list: the
|
|
27
|
+
* issue rejects "a list of forbidden words as the mechanism" in terms, and this is what the reviewer
|
|
28
|
+
* offers a seat as the rewrite, which is a different thing from a gate that refuses.
|
|
29
|
+
*
|
|
30
|
+
* Each entry is [what a lawyer writes, what the reader needs]. The second half is the load-bearing one —
|
|
31
|
+
* a flag naming a word teaches nothing, and the seat has to produce a sentence.
|
|
32
|
+
*/
|
|
33
|
+
export const PLAIN_FORMS = Object.freeze([
|
|
34
|
+
["proprietor", "owner"],
|
|
35
|
+
["subsisting", "live"],
|
|
36
|
+
["specification", "goods list"],
|
|
37
|
+
["citable", "earlier marks the office can raise against you"],
|
|
38
|
+
["prevail", "win"],
|
|
39
|
+
["formative", "names built on"],
|
|
40
|
+
["belt-and-braces", "extra"],
|
|
41
|
+
["non-use attack", "could be cancelled for not being used"],
|
|
42
|
+
["on the record as it stands", "on what we found"],
|
|
43
|
+
["marks-and-goods comparison", "same name, same goods"],
|
|
44
|
+
["dispatch", "the request"],
|
|
45
|
+
["instructed", "what was asked"],
|
|
46
|
+
["chunk", ""],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** The longest visible sentence a reader should meet. The issue's number, not a derived one. */
|
|
50
|
+
export const SENTENCE_WORD_LIMIT = 25;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How a term in `PLAIN_FORMS` is looked for in prose — ONE definition, because two of them drift.
|
|
54
|
+
*
|
|
55
|
+
* THE INFLECTIONS ARE THE POINT, and they were the reason a second copy of this rule survived. The
|
|
56
|
+
* pre-delivery lint carried its own hand-tuned patterns — `\bproprietors?\b`, `\bprevails?\b|\bprevailing\b`
|
|
57
|
+
* — while this file built `\bproprietor\b` and matched neither plural. So the pinned source was the
|
|
58
|
+
* WEAKER of the two, and reading terms from it without this would have quietly narrowed what the live
|
|
59
|
+
* check catches: a consolidation that loses coverage is a regression wearing a tidy-up's clothes.
|
|
60
|
+
*
|
|
61
|
+
* A trailing `s`, `es`, `ed` or `ing` after the term, and a hyphen matching a space, which is how the
|
|
62
|
+
* same phrase is written in two documents by two people.
|
|
63
|
+
*/
|
|
64
|
+
export const termMatcher = (term) => new RegExp(
|
|
65
|
+
`\\b${term.replace(/[-]/g, "[- ]").replace(/\s+/g, "\\s+")}(?:e?s|ed|ing)?\\b`, "i");
|
|
66
|
+
|
|
67
|
+
/** Everything the run is ABOUT — the mark, its variants, the owners named. Never flagged. */
|
|
68
|
+
const ownTerms = (about = {}) => {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const v of [about.mark, ...(about.marks ?? []), ...(about.owners ?? []), ...(about.terms ?? [])]) {
|
|
71
|
+
const s = String(v ?? "").trim();
|
|
72
|
+
if (s) out.push(s.toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Sentences, split on terminators that end one. Crude on purpose — this counts words, not grammar. */
|
|
78
|
+
export const sentencesOf = (text) =>
|
|
79
|
+
String(text ?? "").split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
80
|
+
|
|
81
|
+
export const wordsIn = (sentence) => String(sentence ?? "").trim().split(/\s+/).filter(Boolean).length;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What is wrong with one default-visible line, as rewrite advice. `[]` means nothing to say.
|
|
85
|
+
*
|
|
86
|
+
* `about` carries the run's own marks and owners so they are never reported — see the header. A term
|
|
87
|
+
* that IS the thing being cleared is not the lawyer's vocabulary, it is the subject.
|
|
88
|
+
*/
|
|
89
|
+
export function plainRegisterFlags(text, about = {}) {
|
|
90
|
+
const raw = String(text ?? "");
|
|
91
|
+
if (!raw.trim()) return [];
|
|
92
|
+
const mine = ownTerms(about);
|
|
93
|
+
// Blank the run's own terms before reading, rather than filtering hits afterwards: a mark can contain
|
|
94
|
+
// one of these words ("PREVAIL"), and a hit inside it is not a hit at all.
|
|
95
|
+
let scan = raw;
|
|
96
|
+
for (const t of mine) {
|
|
97
|
+
if (!t) continue;
|
|
98
|
+
scan = scan.replace(new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), " ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const flags = [];
|
|
102
|
+
for (const [term, plain] of PLAIN_FORMS) {
|
|
103
|
+
if (!termMatcher(term).test(scan)) continue;
|
|
104
|
+
flags.push({
|
|
105
|
+
kind: "vocabulary",
|
|
106
|
+
term,
|
|
107
|
+
say: plain
|
|
108
|
+
? `"${term}" is the lawyer's word — the reader needs "${plain}". Rewrite the sentence, do not swap the word.`
|
|
109
|
+
: `"${term}" is an engine word and has no place on a page a client reads. Rewrite the sentence.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const s of sentencesOf(raw)) {
|
|
114
|
+
const n = wordsIn(s);
|
|
115
|
+
if (n > SENTENCE_WORD_LIMIT) {
|
|
116
|
+
flags.push({
|
|
117
|
+
kind: "length",
|
|
118
|
+
words: n,
|
|
119
|
+
say: `${n} words in one sentence, and a visible line takes ${SENTENCE_WORD_LIMIT}. Split it — one idea per sentence, `
|
|
120
|
+
+ "the conclusion first. Do not shorten it by dropping the reason.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return flags;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The fields a reader meets before opening anything. Named here rather than at each call site so the
|
|
129
|
+
* two products answer to one list — the knockout and the clearance drifted apart once already.
|
|
130
|
+
*/
|
|
131
|
+
export const DEFAULT_VISIBLE_FIELDS = Object.freeze({
|
|
132
|
+
knockout: ["summary", "batchOpener", "basis", "net", "factors", "counterFactors", "mitigation", "standardCaveats", "reviewerNotes"],
|
|
133
|
+
clearance: ["summary", "oneLiner", "registrability", "thirdPartyRights", "ownRights", "freedomToOperate", "reviewerNotes", "coverage"],
|
|
134
|
+
});
|
package/driver/portal-access.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const domainOf = (email) => { const e = String(email ?? "").toLowerCase(); const
|
|
|
17
17
|
/**
|
|
18
18
|
* makePrincipal({ email, grants, staffDomains }) →
|
|
19
19
|
* { role: "staff", email, accounts: "*" } — firm identity: everything, acting-for allowed
|
|
20
|
-
* | { role: "client", email, accounts: ["
|
|
20
|
+
* | { role: "client", email, accounts: ["foxglade", …] } — enrolled client: exactly the granted accounts
|
|
21
21
|
* | null — unknown identity: no portal (the door 403s)
|
|
22
22
|
* Staff wins over an (accidental) grants row; a client row with a tenant-wide "*" grant is honored
|
|
23
23
|
* but the role stays client (no staff surfaces).
|
|
@@ -49,6 +49,11 @@ import { readFlagSnapshot, engineFor, providersFor, postureDisagreement } from "
|
|
|
49
49
|
// reading is, because the question it was standing in for — does this still describe the box — now has
|
|
50
50
|
// a direct answer in `lastRun.disagrees`.
|
|
51
51
|
import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVED at read time, never stored
|
|
52
|
+
// THE ENGINE TABLE, READ FOR TWO WORDS. A row saying an engine cannot run has to name the program it
|
|
53
|
+
// could not find and the command that installs it, or the reader is told they have a problem and not
|
|
54
|
+
// what to do about it — and this table is already where the wizard and the run-door preflight read
|
|
55
|
+
// both of those, so naming them here adds no second description of an engine.
|
|
56
|
+
import { ENGINE_BINARIES } from "./driver.config.mjs";
|
|
52
57
|
|
|
53
58
|
/**
|
|
54
59
|
* The flag view.
|
|
@@ -60,6 +65,30 @@ import { engineMode } from "./config-inventory.mjs"; // — the mode is DERIVE
|
|
|
60
65
|
// One projection, used for whichever posture is the answer. Extracted when the live posture became that
|
|
61
66
|
// answer, so the LIVE reading and the LAST-RUN capture cannot be shaped differently and quietly invite a
|
|
62
67
|
// reader to compare two things that were built by two rules.
|
|
68
|
+
/**
|
|
69
|
+
* The engine block, plus the two words a reader needs when it cannot run.
|
|
70
|
+
*
|
|
71
|
+
* DERIVED FROM THE ID AT READ TIME, never stored in the capture. A capture written by an older build
|
|
72
|
+
* carries neither field, and a page that read them out of the capture would go quiet about the engine
|
|
73
|
+
* on exactly the deployments most likely to be misconfigured. The id is in every capture there has
|
|
74
|
+
* ever been, and the table is in this build.
|
|
75
|
+
*
|
|
76
|
+
* NULL FOR AN ENGINE THIS BUILD DOES NOT SHIP, which the row already has its own sentence for. Naming
|
|
77
|
+
* a program for an engine that does not exist here would be an invented fact.
|
|
78
|
+
*/
|
|
79
|
+
function withProgram(engine) {
|
|
80
|
+
if (!engine) return engine;
|
|
81
|
+
const spec = ENGINE_BINARIES[engine.id] ?? null;
|
|
82
|
+
return {
|
|
83
|
+
...engine,
|
|
84
|
+
// A BARE NAME, NEVER A RESOLVED PATH. `fallback` is what the table calls the program when nothing
|
|
85
|
+
// overrides it — "claude" — and it is what a reader types. The resolved path is this machine's
|
|
86
|
+
// layout and is deliberately kept out of anything a browser renders.
|
|
87
|
+
program: spec?.fallback ?? null,
|
|
88
|
+
install: spec?.install ?? null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
function postureView(snap) {
|
|
64
93
|
return {
|
|
65
94
|
flags: Object.entries(snap.flags ?? {}).map(([name, f]) => ({
|
|
@@ -72,7 +101,7 @@ function postureView(snap) {
|
|
|
72
101
|
killSwitch: (snap.killSwitches ?? []).includes(name),
|
|
73
102
|
})),
|
|
74
103
|
built: snap.built ?? null,
|
|
75
|
-
engine: engineFor(snap),
|
|
104
|
+
engine: withProgram(engineFor(snap)),
|
|
76
105
|
engineMode: engineFor(snap) ? engineMode(engineFor(snap)) : null,
|
|
77
106
|
providers: providersFor(snap),
|
|
78
107
|
};
|
|
@@ -239,6 +268,41 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
|
|
|
239
268
|
};
|
|
240
269
|
}
|
|
241
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Where the staff-domain rule was written, so a reader can go and undo it.
|
|
273
|
+
*
|
|
274
|
+
* ── WHY A PAGE THAT NAMES A RULE MUST ALSO NAME ITS ADDRESS ─────────────────────────────────────────
|
|
275
|
+
*
|
|
276
|
+
* The People & access screen renders the rule — "Anyone at <domain> — a rule, not a person" — and said
|
|
277
|
+
* nothing about where it came from. A reader who does not recognise the domain therefore learns that
|
|
278
|
+
* strangers may hold an administrator's view of their instance and has no next step at all: the value
|
|
279
|
+
* is in an environment variable, in one of two files depending on how the instance is run, and neither
|
|
280
|
+
* is named anywhere on the screen. The one outside reader who met this reported it as a back door,
|
|
281
|
+
* twice, which is the correct thing to do with an access rule you cannot trace.
|
|
282
|
+
*
|
|
283
|
+
* PURE, and it answers "could not tell" as itself. `envLoad` is `shared/env-local.mjs`'s own report of
|
|
284
|
+
* what this process read, so the answer describes the process actually serving the page rather than
|
|
285
|
+
* being composed from a path that some other process would have read — the distinction that module
|
|
286
|
+
* exists for. A service started by systemd took its configuration from an EnvironmentFile; a child of
|
|
287
|
+
* `clearotron start` was handed an explicit environment and read no file at all; a hand-run CLI read
|
|
288
|
+
* the CLI's file. Each gets its own sentence, because the remedy is a different file in each.
|
|
289
|
+
*/
|
|
290
|
+
export function staffRuleSource({ name = "PORTAL_STAFF_DOMAINS", value = "", envLoad = null,
|
|
291
|
+
unitEnvFile = null, cliEnvFile = null } = {}) {
|
|
292
|
+
if (!String(value ?? "").trim()) return null;
|
|
293
|
+
const reason = envLoad?.reason ?? null;
|
|
294
|
+
const applied = Array.isArray(envLoad?.applied) ? envLoad.applied : [];
|
|
295
|
+
if (reason === "read" && applied.includes(name))
|
|
296
|
+
return { name, where: `read from ${envLoad.path}` };
|
|
297
|
+
if (reason === "service-managed")
|
|
298
|
+
return { name, where: unitEnvFile ? `set in this service's environment file, ${unitEnvFile}` : "set in this service's environment" };
|
|
299
|
+
if (reason === "opted-out")
|
|
300
|
+
return { name, where: cliEnvFile
|
|
301
|
+
? `handed to this service by the command that started it, which takes it from ${cliEnvFile} or derives it from the sign-in address`
|
|
302
|
+
: "handed to this service by the command that started it" };
|
|
303
|
+
return { name, where: cliEnvFile ? `set in this service's environment (the file it would otherwise read is ${cliEnvFile})` : "set in this service's environment" };
|
|
304
|
+
}
|
|
305
|
+
|
|
242
306
|
/**
|
|
243
307
|
* The enrolment view: who is granted what, and where an enrolment is half done.
|
|
244
308
|
*
|
|
@@ -246,7 +310,7 @@ export function authView({ mode = "", oidcIssuer = "", team = "", jwksUrl = "",
|
|
|
246
310
|
* they are reported separately — a staff member absent from the grants file is normal, not a fault,
|
|
247
311
|
* and listing them as "unenrolled" would bury the real problems.
|
|
248
312
|
*/
|
|
249
|
-
export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null }) {
|
|
313
|
+
export function accessView({ grants, staffDomains = [], knownAccounts = [], grantsFile = null, staffRule = null }) {
|
|
250
314
|
const tenants = grants?.tenants ?? {};
|
|
251
315
|
const known = new Set(knownAccounts);
|
|
252
316
|
const people = [];
|
|
@@ -277,6 +341,10 @@ export function accessView({ grants, staffDomains = [], knownAccounts = [], gran
|
|
|
277
341
|
return {
|
|
278
342
|
people: people.sort((a, b) => a.email.localeCompare(b.email)),
|
|
279
343
|
staffDomains: [...staffDomains],
|
|
344
|
+
// An ADDITIONAL field rather than a reshape of `staffDomains`: that array is parsed by the browser
|
|
345
|
+
// contract and read by three screens' worth of arms, and a rule nobody can trace is a copy problem,
|
|
346
|
+
// not a data-shape problem. Null when there is no rule, or when the source could not be told.
|
|
347
|
+
staffRule,
|
|
280
348
|
// Accounts named in grants that no profile matches — the other typo direction.
|
|
281
349
|
unknownAccounts: [...unknownAccounts].sort(),
|
|
282
350
|
// Where to go to change any of this — a filename and a date, so "I want to add someone" has a
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// ── the shape ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
19
|
//
|
|
20
20
|
// { "schema": 1,
|
|
21
|
-
// "families": { "hydra-range": { "name": "Hydra range", "account": "
|
|
21
|
+
// "families": { "hydra-range": { "name": "Hydra range", "account": "foxglade" } },
|
|
22
22
|
// "of": { "<runId>": "hydra-range" } }
|
|
23
23
|
//
|
|
24
24
|
// Keyed by RUN rather than by mark. A mark is not a thing the pool stores — it is a grouping the browser
|
|
@@ -47,6 +47,7 @@ import { randomBytes, scryptSync, timingSafeEqual, createHmac } from "node:crypt
|
|
|
47
47
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
48
48
|
import { dirname, join } from "node:path";
|
|
49
49
|
import { homedir } from "node:os";
|
|
50
|
+
import { envPrefix } from "../shared/os-advice.mjs";
|
|
50
51
|
|
|
51
52
|
// ── the credential record ────────────────────────────────────────────────────────────────────────
|
|
52
53
|
//
|
|
@@ -164,7 +165,10 @@ export function passphraseResetCommand({ prefix = "", credentialPath = null, env
|
|
|
164
165
|
const base = `${prefix}clearotron passphrase --reset`;
|
|
165
166
|
const path = credentialPath ?? env.PORTAL_LOCAL_CREDENTIAL ?? null;
|
|
166
167
|
if (!path || path === credentialPathFor({}, home)) return base;
|
|
167
|
-
|
|
168
|
+
// `VAR=value cmd` IS POSIX-ONLY. PowerShell has no such juxtaposition — the assignment is its own
|
|
169
|
+
// statement there — so this line told a Windows reader their variable name was not a cmdlet, naming
|
|
170
|
+
// the wrong half of the command as the fault. Reported from a real run.
|
|
171
|
+
return `${envPrefix("PORTAL_LOCAL_CREDENTIAL", path)}${base}`;
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
/**
|
package/driver/portal-report.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { SUMMARY_BLOCK_LINE } from "../shared/summary-blocks.mjs";
|
|
|
21
21
|
// a brand lockup, links to the pool index, status, profiles, and a "Clients" dropdown listing
|
|
22
22
|
// EVERY customer by key:
|
|
23
23
|
//
|
|
24
|
-
// <a href="../customer/
|
|
24
|
+
// <a href="../customer/coastline/">… <a href="../customer/foxglade/">…
|
|
25
25
|
//
|
|
26
26
|
// That is correct for a file a lawyer opens from the archive, and it is a disclosure of the client list
|
|
27
27
|
// to anybody else. It has to come out before the document is embedded, and not because the nav is ugly:
|
|
@@ -38,7 +38,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
|
38
38
|
import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, resolveStoreRepoRoot, makeStoreCommit } from "../shared/store-in-repo.mjs"; //,
|
|
39
39
|
import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
|
|
40
40
|
import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
|
|
41
|
-
import { bareInvocation, invocationPrefix } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
|
|
41
|
+
import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
|
|
42
42
|
import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
|
|
43
43
|
import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
|
|
44
44
|
// — the portal became an ISSUANCE PATH here, deliberately and by owner ruling.
|
|
@@ -92,7 +92,7 @@ import { readFlagSnapshot, builtFor, registerCanCountFor, registerTerritoriesFor
|
|
|
92
92
|
import { isDemo, demoPostureLine } from "./demo-posture.mjs";
|
|
93
93
|
import { triggerCapGap, triggerCapWarning } from "./trigger-cap.mjs"; // F51 — one answer, three surfaces
|
|
94
94
|
import { makeUpstream } from "./portal-upstream.mjs";
|
|
95
|
-
import { flagView, accessView, observedView, authView } from "./portal-config-view.mjs";
|
|
95
|
+
import { flagView, accessView, observedView, authView, staffRuleSource } from "./portal-config-view.mjs";
|
|
96
96
|
import { livePosture } from "./flag-snapshot.mjs"; // — for the capture-vs-box comparison only, never for a value
|
|
97
97
|
import { familiesView, groupRuns, ungroupRuns } from "./portal-families.mjs";
|
|
98
98
|
import { validateJob } from "./enqueue-schema.mjs";
|
|
@@ -1029,6 +1029,11 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
|
|
|
1029
1029
|
export function makePortalService({
|
|
1030
1030
|
poolRoot, workspaceRoot, recipesDir = undefined, secret,
|
|
1031
1031
|
staffDomains = [], grants = null,
|
|
1032
|
+
// Where the staff-domain rule is written, for the People & access page to name. INJECTED, because
|
|
1033
|
+
// the answer is a fact about the PROCESS — which file, if any, it took its configuration from — and
|
|
1034
|
+
// this constructor is deliberately pure over its inputs. Null means "no rule, or could not tell",
|
|
1035
|
+
// and the page then says nothing rather than guessing at a path.
|
|
1036
|
+
staffRule = null,
|
|
1032
1037
|
// The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
|
|
1033
1038
|
// and the quota pre-check read their ledger beside these, so they count what the wall counts (:
|
|
1034
1039
|
// they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
|
|
@@ -1328,6 +1333,48 @@ export function makePortalService({
|
|
|
1328
1333
|
};
|
|
1329
1334
|
const selectorOf = (body) => (body.recipeKey ? `recipe:${body.recipeKey}` : `product:${body.product || "the account's default"}`);
|
|
1330
1335
|
|
|
1336
|
+
// ── The engine program, read once, for every surface that reports it ───────────────────────────
|
|
1337
|
+
//
|
|
1338
|
+
// WHY /me NEEDS THIS AT ALL. The settings page can ask `/portal/admin/config`, which already computes
|
|
1339
|
+
// the live-versus-capture comparison. New clearance cannot: that route is staff-only, and the reader
|
|
1340
|
+
// who gets stuck here is as often a client. A screen that asked it would get a 404 and fall back to
|
|
1341
|
+
// the generic advice — which is the exact defect this exists to end, delivered to the exact reader
|
|
1342
|
+
// who reported it.
|
|
1343
|
+
//
|
|
1344
|
+
// WHAT IT COSTS ON A NORMAL PAGE VIEW: NOTHING, and that is structural rather than a promise. The
|
|
1345
|
+
// question only has a wrong answer in one state — the capture says there is no engine program — so
|
|
1346
|
+
// the live reading is taken only in that state. An install with a working engine never reaches the
|
|
1347
|
+
// probe, and /me is the hottest endpoint in the portal.
|
|
1348
|
+
//
|
|
1349
|
+
// AND IT IS THE SAME MECHANISM THE SETTINGS PAGE USES, deliberately: `flagView` with a live posture,
|
|
1350
|
+
// and the same `engine program` row out of `lastRun.disagrees`. Two surfaces answering this question
|
|
1351
|
+
// by two routes is how they came to contradict each other in the first place.
|
|
1352
|
+
//
|
|
1353
|
+
// NOT CACHED, AND THAT IS THE POINT OF THE CHANGE. A held reading was written first and removed: it
|
|
1354
|
+
// would have meant a reader who restarted the engine service went on being told to restart it for
|
|
1355
|
+
// as long as the window lasted — the staleness this whole change exists to end, in miniature, in
|
|
1356
|
+
// the one place a reader is standing when they act on it. Measured in testing, 2026-09-08: 0.28ms
|
|
1357
|
+
// for a reading taken cold with the imports warm, and a 0.29ms median over 50 requests in the state
|
|
1358
|
+
// that takes one on every request, against 0.04ms in the state that takes none. It is taken only
|
|
1359
|
+
// where a search is already refusing, so there is nothing here worth trading a wrong answer for.
|
|
1360
|
+
async function engineProgramDisputed(mode) {
|
|
1361
|
+
// NOT DEMO, NOTHING TO DISPUTE. The capture already sees the program, so the only disagreement
|
|
1362
|
+
// left is the mirror — this box cannot see what the engine could — and that one does not change
|
|
1363
|
+
// what this screen says: the screen is not refusing a search in that state.
|
|
1364
|
+
if (mode !== "demo") return false;
|
|
1365
|
+
try {
|
|
1366
|
+
const live = await livePosture();
|
|
1367
|
+
// `disagrees` is [] on agreement, rows on disagreement, and null when there is no capture to
|
|
1368
|
+
// compare against — three facts, and only the middle one is this. A null must not read as false.
|
|
1369
|
+
const rows = flagView(poolRoot, { live }).lastRun?.disagrees;
|
|
1370
|
+
return Array.isArray(rows) ? rows.some((d) => d.what === "engine program") : null;
|
|
1371
|
+
} catch {
|
|
1372
|
+
// COULD NOT LOOK, AND IT SAYS SO. Null travels to the screen as "unknown" and the screen prints
|
|
1373
|
+
// the general advice, which is what it printed before this existed.
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1331
1378
|
async function route(method, path, identity, body = {}, query = {}) {
|
|
1332
1379
|
const principal = makePrincipal({ email: identity?.email, grants: grantsNow(), staffDomains });
|
|
1333
1380
|
const parts = path.replace(/\/+$/, "").split("/").filter(Boolean); // ["portal", ...]
|
|
@@ -1338,7 +1385,7 @@ export function makePortalService({
|
|
|
1338
1385
|
assertPrincipal(principal, { door: true }); // door check only — a multi-account client enters and gets the picker list
|
|
1339
1386
|
// `accountNames` — the DISPLAY name of each account this identity holds, and nothing else.
|
|
1340
1387
|
//
|
|
1341
|
-
// Every profile carries a name ("Vantor Labs", "
|
|
1388
|
+
// Every profile carries a name ("Vantor Labs", "Foxglade Interactive"); the account KEY is a
|
|
1342
1389
|
// slug ("vantor"). Staff read names because the account picker fetches the staff-only
|
|
1343
1390
|
// roster; a client had no name source at all, so the identical screen printed the slug at them.
|
|
1344
1391
|
// The same brand owner therefore read two different ways depending on who signed in, which is
|
|
@@ -1384,8 +1431,23 @@ export function makePortalService({
|
|
|
1384
1431
|
// writes at boot and derives the mode from `binaryPresent`, which that snapshot already
|
|
1385
1432
|
// carries. NULL when there is no snapshot to read, and null means THIS CANNOT ANSWER — the UI
|
|
1386
1433
|
// must leave the button alone rather than infer demo from an absent file.
|
|
1434
|
+
// READ ONCE. The payload names it and the program reading below is gated on it; two calls to
|
|
1435
|
+
// `flagView` here would be two reads of the same file that could disagree with each other.
|
|
1436
|
+
const meEngineMode = flagView(poolRoot).engineMode;
|
|
1387
1437
|
return { status: 200, json: { role: principal.role, email: principal.email, accounts: principal.accounts, accountNames,
|
|
1388
|
-
concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode:
|
|
1438
|
+
concurrentRuns: concurrentRunsCap(), brand: BRAND.name, engineMode: meEngineMode,
|
|
1439
|
+
// WHETHER THE PROGRAM IS ON THIS BOX WHILE THE ENGINE CANNOT SEE IT — true, false, or null
|
|
1440
|
+
// for "this could not be checked". The screen above renders one of three remedies from it,
|
|
1441
|
+
// and they are different remedies: install the CLI, restart the service that cannot see it,
|
|
1442
|
+
// or the general advice when nothing could be read. Only `engineMode: "demo"` can make this
|
|
1443
|
+
// anything but false; see `engineProgramDisputed`.
|
|
1444
|
+
engineProgramDisputed: await engineProgramDisputed(meEngineMode),
|
|
1445
|
+
// HOW THIS INSTALL ARRIVED, so a screen can name the setup command the reader can actually
|
|
1446
|
+
// type. `npm run setup` and `npx clearotron install` are the same wizard and each one is
|
|
1447
|
+
// unrunnable on the other route; the no-engine notice named one of them and was wrong for
|
|
1448
|
+
// half its readers. A WORD, never a command line and never a prefix: `invocationForm` can
|
|
1449
|
+
// answer with this machine's absolute path, and this value is rendered in a browser.
|
|
1450
|
+
setupRoute: installRoute(),
|
|
1389
1451
|
// — a button that always fails must not render as available. The reason is
|
|
1390
1452
|
// operator-shaped and staff-only; a client reads the generic sentence the button carries.
|
|
1391
1453
|
controls: { stop: { available: stopControl.available !== false,
|
|
@@ -2131,7 +2193,7 @@ export function makePortalService({
|
|
|
2131
2193
|
// command is a true fact about THIS INSTALL'S OWN DISK, useful to anyone with a shell on the
|
|
2132
2194
|
// box and useless to a hosted client who has no checkout. On a local install the reader IS the
|
|
2133
2195
|
// operator, which is why the split that already exists does the work an "is this deployment
|
|
2134
|
-
// local" inference would have done badly. Agreed
|
|
2196
|
+
// local" inference would have done badly. Agreed before building, because it
|
|
2135
2197
|
// changes what a signed-in staff user is shown.
|
|
2136
2198
|
//
|
|
2137
2199
|
// COMPOSED IN ONE PLACE and handed over as a string. The browser cannot know this install's
|
|
@@ -2822,7 +2884,7 @@ export function makePortalService({
|
|
|
2822
2884
|
const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
|
|
2823
2885
|
if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
|
|
2824
2886
|
} catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
|
|
2825
|
-
return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile }) };
|
|
2887
|
+
return { status: 200, json: accessView({ grants: grantsNow(), staffDomains, knownAccounts, grantsFile, staffRule }) };
|
|
2826
2888
|
}
|
|
2827
2889
|
// /portal/admin/observed — who has actually USED this instance lately, from the audit log.
|
|
2828
2890
|
//
|
|
@@ -3506,7 +3568,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
3506
3568
|
// local-auth block, so calling it from here threw ReferenceError and this route answered 500 —
|
|
3507
3569
|
// worse than the dead link it replaced, because a dead link looks like a dead link and a 500
|
|
3508
3570
|
// looks like the server broke. Found by `npm run lint:driver`, which says it in one line
|
|
3509
|
-
// (`'redirect' is not defined`), and by
|
|
3571
|
+
// (`'redirect' is not defined`), and by driving the route rather than reading it (review, 2026-08).
|
|
3510
3572
|
// Hoisting that helper would move it away from the comment explaining its own no-`?next=` rule,
|
|
3511
3573
|
// so the four lines live here instead.
|
|
3512
3574
|
if (!localAuth && url.pathname === "/portal/sign-out") {
|
|
@@ -3915,6 +3977,14 @@ const PORT = PORT_CHOICE.port;
|
|
|
3915
3977
|
const grants = () => loadGrants({});
|
|
3916
3978
|
const staffDomains = (process.env.PORTAL_STAFF_DOMAINS || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3917
3979
|
if (!staffDomains.length && !grants()) { log(`FATAL: neither PORTAL_STAFF_DOMAINS nor CLEAROTRON_ACCESS_FILE configured — nobody could ever sign in (fail-closed).`); process.exit(1); }
|
|
3980
|
+
// WHERE THAT RULE IS WRITTEN, resolved once at boot and handed to the service. `loaded` is this
|
|
3981
|
+
// process's own report of which file configured it — never a path composed here, which would answer
|
|
3982
|
+
// for a process that read nothing (see `envFileRead`'s note in shared/env-local.mjs).
|
|
3983
|
+
const { loaded, unitEnvPath, envLocalPath } = await import("../shared/env-local.mjs");
|
|
3984
|
+
const staffRule = staffRuleSource({
|
|
3985
|
+
value: process.env.PORTAL_STAFF_DOMAINS, envLoad: loaded,
|
|
3986
|
+
unitEnvFile: unitEnvPath(), cliEnvFile: envLocalPath(),
|
|
3987
|
+
});
|
|
3918
3988
|
|
|
3919
3989
|
const { config } = await import("./driver.config.mjs");
|
|
3920
3990
|
const { appendFileSync: append } = await import("node:fs");
|
|
@@ -4188,7 +4258,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4188
4258
|
// The result was not a failure. It was worse. With the overlay unset, resolveSkillPath falls back
|
|
4189
4259
|
// to the PRODUCT REPO's own driver/skills — where the customer frameworks either do not exist
|
|
4190
4260
|
// (a config-store-only customer → the page's fail-loud "could not be read" card) or exist as the SYNTHETIC
|
|
4191
|
-
// DEMO fixtures the
|
|
4261
|
+
// DEMO fixtures the repository carries for the suite (a framework titled "… risk framework
|
|
4192
4262
|
// (synthetic demo)", source_deck "content invented"). The second case renders with a title, a band
|
|
4193
4263
|
// ladder and band meanings, and is indistinguishable on screen from the client's real framework.
|
|
4194
4264
|
// A lawyer read invented risk definitions as their client's own for as long as this was live.
|
|
@@ -4356,7 +4426,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4356
4426
|
const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
|
|
4357
4427
|
// Re-read per request (a getter that rescans), so a workspace created after boot is counted.
|
|
4358
4428
|
queueDirs: () => config.queueDirs,
|
|
4359
|
-
secret, staffDomains, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
|
|
4429
|
+
secret, staffDomains, staffRule, grants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
|
|
4360
4430
|
// — the ONLY place the environment is read for this. `bin/start.mjs` is the
|
|
4361
4431
|
// only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
|
|
4362
4432
|
// environment through, so a stray `.env` can neither put a live install into demo mode nor take a
|
|
@@ -96,7 +96,7 @@ export const PATH_FIELDS = ["frameworkPath", "workedExamplesPath"];
|
|
|
96
96
|
/**
|
|
97
97
|
* The code-owned values, READ-ONLY, for display. The page shows them badged; it cannot send them.
|
|
98
98
|
*
|
|
99
|
-
* Two of them are PATHS INSIDE THE ENGINE — `skills/prelim-search/risk-framework
|
|
99
|
+
* Two of them are PATHS INSIDE THE ENGINE — `skills/prelim-search/risk-framework-<customer>.md` — and they
|
|
100
100
|
* are withheld from a client here, on the server, where the role is already in hand. The React page has
|
|
101
101
|
* filtered them out of its own render since the rebuild, but a filter in the browser is a display
|
|
102
102
|
* convenience and not a wall: the value still crossed the wire and was one devtools tab away.
|
|
@@ -129,7 +129,7 @@ export function readOnlyFields(profile, { staff = false } = {}) {
|
|
|
129
129
|
*
|
|
130
130
|
* house "…IP Risk Assessment Framework.pptx (Privileged & Confidential), transcribed 2026-07-05
|
|
131
131
|
* (doc 50); supersedes the client-transposed neutral default"
|
|
132
|
-
*
|
|
132
|
+
* demo "Synthetic demo transposition of a customer risk deck (structure faithful, content
|
|
133
133
|
* invented), doc 50 shape"
|
|
134
134
|
* triage "Ported from the interactive knockout-searches skill's 5-tier rating system (SKILL.md
|
|
135
135
|
* rating table + calibration rules), doc 50 shape"
|