clearotron 0.3.2-beta.10 → 0.3.2-beta.12
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 +1 -1
- package/CONTRIBUTING.md +1 -0
- package/INSTALL.md +49 -46
- package/README.md +8 -7
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +51 -14
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +43 -9
- package/build-info.json +2 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +416 -388
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +21 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +16 -7
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/pipeline.mjs +48 -1
- package/driver/portal-local-auth.mjs +14 -4
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +20 -11
- package/driver/publish/attr.mjs +36 -0
- package/driver/publish/index.mjs +10 -10
- package/driver/publish/parse.mjs +1 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +9 -9
- package/driver/publish/xlsx.mjs +7 -1
- package/driver/queue-watch-verdict.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +104 -38
- package/driver/unit-inventory.mjs +7 -7
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +27 -19
- package/mcp-server/lib/audit-view.mjs +4 -4
- package/mcp-server/lib/trace.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -2
- package/providers/jx-subclass/README.md +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/README.md +5 -10
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +8 -8
- package/scripts/env-audit.mjs +6 -0
- package/scripts/generated-files-are-current.mjs +35 -4
- package/scripts/live-surface-check.mjs +17 -17
- package/scripts/release-code-scanning-check.mjs +114 -0
- package/scripts/release-visible-check.mjs +117 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/invocation.mjs +2 -2
- package/shared/parent-watch.mjs +33 -0
- package/shared/register-selection.mjs +4 -1
- package/shared/root-doc-commands.mjs +10 -4
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +24 -5
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// D4 verify.mjs parseCoverageLedgerJson, same shape
|
|
22
22
|
// D5 verify.mjs:1504 fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
|
|
23
23
|
// D6 verify.mjs:1567 fail(`${violations[0].token}…`) — validatePlanFeasibility in register-plan.mjs
|
|
24
|
-
// D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:
|
|
24
|
+
// D7 verify.mjs:1558 fail(`${v2[0].token}${detail}…`) — register-plan.mjs:2270 disclosureTextByAxis
|
|
25
25
|
// D8 verify.mjs:2470 caseLawLedgerFail fail(caseLawLedgerFail(…)) — token built in case-law-ledger.mjs:195 caseLawLedgerFail
|
|
26
26
|
//
|
|
27
27
|
// A partition built on the 60 tokens a regex CAN see would run green while blind to the rest, which is
|
|
@@ -139,10 +139,10 @@ export const VOCABULARY = [
|
|
|
139
139
|
{ token: "coverage_form_missing", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
140
140
|
{ token: "coverage_form_empty", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
141
141
|
{ token: "coverage_status_offenum", stages: ["register-digest"], site: "driver/verify.mjs:2050" },
|
|
142
|
-
{ token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:
|
|
143
|
-
{ token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
144
|
-
{ token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
145
|
-
{ token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:
|
|
142
|
+
{ token: "coverage_deferred_unaccounted", stages: ["register-digest"], site: "driver/verify.mjs coverageFormFail", family: "driver/register-plan.mjs:1947 PROVIDER_HARD_ERROR_PREFIX — token on a data row", dynamic: "D5" },
|
|
143
|
+
{ token: "coverage_clean_unexecuted", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:1681 validatePlanFeasibility", dynamic: "D6" },
|
|
144
|
+
{ token: "coverage_clean_skipped", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2042 searchedJurisdictionsFromPlan", dynamic: "D6" },
|
|
145
|
+
{ token: "coverage_clean_unverified_incomplete", stages: ["register-digest"], site: "driver/verify.mjs, the matterContext validator", family: "driver/register-plan.mjs:2270 disclosureTextByAxis", dynamic: "D7" },
|
|
146
146
|
{ token: "coverage_clean_tainted", stages: ["register-digest"], site: "driver/verify.mjs" },
|
|
147
147
|
{ token: "coverage_ledger_", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs (parseCoverageLedgerJson token-first throws)", dynamic: "D4" },
|
|
148
148
|
{ token: "coverage_key_unknown", stages: ["register-digest"], site: "driver/verify.mjs", family: "driver/coverage-ledger.mjs", dynamic: "D4" },
|
|
@@ -826,6 +826,26 @@ serve({
|
|
|
826
826
|
},
|
|
827
827
|
},
|
|
828
828
|
},
|
|
829
|
+
// OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
|
|
830
|
+
// and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
|
|
831
|
+
// the owner approved it; it is model-facing prose, so its wording is his.
|
|
832
|
+
house_element_candidate: {
|
|
833
|
+
type: "object",
|
|
834
|
+
description:
|
|
835
|
+
"Only when an element of the mark is one the CLIENT already owns as a registered mark in the " +
|
|
836
|
+
"instructed classes (a house mark before a tagline, for instance): name that element, the remainder " +
|
|
837
|
+
"the analysis should be limited to, and why you read it as the client's own. Ownership is checked " +
|
|
838
|
+
"on the register, by owner, before anything is excluded; if it cannot be verified, nothing is. Omit " +
|
|
839
|
+
"the field when no element is the client's own.",
|
|
840
|
+
required: ["element", "remainder", "owner_basis"],
|
|
841
|
+
properties: {
|
|
842
|
+
element: { type: "string", description: "The client's own element, exactly as it appears in the mark." },
|
|
843
|
+
remainder: { type: "string",
|
|
844
|
+
description: "The rest of the mark: the part the analysis is limited to. Never empty, never the element itself." },
|
|
845
|
+
owner_basis: { type: "string",
|
|
846
|
+
description: "Why you read the element as the client's own — what in the matter says so." },
|
|
847
|
+
},
|
|
848
|
+
},
|
|
829
849
|
},
|
|
830
850
|
},
|
|
831
851
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -1112,7 +1132,7 @@ serve({
|
|
|
1112
1132
|
factors: { type: "array", minItems: 2, maxItems: 4, items: { type: "string" }, description: "2–4 one-line load-bearing observations behind the band." },
|
|
1113
1133
|
counterFactors: { type: "array", minItems: 1, maxItems: 3, items: { type: "string" }, description: "1–3 one-line statements of what holds this name at this band rather than the next, either way." },
|
|
1114
1134
|
mitigation: { type: "string", description: "May be \"\" when nothing would move the band — but SEND THE KEY, so a considered \"none\" is not confusable with an omission." },
|
|
1115
|
-
assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating
|
|
1135
|
+
assessment: { type: "string", description: "The opening paragraph a reader of THIS MARK'S own report sees first: what the name is, what the landscape looks like, what drives the rating." },
|
|
1116
1136
|
contextFraming: { type: "string" },
|
|
1117
1137
|
registerEstimate: { type: "string" },
|
|
1118
1138
|
parodyNote: { type: ["string", "null"] },
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
31
31
|
import { dirname, join } from "node:path";
|
|
32
32
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
33
|
-
import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions } from "../../register-plan.mjs";
|
|
33
|
+
import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions, houseElementOf, withoutHouseElementTerms } from "../../register-plan.mjs";
|
|
34
34
|
import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
|
|
35
35
|
import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
|
|
36
36
|
|
|
@@ -289,9 +289,10 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
289
289
|
// execute-plan kernel hands it to the provider's buildEntryQuery, which backfills only entries that
|
|
290
290
|
// declare none (makeRegionRequiredBuildEntryQuery). A proposal that DOES declare regions is
|
|
291
291
|
// untouched, qid fingerprints are unchanged, and providers that do not require regions ignore it.
|
|
292
|
-
let planRegions = [], planClasses = [];
|
|
292
|
+
let planRegions = [], planClasses = [], house = null;
|
|
293
293
|
try {
|
|
294
294
|
const frozen = JSON.parse(readFileSync(driverDir(dirname(dirname(outPath)), "register-plan.json"), "utf8"));
|
|
295
|
+
house = houseElementOf(frozen);
|
|
295
296
|
planRegions = (Array.isArray(frozen?.regions) ? frozen.regions : []).map((r) => String(r).trim()).filter(Boolean);
|
|
296
297
|
// C3 — the frozen plan's own class list is the priority set: proposals intersecting it compete
|
|
297
298
|
// for the per-call/per-axis caps first (mintSupplementalEntries stable-sorts; values unchanged).
|
|
@@ -307,8 +308,24 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
307
308
|
}
|
|
308
309
|
const perCall = 12; // step 3 — was a knob; no environment ever set it
|
|
309
310
|
const axisMax = 24; // step 3 — was a knob; no environment ever set it
|
|
311
|
+
// ── THE CLIENT'S OWN ELEMENT IS LEFT OUT BEFORE ANYTHING RUNS ───────────────────────────────────
|
|
312
|
+
//
|
|
313
|
+
// This tool EXECUTES what it mints, before the fold adds it to the plan, so the fold's own exclusion
|
|
314
|
+
// (`houseElementOf` in register-plan.mjs) comes too late here: the query would already have run. On
|
|
315
|
+
// the first live run that proposed an exclusion, this session proposed an exact search on the bare
|
|
316
|
+
// house element the compile had set aside. The same rule is applied here, before the mint. Not a
|
|
317
|
+
// rejection: rejected[] becomes an OPEN ask on the report, and the element is not an open question.
|
|
318
|
+
const excludedHouse = [];
|
|
319
|
+
const offered = [];
|
|
320
|
+
for (const p of proposals) {
|
|
321
|
+
const kept = house ? withoutHouseElementTerms({ ...(p ?? {}), predicate: String(p?.predicate ?? "default") }, house) : p;
|
|
322
|
+
if (!kept) { excludedHouse.push(String(p?.term ?? p?.terms?.[0] ?? "")); continue; }
|
|
323
|
+
offered.push(kept === p || !Array.isArray(kept.terms) ? p : { ...p, terms: kept.terms });
|
|
324
|
+
}
|
|
325
|
+
if (!offered.length)
|
|
326
|
+
return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
|
|
310
327
|
const existingQids = new Set(supp.entries.map((e) => e.qid));
|
|
311
|
-
const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis,
|
|
328
|
+
const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
|
|
312
329
|
{ existingQids, perCall, axisMax, existingCount: supp.entries.length, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
|
|
313
330
|
|
|
314
331
|
// Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
|
|
@@ -373,7 +390,7 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
373
390
|
const r = await executePlan({ plan_path: suppPath, axis, output_path: outPath, qids }, tctx);
|
|
374
391
|
const text = r && typeof r === "object" ? (r.text ?? "") : String(r ?? "");
|
|
375
392
|
if (!text || text.startsWith("ERROR")) {
|
|
376
|
-
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
|
|
393
|
+
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: false, error: text.slice(0, 300) || "executor returned nothing" }, null, 2) };
|
|
377
394
|
}
|
|
378
395
|
try { summary = JSON.parse(text); } catch { summary = { raw: text.slice(0, 300) }; }
|
|
379
396
|
}
|
|
@@ -398,5 +415,5 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
398
415
|
}
|
|
399
416
|
} catch { /* band unreadable — the executor summary still crosses */ }
|
|
400
417
|
|
|
401
|
-
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
|
|
418
|
+
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
|
|
402
419
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
// knockout-next-step.mjs — a next-step section the assessing model wrote into a name's read comes off
|
|
4
|
+
// before the report is published.
|
|
5
|
+
//
|
|
6
|
+
// A knockout states findings and a rating; what to do with the name is the reading lawyer's. The assessing
|
|
7
|
+
// instructions stopped asking for a next step, and the model still writes one now and then, under a heading
|
|
8
|
+
// of its own: "What to do with it", "Practical next step". Refusing the turn and re-asking cost a full
|
|
9
|
+
// re-dispatch each time, and the owner ruled for delivery over the loop (2026-09-19). So the section is
|
|
10
|
+
// removed here, in code, with no model call, and the run record names every heading removed.
|
|
11
|
+
//
|
|
12
|
+
// WHAT COMES OFF: a heading whose own words are a next step, and the section under it — nothing else. A
|
|
13
|
+
// hashed heading's section runs to the next heading at its level or above; a line that is only bold text
|
|
14
|
+
// runs to the next heading of either kind; a bold label opening a paragraph ("**Practical next step** —
|
|
15
|
+
// …") takes that paragraph. Position does not matter: removing a section cannot reorder what is left.
|
|
16
|
+
//
|
|
17
|
+
// WHAT STAYS, BY DESIGN: the same advice written as an ordinary sentence with no heading over it. Nothing
|
|
18
|
+
// bounds that sentence but judgement, and cutting a guess out of the client's read is worse than leaving
|
|
19
|
+
// it. The instruction not to write one is the only thing that reaches it.
|
|
20
|
+
|
|
21
|
+
// A heading's own words that make it a next step. Whole-heading: "What drives the rating" and "What is
|
|
22
|
+
// still open" are not next steps, and a word appearing somewhere inside a heading proves nothing.
|
|
23
|
+
export const NEXT_STEP_HEADING_RE =
|
|
24
|
+
/^(?:(?:practical|immediate|suggested|recommended|possible)\s+)?(?:next\s+steps?\b.*|what\s+to\s+do\b.*|what\s+happens\s+next|recommendations?\b.*|our\s+recommendations?\b.*|(?:the\s+)?way\s+forward)$/i;
|
|
25
|
+
|
|
26
|
+
const label = (s) => String(s).replace(/[*_`]/g, "").replace(/[\s:.–—-]+$/, "").trim();
|
|
27
|
+
|
|
28
|
+
/** What kind of heading this line is, and its words — or null for a line that heads nothing. PURE. */
|
|
29
|
+
function headingAt(line) {
|
|
30
|
+
const atx = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
31
|
+
if (atx) return { kind: "atx", level: atx[1].length, text: label(atx[2]) };
|
|
32
|
+
const bold = /^\s*(\*\*|__)(.+?)\1\s*:?\s*$/.exec(line);
|
|
33
|
+
if (bold) return { kind: "bold", text: label(bold[2]) };
|
|
34
|
+
const lead = /^\s*(\*\*|__)(.+?)\1\s*[:.–—-]?\s+\S/.exec(line);
|
|
35
|
+
if (lead) return { kind: "lead", text: label(lead[2]) };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Is this whole line a heading over a next step? PURE. */
|
|
40
|
+
export function isNextStepHeading(line) {
|
|
41
|
+
const h = headingAt(String(line ?? ""));
|
|
42
|
+
return Boolean(h && NEXT_STEP_HEADING_RE.test(h.text));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The text with every next-step section removed, and what was removed: `{ text, removed: [{ heading,
|
|
47
|
+
* chars }] }`. Text with nothing to remove comes back byte-identical. PURE.
|
|
48
|
+
*/
|
|
49
|
+
export function stripNextStepSections(text) {
|
|
50
|
+
const src = String(text ?? "");
|
|
51
|
+
const lines = src.split("\n");
|
|
52
|
+
const keep = [];
|
|
53
|
+
const removed = [];
|
|
54
|
+
for (let i = 0; i < lines.length;) {
|
|
55
|
+
const h = headingAt(lines[i]);
|
|
56
|
+
if (!h || !NEXT_STEP_HEADING_RE.test(h.text)) { keep.push(lines[i]); i++; continue; }
|
|
57
|
+
let j = i + 1;
|
|
58
|
+
if (h.kind === "lead") {
|
|
59
|
+
while (j < lines.length && lines[j].trim() !== "") j++;
|
|
60
|
+
} else {
|
|
61
|
+
for (; j < lines.length; j++) {
|
|
62
|
+
const n = headingAt(lines[j]);
|
|
63
|
+
if (!n || n.kind === "lead") continue;
|
|
64
|
+
if (h.kind === "atx" ? n.kind === "atx" && n.level <= h.level : true) break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
removed.push({ heading: h.text, chars: lines.slice(i, j).join("\n").trim().length });
|
|
68
|
+
i = j;
|
|
69
|
+
}
|
|
70
|
+
if (!removed.length) return { text: src, removed };
|
|
71
|
+
return { text: keep.join("\n").replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n").trim(), removed };
|
|
72
|
+
}
|
package/driver/named-band.mjs
CHANGED
|
@@ -137,7 +137,7 @@ export function parseNamedBand(raw) {
|
|
|
137
137
|
// byte-identical to a slice the plan deliberately counted without fetching. Measured on a real
|
|
138
138
|
// run: four capability-gap blocks carried `error:true, deferred:true` into this function and
|
|
139
139
|
// reached record-carry.json with both fields gone and a sentence claiming the run "has a hit
|
|
140
|
-
// COUNT for this slice". register-plan.mjs:
|
|
140
|
+
// COUNT for this slice". register-plan.mjs:1681 validatePlanFeasibility already enforces the same rule one layer up
|
|
141
141
|
// ("a transient must not ship indistinguishable from a sanctioned descriptor") — it reads the
|
|
142
142
|
// RAW blocks, which is why it could. Every consumer that reads THIS projection could not.
|
|
143
143
|
// Conditional like the four keys above, so old bands carry neither key and nothing shifts.
|
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.2-beta.
|
|
5
|
+
"version": "0.3.2-beta.12",
|
|
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": {
|
package/driver/phase0.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, appendFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID, randomInt } from "node:crypto";
|
|
9
9
|
import { ledgerPath } from "../providers/_shared/ledger-path.mjs";
|
|
10
10
|
import { config } from "./driver.config.mjs";
|
|
11
11
|
import { resolveProfile } from "./profiles.mjs";
|
|
@@ -26,7 +26,15 @@ const NOUN = [
|
|
|
26
26
|
"warren", "beacon", "quarry", "willow", "kestrel", "monolith", "estuary", "bramble", "compass", "drift",
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// THE CODENAME IS DRAWN FROM THE OPERATING SYSTEM'S RANDOM SOURCE, not Math.random. It is a label, not a
|
|
30
|
+
// secret: it names a run's directory and appears in its reports, and the keys built from it name a run's
|
|
31
|
+
// attempts and ledgers — nothing is authorised by knowing one. But it is the first thing the run identifier
|
|
32
|
+
// is made of, and a label that is also unpredictable costs nothing here, while a predictable one invites
|
|
33
|
+
// every later reader to wonder what else leans on it. Same [0, 1) shape as Math.random, so the injected
|
|
34
|
+
// `rand` the tests pass for determinism works unchanged.
|
|
35
|
+
const cryptoRand = () => randomInt(0, 2 ** 32) / 2 ** 32;
|
|
36
|
+
|
|
37
|
+
export function genCodename(rand = cryptoRand) {
|
|
30
38
|
return `${ADJ[Math.floor(rand() * ADJ.length)]}-${NOUN[Math.floor(rand() * NOUN.length)]}`;
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -186,7 +194,7 @@ export function claimRunCodename({ slug, date, codename, registryPath = codename
|
|
|
186
194
|
// a pre-mint through raw genCodename would silently lose this collision protection.
|
|
187
195
|
// 20 straight collisions ⇒ the 400-name space is exhausted for this slug+date — suffix for freshness.
|
|
188
196
|
export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
|
|
189
|
-
rand =
|
|
197
|
+
rand = cryptoRand, claim = claimRunCodename }) {
|
|
190
198
|
for (let i = 0; i < 20; i++) {
|
|
191
199
|
const c = genCodename(rand);
|
|
192
200
|
if (!existsSync(runDirFor({ slug, date, codename: c, studioRoot }))
|
|
@@ -198,22 +206,23 @@ export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot,
|
|
|
198
206
|
return `${genCodename(rand)}-${Date.now().toString(36)}`;
|
|
199
207
|
}
|
|
200
208
|
|
|
201
|
-
// Assemble the immutable run identity for a job. rand
|
|
209
|
+
// Assemble the immutable run identity for a job. rand and claim are injectable for tests — claim so a test's
|
|
210
|
+
// mints go to a registry of its own rather than the box-wide one every run on this account shares. studioRoot/archiveRoot are the
|
|
202
211
|
// FORWARDING agent's (derived from its queue dir by the runner); they default to clawdi's for back-compat.
|
|
203
212
|
// `codename`/`date` overrides exist for RESUME: re-driving a failed run must rebuild the SAME run identity
|
|
204
213
|
// (slug/date/codename → the same run-dir) so the idempotency skip reuses the prior stages instead of minting
|
|
205
214
|
// a fresh codename and re-spending everything (the "pearl-keystone" trap). A bare new run leaves both unset.
|
|
206
215
|
export function buildRunContext(
|
|
207
216
|
job,
|
|
208
|
-
{ rand =
|
|
209
|
-
codename: codenameOverride, date: dateOverride } = {},
|
|
217
|
+
{ rand = cryptoRand, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
|
|
218
|
+
codename: codenameOverride, date: dateOverride, claim = claimRunCodename } = {},
|
|
210
219
|
) {
|
|
211
220
|
const slug = deriveSlug(job);
|
|
212
221
|
const date = dateOverride ?? todayISO(now);
|
|
213
222
|
// Fresh mints go through mintFreshCodename (above) so they never land in a dir another run already owns.
|
|
214
223
|
// Overrides skip the check: RESUME (and the runner's dispatch pre-mint, which already minted freshly)
|
|
215
224
|
// wants the identity verbatim.
|
|
216
|
-
const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand });
|
|
225
|
+
const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand, claim });
|
|
217
226
|
return {
|
|
218
227
|
slug,
|
|
219
228
|
codename,
|
|
@@ -56,6 +56,7 @@ import { registerUnavailableOffices } from "./register-unreachable.mjs";
|
|
|
56
56
|
import { runRecordLogPath } from "../providers/_shared/ledger-path.mjs"; // — this run's record log
|
|
57
57
|
import { validators as koValidators, validateMergedFindings, worstBand, registerSurfacedFilings, raterCaveats, SURVIVOR_BOUNDARY_RE } from "./verify-knockout.mjs";
|
|
58
58
|
import { reviewAbout, reviewEvidence, reviewEvidenceLines, applyKnockoutReview, knockoutReviewFile } from "./knockout-review-record.mjs";
|
|
59
|
+
import { stripNextStepSections } from "./knockout-next-step.mjs";
|
|
59
60
|
import { publishKnockout, composeKnockoutEmail } from "./publish/knockout.mjs";
|
|
60
61
|
import { writeRunStatus, rollupStatus, atomicWrite, identitySeed } from "./progress.mjs"; // — the identity seed is shared; the stepper is not
|
|
61
62
|
import { batchMarkName } from "./mark-name.mjs";
|
|
@@ -349,6 +350,28 @@ export function survivorBoundaryNote(policy) {
|
|
|
349
350
|
return `This is ${screenName}, not a clearance. A mark not knocked out here is not clear — it is not knocked out at this screen's depth, and proceeds to clearance. Nothing above is a finding of availability.`;
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
// ── a next-step section the model wrote comes off, in code ──────────────────────────────────────────
|
|
354
|
+
//
|
|
355
|
+
// Each name's read is published as its own report, and the screen states findings and a rating — what to
|
|
356
|
+
// do with the name is the reading lawyer's. A heading the model wrote over a next step, and the section
|
|
357
|
+
// under it, are removed here and named in the run record; `knockout-next-step.mjs` says what counts. This
|
|
358
|
+
// replaced a pre-delivery refusal that re-asked the whole chunk for the same edit (owner, 2026-09-19).
|
|
359
|
+
// EXPORTED so the record line is driven directly, not only through a whole pipeline.
|
|
360
|
+
export function removeNextStepSections(runDir, marks) {
|
|
361
|
+
const out = [];
|
|
362
|
+
for (const m of marks ?? []) {
|
|
363
|
+
if (typeof m?.assessment !== "string") continue;
|
|
364
|
+
const { text, removed } = stripNextStepSections(m.assessment);
|
|
365
|
+
if (!removed.length) continue;
|
|
366
|
+
m.assessment = text;
|
|
367
|
+
const row = { event: "knockout-next-step-removed", mark: m.name, headings: removed.map((r) => r.heading),
|
|
368
|
+
chars: removed.reduce((n, r) => n + r.chars, 0), lane: "knockout" };
|
|
369
|
+
runLog(runDir, row);
|
|
370
|
+
out.push(row);
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
352
375
|
// ── — the knockout lane's recovery park ───────────────────────────────────────
|
|
353
376
|
//
|
|
354
377
|
// EXPORTED so it can be armed directly. The clearance lane's equivalent is inline in a 6,000-line catch
|
|
@@ -964,6 +987,9 @@ export async function knockoutInner(ctx, job, opts = {}) {
|
|
|
964
987
|
// it runs BEFORE the artifact is written, not after. The counts are logged because a receipts pass
|
|
965
988
|
// over zero citations is a different fact from a receipts pass, and only the count can tell them
|
|
966
989
|
// apart afterwards.
|
|
990
|
+
// BEFORE THE GATE AND THE WRITE, so the record on disk is the record that ships, and the reviewing
|
|
991
|
+
// pass never addresses a line inside a section that is about to go.
|
|
992
|
+
removeNextStepSections(run.runDir, merged.marks);
|
|
967
993
|
const mv = validateMergedFindings(run.runDir, merged, plan);
|
|
968
994
|
if (!mv.ok) throw new StageFailure("knockout-assess", `merged findings failed the lint: ${mv.failures.join("; ")}`, null);
|
|
969
995
|
runLog(run.runDir, { event: "knockout-receipts", ...mv.receipts });
|
package/driver/pipeline.mjs
CHANGED
|
@@ -13518,7 +13518,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
13518
13518
|
// structured-only). Each stage is file-gated/resumable; per-card sessions feed the lint repair below.
|
|
13519
13519
|
// C2 — fold same-owner+same-mark duplicate filings into one finding BEFORE the overview + cards read
|
|
13520
13520
|
// findings.json, so the whole delivery phase (and the published copy) sees the single consolidated set.
|
|
13521
|
-
injectDeferralCoverage(P, run.runDir, note); // A3: unclosed reopen directives become reader-visible coverage rows first
|
|
13521
|
+
injectDeferralCoverage(P, run.runDir, note); injectMeaningGapCoverage(P, run.runDir, note); // A3: unclosed reopen directives, and meaning searches that did not complete, become reader-visible coverage rows first
|
|
13522
13522
|
// qw/cn-scope-honesty — the sibling injection: a CN-family-scope run whose zh lane did not run
|
|
13523
13523
|
// discloses what the native-language investigation would have searched, and where it is offered
|
|
13524
13524
|
// (coverage-limited: never clamps, never gates).
|
|
@@ -16831,3 +16831,50 @@ export function registerGapConditions(regGap) { // @internal
|
|
|
16831
16831
|
});
|
|
16832
16832
|
return out;
|
|
16833
16833
|
}
|
|
16834
|
+
|
|
16835
|
+
// ── A MEANING SEARCH THAT DID NOT COMPLETE REACHES THE CLIENT'S OWN PAGE, NOT ONLY THE AUDIT ────────────
|
|
16836
|
+
//
|
|
16837
|
+
// The connotation gate delivers a run whose dictated meaning searches did not all complete, and records
|
|
16838
|
+
// each unfinished one as a gap carrying its term — a gap the engine wrote for a search that never came
|
|
16839
|
+
// back, or the provider's own row for one it refused. The audit read those gaps; the report was never
|
|
16840
|
+
// handed the grid, so whether a client learned a meaning search was missing was up to the synthesis
|
|
16841
|
+
// model. This puts each on the coverage the report renders, the way an unclosed follow-up already is:
|
|
16842
|
+
// the same row, `deferralCoverageRow`, and an approved reader sentence that says it is left open — true
|
|
16843
|
+
// of a drop and of a refusal alike. Telling the two apart on the page
|
|
16844
|
+
// would need a sentence nobody has approved, and is not attempted here.
|
|
16845
|
+
//
|
|
16846
|
+
// The same guard as `injectDeferralCoverage`: a gap synthesis already weighed in is not added twice, and
|
|
16847
|
+
// a findings.json that fails its own schema after the push is left as it was.
|
|
16848
|
+
// THE WORDS ARE CHOSEN, NOT THE CAUSE: of the approved reasons, "nothing in the run's own record confirms it
|
|
16849
|
+
// was searched" is true of a search that never came back AND of one the provider refused, and does not
|
|
16850
|
+
// repeat the row's own "not completed this run" the way the generic fallback does. It borrows that arm's
|
|
16851
|
+
// sentence only; if the arm is ever reworded for its own case, re-read this row against it.
|
|
16852
|
+
const MEANING_SEARCH_UNFINISHED = "not-verified-closed";
|
|
16853
|
+
export function injectMeaningGapCoverage(P, runDir, note) { // @internal — exported so the refusal shape is testable without a run
|
|
16854
|
+
try {
|
|
16855
|
+
if (!existsSync(P.commonLawGrid) || !existsSync(P.findings)) return;
|
|
16856
|
+
const grid = JSON.parse(readFileSync(P.commonLawGrid, "utf8"));
|
|
16857
|
+
const terms = [...new Set((Array.isArray(grid?.gaps) ? grid.gaps : [])
|
|
16858
|
+
.filter((g) => String(g?.platform ?? "").toLowerCase() === "connotation")
|
|
16859
|
+
.map((g) => String(g?.term ?? "").trim()).filter(Boolean))];
|
|
16860
|
+
if (!terms.length) return;
|
|
16861
|
+
const doc = JSON.parse(readFileSync(P.findings, "utf8"));
|
|
16862
|
+
if (!Array.isArray(doc.coverage)) doc.coverage = [];
|
|
16863
|
+
const normTxt = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
16864
|
+
const covText = normTxt(doc.coverage.map((c) => `${c.area} ${c.note ?? ""}`).join(" "));
|
|
16865
|
+
let added = 0;
|
|
16866
|
+
for (const term of terms) {
|
|
16867
|
+
const slice = normTxt(term).split(" ").filter((w) => w.length >= 4).slice(0, 4).join(" ");
|
|
16868
|
+
if (slice && covText.includes(slice)) continue; // synthesis already weighed it in
|
|
16869
|
+
doc.coverage.push(deferralCoverageRow(term, MEANING_SEARCH_UNFINISHED));
|
|
16870
|
+
added++;
|
|
16871
|
+
}
|
|
16872
|
+
if (!added) return;
|
|
16873
|
+
parseFindingsJson(JSON.stringify(doc)); // re-validate the shape (throws → catch keeps original)
|
|
16874
|
+
atomicWrite(P.findings, `${JSON.stringify(doc, null, 2)}\n`);
|
|
16875
|
+
note(`[common-law] ${added} meaning search(es) that did not complete now reach the report's coverage`);
|
|
16876
|
+
runLog(runDir, { event: "meaning-gap-coverage", added });
|
|
16877
|
+
} catch (e) {
|
|
16878
|
+
note(`[common-law] meaning-gap coverage skipped: ${String(e?.message || e).replace(/\s+/g, " ").slice(0, 100)}`);
|
|
16879
|
+
}
|
|
16880
|
+
}
|
|
@@ -422,10 +422,20 @@ export function firstRunCredentialLines({ handoff, credentialPath, email, passph
|
|
|
422
422
|
return [head, ` PASSPHRASE: ${passphrase}`,
|
|
423
423
|
" Write it down now. It is not stored anywhere in a form that can be read back, and this line will not be printed again."];
|
|
424
424
|
}
|
|
425
|
-
return [head,
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
return [head, ...passphraseWithheldLines({ stream: "stderr", resetCommand }).map((line) => ` ${line}`)];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* What stands where the passphrase would, on an output that is not a terminal. ONE composer for the two
|
|
430
|
+
* places a first run can hand the passphrase over \u2014 the portal's standard error and the launcher's closing
|
|
431
|
+
* box on standard output \u2014 so they give one reason and one way back in. It is never handed the
|
|
432
|
+
* passphrase, so no caller can wire the value through it.
|
|
433
|
+
*/
|
|
434
|
+
export function passphraseWithheldLines({ stream, resetCommand = "" }) {
|
|
435
|
+
return [
|
|
436
|
+
`The passphrase is NOT printed here: ${stream} is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.`,
|
|
437
|
+
"Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
|
|
438
|
+
` ${resetCommand}`];
|
|
429
439
|
}
|
|
430
440
|
|
|
431
441
|
export const SESSION_DOMAIN = "portal-session.v1|";
|
package/driver/portal-report.mjs
CHANGED
|
@@ -122,6 +122,13 @@ const CHROME_RES = [
|
|
|
122
122
|
// has stopped matching. Removing the wrapper first would take the nav with it, drive the count to
|
|
123
123
|
// zero, and turn a security assertion into a permanent false alarm.
|
|
124
124
|
{ tag: "div", open: /<div class="[^"]*\brep-stickyhead\b[^"]*"[^>]*>/ },
|
|
125
|
+
// THE SECTION MENU, WHEREVER THE RENDERER PUT IT. A report rendered today carries it inside the header
|
|
126
|
+
// above, and it goes with that. A report rendered before 2026-09-18 carries it just AFTER the header, so
|
|
127
|
+
// stripping the header alone left it in the frame: a second copy of the menu the portal draws, pinned
|
|
128
|
+
// over the report's title band, its current item red on red in the dark theme. Measured on an archived
|
|
129
|
+
// global preliminary report served by a published beta. Archived reports are served from their baked
|
|
130
|
+
// bytes, so this has to happen here rather than in the renderer. `sectionsOf` has already read it.
|
|
131
|
+
{ tag: "nav", open: /<nav class="[^"]*\bstrip\b[^"]*"[^>]*>/ },
|
|
125
132
|
// the "Internal review copy — stripped on export" bar, which hosted the quality-capture controls
|
|
126
133
|
{ tag: "div", open: /<div class="[^"]*\breview\b[^"]*\binternal\b[^"]*"[^>]*>/ },
|
|
127
134
|
// per-finding flag buttons and their popovers
|
|
@@ -529,7 +536,7 @@ const EMBED_JS = `
|
|
|
529
536
|
function schedule(){
|
|
530
537
|
if(queued)return;
|
|
531
538
|
queued=true;
|
|
532
|
-
requestAnimationFrame(function(){queued=false;post();});
|
|
539
|
+
requestAnimationFrame(function(){queued=false;post();sections();});
|
|
533
540
|
}
|
|
534
541
|
// WHICH CONTROLS THIS DOCUMENT ACTUALLY HAS.
|
|
535
542
|
//
|
|
@@ -559,12 +566,24 @@ const EMBED_JS = `
|
|
|
559
566
|
// script) and the shell draws it in its own header. Only the ids that are really in the document are
|
|
560
567
|
// announced: a report whose renderer named a section it did not draw would otherwise offer the reader
|
|
561
568
|
// a breadcrumb entry that jumps nowhere.
|
|
569
|
+
//
|
|
570
|
+
// AND WHERE EACH ONE STARTS, so the shell can show how far the reader has got (owner, 2026-09-19): every
|
|
571
|
+
// section reached so far is marked, the rest are not. The page scrolls, not this frame, so only the shell
|
|
572
|
+
// knows where the reader is. It needs each section's top in this document to compare. Said again whenever
|
|
573
|
+
// the layout moves: a panel opening pushes every section below it down.
|
|
574
|
+
var saidSections='';
|
|
562
575
|
function sections(){
|
|
563
576
|
try{
|
|
564
577
|
var list=window.__CORD_SECTIONS;
|
|
565
578
|
if(!list||!list.length)return;
|
|
566
579
|
var live=[];
|
|
567
|
-
for(var i=0;i<list.length;i++)
|
|
580
|
+
for(var i=0;i<list.length;i++){
|
|
581
|
+
var el=document.getElementById(list[i].id);
|
|
582
|
+
if(el) live.push({id:list[i].id,label:list[i].label,top:Math.max(0,Math.round(el.getBoundingClientRect().top+window.scrollY))});
|
|
583
|
+
}
|
|
584
|
+
var said=JSON.stringify(live);
|
|
585
|
+
if(said===saidSections)return;
|
|
586
|
+
saidSections=said;
|
|
568
587
|
parent.postMessage({source:TAG,type:'sections',sections:live},'*');
|
|
569
588
|
}catch(e){}
|
|
570
589
|
}
|
|
@@ -968,6 +968,15 @@ const DENIAL_REASON = Object.freeze({
|
|
|
968
968
|
429: "rate limited",
|
|
969
969
|
});
|
|
970
970
|
|
|
971
|
+
/**
|
|
972
|
+
* WHAT A REFUSED CLIENT IS TOLD — a closed set, keyed on the status, never the error's message. The message
|
|
973
|
+
* can come out of a third-party token check with a claim value, an address or a fragment of the rejected
|
|
974
|
+
* token in it, and some of our own refusals put the caller's address in theirs. A 401 keeps the words the
|
|
975
|
+
* browser contract already decodes ("not signed in"); every other status says the journal's code-owned
|
|
976
|
+
* reason. The message itself is still journalled nowhere a client reads. PURE.
|
|
977
|
+
*/
|
|
978
|
+
export const refusalWords = (status) => (status === 401 ? "not signed in" : DENIAL_REASON[status] ?? "refused at the door");
|
|
979
|
+
|
|
971
980
|
/**
|
|
972
981
|
* — THE ROW A REFUSAL FILES. One shape, one sink, whichever side of `route` decided the answer.
|
|
973
982
|
*
|
|
@@ -3922,7 +3931,9 @@ const escHtml = (t) => String(t).replace(/[&<>"']/g, (c) => ({ "&": "&", "<"
|
|
|
3922
3931
|
// and brand pack §01 fixes dark at #0f0e0c near-black + #f0e8d8 parchment. The pack wins.
|
|
3923
3932
|
// ── THE PLAIN LINE LEADS; THE ADMINISTRATOR'S TWO LINES STEP BACK INTO A FOLD ────────────────────────
|
|
3924
3933
|
//
|
|
3925
|
-
// A person arriving here needs the field, the button, and one fact: this install
|
|
3934
|
+
// A person arriving here needs the field, the button, and one fact: this install has one user, and which
|
|
3935
|
+
// address that is. The owner's words, 2026-09-19: "Clearotron portal, as <address>." read as "you are
|
|
3936
|
+
// <address>", and a person who found `key issue` first minted a key and could not get in.
|
|
3926
3937
|
// The reset command and the way to add people are an administrator's business, so they sit in a closed
|
|
3927
3938
|
// "Administrator help" fold, word for word as they were, with the same link People gives to putting a
|
|
3928
3939
|
// login system in front. A `<details>` needs no script, which this door must render without.
|
|
@@ -3967,9 +3978,7 @@ ${DOOR_THEME_INIT}
|
|
|
3967
3978
|
.hint { margin-top:18px; font-size:13px; }
|
|
3968
3979
|
code { font-family:var(--mono); font-size:12.5px; background:var(--code-bg);
|
|
3969
3980
|
padding:1px 5px; border-radius:4px; overflow-wrap:anywhere; }
|
|
3970
|
-
.
|
|
3971
|
-
.lead b { font-weight:600; }
|
|
3972
|
-
.fold { margin-top:10px; }
|
|
3981
|
+
.fold { margin-top:18px; padding-top:14px; border-top:1px solid var(--line); }
|
|
3973
3982
|
.fold > summary { display:inline-flex; align-items:center; gap:6px; list-style:none; cursor:pointer;
|
|
3974
3983
|
font-size:13px; color:var(--link); }
|
|
3975
3984
|
.fold > summary::-webkit-details-marker { display:none; }
|
|
@@ -3984,7 +3993,7 @@ ${signedIn
|
|
|
3984
3993
|
? `<p>You are signed in as <span class="who">${escHtml(email)}</span>.</p>
|
|
3985
3994
|
<div><a href="/portal">Go to the portal</a></div>
|
|
3986
3995
|
<form method="post" action="/portal/logout"><button type="submit">Sign out</button></form>`
|
|
3987
|
-
: `<p
|
|
3996
|
+
: `<p>This ${escHtml(BRAND.name)} has one user: <b class="who">${escHtml(email)}</b>. Enter its passphrase.</p>
|
|
3988
3997
|
${discarded ? `<p class="hint">A session this portal did not start, from another ${escHtml(BRAND.name)} on this address or an expired one, was set aside. Sign in below.</p>` : ""}
|
|
3989
3998
|
${error ? `<p class="err">${escHtml(error)}</p>` : ""}
|
|
3990
3999
|
<form method="post" action="/portal/login">
|
|
@@ -3992,10 +4001,10 @@ ${error ? `<p class="err">${escHtml(error)}</p>` : ""}
|
|
|
3992
4001
|
<input id="passphrase" name="passphrase" type="password" autocomplete="new-password" autofocus>
|
|
3993
4002
|
<button type="submit">Sign in</button>
|
|
3994
4003
|
</form>
|
|
3995
|
-
<p class="lead"><b>This ${escHtml(BRAND.name)} signs in one person: you.</b></p>
|
|
3996
4004
|
<details class="fold"><summary><span>Administrator help</span><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></summary>
|
|
3997
|
-
<p class="hint">
|
|
3998
|
-
running this portal. It
|
|
4005
|
+
<p class="hint">The passphrase was printed once when this ${escHtml(BRAND.name)} first started. Lost it? Run
|
|
4006
|
+
<code>${escHtml(reset)}</code> on the machine running this portal. It prints a new one, once, for the same user.</p>
|
|
4007
|
+
<p class="hint">A key from <code>${escHtml(bareInvocation("key"))} issue</code> is for an AI assistant, not for this page.</p>
|
|
3999
4008
|
<p class="hint">To add people, put it behind a login system such as your company single sign-on. <a href="${escHtml(LOGIN_IN_FRONT_DOC)}" target="_blank" rel="noreferrer">How to set that up</a></p>
|
|
4000
4009
|
</details>`}
|
|
4001
4010
|
</div></body></html>`;
|
|
@@ -4399,7 +4408,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4399
4408
|
// row must not become a place a caller can write into by sending a body that fails to parse.
|
|
4400
4409
|
let body;
|
|
4401
4410
|
try { body = await readJsonBody(req); }
|
|
4402
|
-
catch
|
|
4411
|
+
catch { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: "unreadable body" }); } // the parser's own message describes the bytes it was sent, and a response is not where a parse error is read back
|
|
4403
4412
|
const query = Object.fromEntries(url.searchParams.entries());
|
|
4404
4413
|
const r = await service.route(req.method, url.pathname, identity, body, query);
|
|
4405
4414
|
// A PLAIN DOCUMENT this server renders itself. Escaped into a `<pre>`, so nothing in the file can
|
|
@@ -4466,11 +4475,11 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4466
4475
|
// person who typed the address gets a door.
|
|
4467
4476
|
const wantsHtml = String(req.headers.accept ?? "").includes("text/html");
|
|
4468
4477
|
if (wantsHtml) {
|
|
4469
|
-
const html = denialPage(e.status, e.
|
|
4478
|
+
const html = denialPage(e.status, refusalWords(e.status));
|
|
4470
4479
|
res.writeHead(e.status, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html), "x-content-type-options": "nosniff" });
|
|
4471
4480
|
return res.end(html);
|
|
4472
4481
|
}
|
|
4473
|
-
return send(res, e.status, { error: e.
|
|
4482
|
+
return send(res, e.status, { error: refusalWords(e.status) });
|
|
4474
4483
|
}
|
|
4475
4484
|
log(`500 ${String(e?.message || e)}`);
|
|
4476
4485
|
if (!alreadyFiled(e)) journal(500, e?.message ?? String(e), identity?.email);
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
// attr.mjs — a value placed inside a quoted HTML attribute, and a URL placed inside an href.
|
|
4
|
+
//
|
|
5
|
+
// Every renderer here has its own `esc`, and each escapes `&`, `<` and `>` — which is right for text and
|
|
6
|
+
// wrong inside an attribute: a `"` in the value closes the attribute and whatever follows is markup. So a
|
|
7
|
+
// value that sits between quotes goes through `attrValue`, which also encodes both quote characters.
|
|
8
|
+
//
|
|
9
|
+
// An href needs one more guard, because a correctly escaped `javascript:` URL is still a link that runs
|
|
10
|
+
// code when clicked, and the same for `data:` and `vbscript:`. So an href is built by `hrefAttr` or not at
|
|
11
|
+
// all: http(s) only — or, when the caller says so, a reference with no scheme (the audit download is a file
|
|
12
|
+
// beside the report). A browser drops tabs, newlines and other control characters from a URL before it
|
|
13
|
+
// reads the scheme, so the scheme is read with those removed: `java` + newline + `script:` is `javascript:`.
|
|
14
|
+
|
|
15
|
+
// Every C0 control character, space and DEL — what a URL parser strips or ignores before the scheme.
|
|
16
|
+
const IGNORED_BEFORE_SCHEME = /[\x00-\x20\x7f]/g;
|
|
17
|
+
|
|
18
|
+
/** A value safe between the quotes of an HTML attribute: & < > " and ' all encoded. PURE. */
|
|
19
|
+
export const attrValue = (s) => String(s ?? "")
|
|
20
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An href value, attribute-safe, or null when it must not be a link — the caller then renders its text
|
|
25
|
+
* with no anchor. `relative: true` also admits a reference with no scheme, never a protocol-relative one
|
|
26
|
+
* (`//host/…` is an external address by another spelling). PURE.
|
|
27
|
+
*/
|
|
28
|
+
export function hrefAttr(u, { relative = false } = {}) {
|
|
29
|
+
const s = String(u ?? "").trim();
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
const probe = s.replace(IGNORED_BEFORE_SCHEME, "");
|
|
32
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(probe)?.[1];
|
|
33
|
+
if (scheme) { if (!/^https?$/i.test(scheme)) return null; }
|
|
34
|
+
else if (!relative || probe.startsWith("//") || probe.startsWith("\\\\")) return null;
|
|
35
|
+
return attrValue(s);
|
|
36
|
+
}
|