clearotron 0.3.3 → 0.4.0-beta.0

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.
Files changed (43) hide show
  1. package/INSTALL.md +1 -14
  2. package/bin/brandowner.mjs +5 -5
  3. package/bin/connect.mjs +4 -4
  4. package/bin/onboard.mjs +5 -7
  5. package/bin/start.mjs +20 -5
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +18 -0
  8. package/driver/company-bundle.mjs +11 -18
  9. package/driver/door-call-verdict.mjs +27 -0
  10. package/driver/driver.config.mjs +1 -2
  11. package/driver/package.json +1 -1
  12. package/driver/pipeline.mjs +14 -5
  13. package/driver/portal-mcp-client.mjs +1 -1
  14. package/driver/portal-request-origin.mjs +79 -0
  15. package/driver/portal-service.mjs +43 -15
  16. package/driver/profile-page.html +9 -13
  17. package/driver/profile-service.mjs +25 -13
  18. package/driver/profiles.mjs +17 -4
  19. package/driver/publish/index.mjs +13 -1
  20. package/driver/publish/render.mjs +13 -2
  21. package/driver/publish/search-depth.mjs +4 -2
  22. package/driver/run-economics.mjs +7 -18
  23. package/driver/stages.mjs +1 -1
  24. package/driver/suite-census.json +61 -31
  25. package/driver/tokens.mjs +18 -10
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/lib/audit.mjs +9 -2
  28. package/mcp-server/lib/http-handler.mjs +7 -3
  29. package/mcp-server/mint-token.mjs +8 -6
  30. package/mcp-server/package.json +1 -1
  31. package/mcp-server/server.mjs +10 -0
  32. package/package.json +1 -1
  33. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-DVtz44vH.js} +57 -8
  34. package/portal-ui/dist/index.html +1 -1
  35. package/portal-ui/package.json +1 -1
  36. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  37. package/providers/oauth-mcp-bridge/package.json +1 -1
  38. package/scripts/freeze-example-run.mjs +1 -1
  39. package/scripts/live-surface-check.mjs +11 -2
  40. package/scripts/release-entry-catch-up.mjs +142 -0
  41. package/shared/client-door.mjs +15 -8
  42. package/shared/scope.mjs +25 -10
  43. package/shared/store-in-repo.mjs +38 -17
@@ -278,7 +278,11 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
278
278
  : send(res, 401, { error: `invalid trademark token: ${e.message}` });
279
279
  }
280
280
  const transport = await createSession(sessions, scope, user.email);
281
- entry = { transport, sub: scope.sub ?? null, kind: scope.kind ?? null };
281
+ // `namesVerbs` rides with the session so EVERY call it makes is audited with it, not only the
282
+ // first: an ops token that names no verbs is being retired (re-issued with verbs, then refused),
283
+ // and the audit is how the live ones are found. Whether it names verbs, never which, never the token.
284
+ const namesVerbs = scope.kind === "ops" ? Array.isArray(scope.verbs) && scope.verbs.length > 0 : null;
285
+ entry = { transport, sub: scope.sub ?? null, kind: scope.kind ?? null, namesVerbs };
282
286
  // STAMP THE SCOPE'S OWN FACTS ONTO THE STORED ENTRY, HERE, AND AFTER THE HANDSHAKE.
283
287
  //
284
288
  // `createSession` is injected, and every caller carries its own copy of the entry shape — two
@@ -293,7 +297,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
293
297
  // and left the gate reading a field nobody had set — green, and doing nothing.
294
298
  stampScope = () => {
295
299
  const stored = transport.sessionId ? sessions.get(transport.sessionId) : null;
296
- if (stored) { stored.sub = scope.sub ?? null; stored.kind = scope.kind ?? null; }
300
+ if (stored) { stored.sub = scope.sub ?? null; stored.kind = scope.kind ?? null; stored.namesVerbs = namesVerbs; }
297
301
  };
298
302
  } else {
299
303
  if (entry.email && entry.email !== user.email) {
@@ -328,7 +332,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
328
332
  // `finish` fires once the response is fully sent, which is when `res.statusCode` is the answer
329
333
  // the caller got rather than the default it started as.
330
334
  res.once("finish", () => {
331
- try { appendAudit({ email: user.email, sub: entry.sub ?? null, body, status: res.statusCode, door }); }
335
+ try { appendAudit({ email: user.email, sub: entry.sub ?? null, kind: entry.kind ?? null, namesVerbs: entry.namesVerbs ?? null, body, status: res.statusCode, door }); }
332
336
  catch { /* best-effort */ }
333
337
  });
334
338
  const answered = entry.transport.handleRequest(req, res, body);
@@ -15,7 +15,7 @@
15
15
  // intake connector never needs stop_run.
16
16
 
17
17
  import { fileURLToPath } from "node:url";
18
- import { mintToken, verifyToken, TOOL_SCOPES } from "../shared/scope.mjs";
18
+ import { mintToken, verifyToken } from "../shared/scope.mjs";
19
19
  import { homedir } from "node:os";
20
20
  import { defaultDenylistPath } from "../shared/client-door.mjs"; // — one owner for the path
21
21
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
@@ -25,7 +25,7 @@ function fail(msg, code = 1) { process.stderr.write(`mint-token: ${msg}\n`); pro
25
25
  const USAGE = `usage: node mint-token.mjs --scope ops|user|account [options]
26
26
  --scope ops an automation/operator principal (write verbs allowed)
27
27
  --sub <name> REQUIRED for ops: the principal name (rides into the audit log)
28
- --verbs a,b least-privilege write-verb allowlist (recommended; omit = full ops)
28
+ --verbs a,b REQUIRED for ops: the write tools this token may call
29
29
  --accounts a,b GRANTS cap: the account keys (profileKeys) this token may see/start (omit = all)
30
30
  --ttl-days <n> default 30
31
31
  --scope user a run-bound report-link token (read-only client layer)
@@ -76,6 +76,12 @@ export function mintFromOptions({ scope, sub = null, runId = null, ttlDays, verb
76
76
  // They belong here rather than at either caller for exactly the reason the extraction existed: a rule
77
77
  // enforced by one of two callers is a rule the other one does not have.
78
78
  if (scope === "ops" && !sub) throw new Error("--sub is required for an ops token — the audit log must name the principal");
79
+ // AN OPS TOKEN NAMES ITS VERBS. One that named none held every write verb, and over the network that
80
+ // included starting a what-if; it is the most valuable credential the product issues, so it is no longer
81
+ // issued. Tokens already out keep working: the door records their use (the audit line's `namesVerbs`) so
82
+ // they can be found, re-issued with verbs and then refused, in that order.
83
+ if (scope === "ops" && !(Array.isArray(verbs) && verbs.length))
84
+ throw new Error("--verbs is required for an ops token — name the write tools it may call, for example --verbs start_run,stop_run");
79
85
  if (scope === "user" && !runId) throw new Error("--run is required for a user token (it is bound to exactly one run)");
80
86
  if (scope === "account" && !sub) throw new Error("--sub is required for an account key — it names the grants-file identity the key reads its accounts from");
81
87
  if (!Number.isFinite(ttlDays) || ttlDays <= 0) throw new Error("--ttl-days must be a positive number");
@@ -110,10 +116,6 @@ export function mintFromOptions({ scope, sub = null, runId = null, ttlDays, verb
110
116
  notes.push(`revoke this TOKEN specifically: add "${t.jti}" as a line in ${denylistFile()}`);
111
117
  if (scope === "account")
112
118
  notes.push(`the grants row IS the reach — an account key reads its accounts from the grants file on every request`);
113
- if (scope === "ops" && !verbs) {
114
- const writable = Object.keys(TOOL_SCOPES).filter((k) => TOOL_SCOPES[k].write);
115
- notes.push(`note: FULL ops authority (${writable.join(", ")}) — consider --verbs for automation principals`);
116
- }
117
119
  return { token, claims: t, notes };
118
120
  }
119
121
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
@@ -851,6 +851,16 @@ export function attachHandlers(server, { scope = { kind: "ops", runId: null }, l
851
851
  let authedArgs;
852
852
  try { authedArgs = authorize(scope, name, args); }
853
853
  catch (e) { log(`authz deny ${name} [${scope.kind}]: ${e.message}`); return { isError: true, content: [{ type: "text", text: `FORBIDDEN (${name}): ${e.message}` }] }; }
854
+ // THE LISTING IS THE WALL. A tool this session's audience is not shown is one it cannot call: this
855
+ // used to filter only the listing, and a hidden tool called by name ran anyway once authorize()
856
+ // passed it. That is how an ops token reached what_if_run over HTTP, where the listing hides it and
857
+ // the engine starts. After authorize(), so a caller it already refuses keeps the refusal it names;
858
+ // this catches only what it lets through. Same answer as a name that does not exist, because to this
859
+ // session it does not.
860
+ if (!allow(name)) {
861
+ log(`listing deny ${name} [${scope.kind}${local ? "" : ", http"}]: not offered to this session`);
862
+ return { isError: true, content: [{ type: "text", text: `unknown tool "${name}"` }] };
863
+ }
854
864
  // ACCOUNT GATE (GRANTS, INSTALL.md §8): a run-addressed call from an account-scoped session must
855
865
  // target a run inside the grant. Enforced BEFORE dispatch so a denied run leaks nothing — not even
856
866
  // its existence (the deny message names the account, never the run's contents).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.3",
4
+ "version": "0.4.0-beta.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -11865,6 +11865,7 @@ var api = {
11865
11865
  accountNames: Object.fromEntries(Object.entries(asRecord(b["accountNames"])).filter(([, v]) => typeof v === "string" && v)),
11866
11866
  accountFacts: Object.fromEntries(Object.entries(asRecord(b["accountFacts"])).map(([k, v]) => [k, asCompanyFacts(v)])),
11867
11867
  concurrentRuns: asNumber(b["concurrentRuns"]),
11868
+ houseMarketplaces: asStrings(b["houseMarketplaces"]),
11868
11869
  engineMode: b["engineMode"] === "demo" ? "demo" : b["engineMode"] === "engine-unproven" ? "engine-unproven" : null,
11869
11870
  engineProgramDisputed: b["engineProgramDisputed"] === true ? true : b["engineProgramDisputed"] === false ? false : null,
11870
11871
  setupRoute: b["setupRoute"] === "packaged" ? "packaged" : b["setupRoute"] === "checkout" ? "checkout" : null,
@@ -21701,6 +21702,8 @@ var PROFILE_FIELDS = [
21701
21702
  label: "Marketplaces",
21702
21703
  kind: "lines",
21703
21704
  group: "defaults",
21705
+ picker: "marketplaces",
21706
+ clearToEmptyList: true,
21704
21707
  item: {
21705
21708
  ok: (e) => {
21706
21709
  const d = e.trim().toLowerCase();
@@ -21752,7 +21755,10 @@ var PROJECT_EDITABLE = new Set([
21752
21755
  "industry",
21753
21756
  "defaultProduct"
21754
21757
  ]);
21755
- var projectFields = () => PROFILE_FIELDS.filter((f) => PROJECT_EDITABLE.has(rootKey(f)) && !f.customerOnly);
21758
+ var projectFields = () => PROFILE_FIELDS.filter((f) => PROJECT_EDITABLE.has(rootKey(f)) && !f.customerOnly).map((f) => f.clearToEmptyList ? {
21759
+ ...f,
21760
+ clearToEmptyList: false
21761
+ } : f);
21756
21762
  var choiceLabel = (spec, value) => spec.choices?.find((c) => c.value === value)?.label ?? null;
21757
21763
  function depthChoices(products) {
21758
21764
  return products.map((l) => {
@@ -21800,6 +21806,10 @@ function applyField(draft, spec, raw) {
21800
21806
  const next = { ...draft };
21801
21807
  const trimmed = raw.trim();
21802
21808
  if (!trimmed) {
21809
+ if (spec.clearToEmptyList) {
21810
+ next[spec.key] = [];
21811
+ return next;
21812
+ }
21803
21813
  if (spec.clearWith !== void 0) {
21804
21814
  next[spec.key] = spec.clearWith;
21805
21815
  return next;
@@ -21947,7 +21957,7 @@ var suggestionStyle = {
21947
21957
  background: "var(--surface-raised)",
21948
21958
  color: "var(--text-muted)"
21949
21959
  };
21950
- function FieldPicker({ spec, value, onChange }) {
21960
+ function FieldPicker({ spec, value, onChange, suggestions = [] }) {
21951
21961
  const [query, setQuery] = (0, import_react.useState)("");
21952
21962
  const addId = (0, import_react.useId)();
21953
21963
  const addBox = (0, import_react.useRef)(null);
@@ -22026,7 +22036,43 @@ function FieldPicker({ spec, value, onChange }) {
22026
22036
  })]
22027
22037
  });
22028
22038
  }
22029
- const suggestions = matchTerritoriesIn(TERRITORIES, query, chosen, 8);
22039
+ if (spec.picker === "marketplaces") {
22040
+ const have = new Set(chosen.map((c) => c.trim().toLowerCase()));
22041
+ const offered = suggestions.filter((d) => !have.has(d.trim().toLowerCase()));
22042
+ return (0, import_jsx_runtime.jsxs)("div", {
22043
+ style: { marginTop: 6 },
22044
+ children: [offered.length ? (0, import_jsx_runtime.jsx)("div", {
22045
+ style: {
22046
+ display: "flex",
22047
+ flexWrap: "wrap",
22048
+ gap: 4
22049
+ },
22050
+ children: offered.map((d) => (0, import_jsx_runtime.jsxs)("button", {
22051
+ type: "button",
22052
+ onClick: () => toggle(d),
22053
+ style: suggestionStyle,
22054
+ children: ["+ ", d]
22055
+ }, d))
22056
+ }) : null, chosen.length ? (0, import_jsx_runtime.jsx)("div", {
22057
+ className: "chip-row",
22058
+ style: { marginTop: 8 },
22059
+ children: chosen.map((d) => (0, import_jsx_runtime.jsxs)("span", {
22060
+ className: "chip chip-own",
22061
+ children: [d, (0, import_jsx_runtime.jsx)("button", {
22062
+ type: "button",
22063
+ className: "chip-x",
22064
+ onClick: () => toggle(d),
22065
+ "aria-label": `Remove ${d}`,
22066
+ children: (0, import_jsx_runtime.jsx)(Icon, {
22067
+ name: "x",
22068
+ size: 13
22069
+ })
22070
+ })]
22071
+ }, d))
22072
+ }) : null]
22073
+ });
22074
+ }
22075
+ const found = matchTerritoriesIn(TERRITORIES, query, chosen, 8);
22030
22076
  return (0, import_jsx_runtime.jsxs)("div", {
22031
22077
  style: { marginTop: 6 },
22032
22078
  children: [
@@ -22046,14 +22092,14 @@ function FieldPicker({ spec, value, onChange }) {
22046
22092
  fontFamily: "inherit"
22047
22093
  }
22048
22094
  }),
22049
- suggestions.length ? (0, import_jsx_runtime.jsx)("div", {
22095
+ found.length ? (0, import_jsx_runtime.jsx)("div", {
22050
22096
  style: {
22051
22097
  display: "flex",
22052
22098
  flexWrap: "wrap",
22053
22099
  gap: 4,
22054
22100
  marginTop: 5
22055
22101
  },
22056
- children: suggestions.map((t) => (0, import_jsx_runtime.jsxs)("button", {
22102
+ children: found.map((t) => (0, import_jsx_runtime.jsxs)("button", {
22057
22103
  type: "button",
22058
22104
  onClick: () => {
22059
22105
  toggle(t);
@@ -22092,7 +22138,7 @@ function FieldTag({ tag }) {
22092
22138
  children: tag
22093
22139
  });
22094
22140
  }
22095
- function Field({ spec, value, choices, onChange }) {
22141
+ function Field({ spec, value, choices, onChange, suggestions }) {
22096
22142
  const picker = spec.kind === "choice" || spec.kind === "boolean";
22097
22143
  const multi = spec.kind === "lines" || spec.kind === "prose";
22098
22144
  const classes = spec.picker === "classes";
@@ -22138,7 +22184,8 @@ function Field({ spec, value, choices, onChange }) {
22138
22184
  (0, import_jsx_runtime.jsx)(FieldPicker, {
22139
22185
  spec,
22140
22186
  value,
22141
- onChange
22187
+ onChange,
22188
+ ...suggestions ? { suggestions } : {}
22142
22189
  })
22143
22190
  ]
22144
22191
  });
@@ -22319,6 +22366,7 @@ function NewCompany({ ctx }) {
22319
22366
  spec,
22320
22367
  value: spec.key === "selfExclusionOwners" && !namesTouched ? typedName : boxValue(state, spec),
22321
22368
  choices: null,
22369
+ suggestions: ctx.me.houseMarketplaces,
22322
22370
  onChange: (v) => {
22323
22371
  if (spec.key === "selfExclusionOwners") setNamesTouched(true);
22324
22372
  setState((st) => typeField(st, spec, v));
@@ -23629,7 +23677,8 @@ function Profile({ ctx }) {
23629
23677
  edits
23630
23678
  }, spec),
23631
23679
  choices: spec.key === "defaultProduct" ? productChoices : spec.choices ?? null,
23632
- onChange: (v) => edit(spec, v)
23680
+ onChange: (v) => edit(spec, v),
23681
+ suggestions: ctx.me.houseMarketplaces
23633
23682
  }),
23634
23683
  spec.key === "defaultJurisdictions" ? (0, import_jsx_runtime.jsx)(UnsearchableTerritories, { derived: loaded.derived }) : null,
23635
23684
  spec.key === "defaultProduct" && coverageOf(loaded.derived) ? (0, import_jsx_runtime.jsxs)("details", {
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-GBbbyQxc.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-DVtz44vH.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-5CCwiJG7.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.3",
5
+ "version": "0.4.0-beta.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.0-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.3
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.3",
3
+ "version": "0.4.0-beta.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -237,7 +237,7 @@ const substituteVendorKey = (key) => {
237
237
 
238
238
  // meta.json keys the freeze is EXPECTED to change, with the reason. Anything else differing is a finding.
239
239
  const EXPECTED_META_DELTA = {
240
- tokens: "telemetry pruned — _driver/*.jsonl is the only source (driver/tokens.mjs:82)",
240
+ tokens: "telemetry pruned — _driver/*.jsonl is the only source (rollupTokens in driver/tokens.mjs)",
241
241
  servedModels: "telemetry pruned — the attempt rows in _driver/*.jsonl are the only source (servedModels in driver/tokens.mjs)",
242
242
  };
243
243
 
@@ -107,6 +107,7 @@ import { envFrom } from "../shared/env-aliases.mjs";
107
107
  import { gitTry, treeOf } from "../shared/tree-commit.mjs"; // — a packaged install has no git, and says its commit in build-info.json
108
108
  import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a check that could not look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
109
109
  import { planRunAgreementVerdict } from "../driver/plan-run-agreement-verdict.mjs";
110
+ import { doorCallVerdict } from "../driver/door-call-verdict.mjs"; // — a refused or unanswered door call compared nothing
110
111
 
111
112
  const HERE = dirname(fileURLToPath(import.meta.url));
112
113
  const asJson = process.argv.includes("--json");
@@ -605,7 +606,11 @@ try {
605
606
  }
606
607
  } catch (e) {
607
608
  if (e?.message === "__door_unset__") skip("roster resolves", "this instance does not say where its ops-MCP is, so the roster was NOT PROBED");
608
- else fail("roster resolves", `list_profiles failed: ${e.message}`);
609
+ else {
610
+ const v = doorCallVerdict(e, { asked: "list_profiles", notCompared: "the roster was not compared" });
611
+ if (v) record("roster resolves", v.state, v.message, v.blocked === true);
612
+ else fail("roster resolves", `list_profiles failed: ${e.message}`);
613
+ }
609
614
  }
610
615
 
611
616
  // 3. THE LOAD-BEARING CHECK — every door's availability answer vs the engine's own, recomputed here
@@ -644,7 +649,11 @@ try {
644
649
  }
645
650
  } catch (e) {
646
651
  if (e?.message === "__door_unset__") skip("ops-MCP reachable", "this instance does not say where its ops-MCP is — NOT PROBED");
647
- else fail("ops-MCP reachable", `${MCP_URL}: ${e.message}`);
652
+ else {
653
+ const v = doorCallVerdict(e, { asked: `${MCP_URL} for describe_options`, notCompared: "the door's answers were not compared" });
654
+ if (v) record("ops-MCP reachable", v.state, v.message, v.blocked === true);
655
+ else fail("ops-MCP reachable", `${MCP_URL}: ${e.message}`);
656
+ }
648
657
  }
649
658
 
650
659
  // ── 3b. THE TRIGGER LANE, AS ITS OWN SURFACE ─────────────────────────────────────────────────────────
@@ -0,0 +1,142 @@
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
+ // release-entry-catch-up.mjs — finish a release npm was still validating when the publish job stopped waiting.
5
+ //
6
+ // node scripts/release-entry-catch-up.mjs [--repo owner/name] [--bound-hours 6] [--dry-run]
7
+ //
8
+ // WHY THIS EXISTS. npm now accepts a publish before it serves it ("Your package is being processed and may
9
+ // take a few minutes to become available"), and the processing is not bounded. The publish job waits for
10
+ // an unauthenticated client to get the exact bytes it uploaded before it creates the release entry, and
11
+ // that wait has to end somewhere. Measured 2026-09-23: 0.3.3-beta.1 was served eleven minutes after npm
12
+ // accepted it; the 0.3.3 stable was still "Validating" on npmjs.com more than fifty minutes after, the
13
+ // job gave up at its bound, and no entry was ever created. GitHub went on naming 0.3.2 as the latest
14
+ // release while the stable was already published. Nothing ran again to finish it.
15
+ //
16
+ // So a tagged version with no entry is PENDING, not failed, and the scheduled run finishes it: once the
17
+ // registry serves the bytes the publish job kept, the entry is created from the changelog, exactly as the
18
+ // publish job would have created it. Past a bound with the version still not served, it says so in red.
19
+ //
20
+ // THE SAME CHECK, ON THE SAME BYTES. The publish job keeps the tarball it uploaded as an artifact named
21
+ // `published-<version>`, and this hands that file to release-visible-check.mjs, so an entry is only ever
22
+ // created over bytes the registry serves and this pipeline published. A tagged version with no kept bytes
23
+ // (one published before this existed) is reported, never guessed at.
24
+ //
25
+ // Exit 0: nothing to do, the entry was created, or the version is pending inside the bound.
26
+ // Exit 1: the version is still not served past the bound, or its bytes cannot be found to check against.
27
+ // Exit 2: could not look.
28
+ import { execFileSync, spawnSync } from "node:child_process";
29
+ import { mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join, dirname } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
34
+
35
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
36
+
37
+ /**
38
+ * What to do about the version at main's head. PURE, so every branch is driven without a registry.
39
+ * @param {{ tagged: boolean, entryExists: boolean, keptBytes: boolean, visibleExit: number|null,
40
+ * ageSec: number|null, boundSec: number }} s
41
+ * @returns {{ action: "none"|"create"|"wait"|"report", exit: 0|1|2, why: string }}
42
+ */
43
+ export function decide(s) {
44
+ if (!s.tagged) return { action: "none", exit: 0, why: "not tagged: nothing has been published to finish" };
45
+ if (s.entryExists) return { action: "none", exit: 0, why: "the release entry already exists" };
46
+ if (!s.keptBytes) {
47
+ return { action: "report", exit: 1,
48
+ why: "tagged with no release entry, and no kept bytes to check the registry against: create the entry by hand after checking what the registry serves" };
49
+ }
50
+ if (s.visibleExit === 0) return { action: "create", exit: 0, why: "the registry serves the published bytes" };
51
+ if (s.visibleExit === 1) {
52
+ if (s.ageSec != null && s.ageSec > s.boundSec) {
53
+ return { action: "report", exit: 1,
54
+ why: `tagged ${Math.round(s.ageSec / 3600)} h ago and the registry still does not serve these bytes` };
55
+ }
56
+ return { action: "wait", exit: 0, why: "npm has not served it yet; the next scheduled run looks again" };
57
+ }
58
+ return { action: "report", exit: 2, why: "the registry check could not look" };
59
+ }
60
+
61
+ const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
62
+ const gh = (args, opts = {}) => execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts });
63
+
64
+ // Whether the entry exists. Only GitHub's own "release not found" is a no; any other failure throws.
65
+ function entryFor(tag, repo) {
66
+ const r = spawnSync("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"], { encoding: "utf8" });
67
+ if (r.status === 0) return true;
68
+ if (/release not found/i.test(r.stderr ?? "")) return false;
69
+ throw Object.assign(new Error(`could not read the release entry for ${tag}`), { stderr: r.stderr || r.error?.message });
70
+ }
71
+
72
+ function main() {
73
+ try { return catchUp(process.argv.slice(2)); }
74
+ catch (e) {
75
+ // A GitHub call that failed is a check that could not look, never the verdict "past the bound".
76
+ console.log(`::error::release-entry-catch-up could not finish: ${String(e.stderr || e.message).trim().split("\n")[0]}`);
77
+ return 2;
78
+ }
79
+ }
80
+
81
+ function catchUp(argv) {
82
+ const repo = arg(argv, "--repo", process.env.GITHUB_REPOSITORY);
83
+ const boundSec = Number(arg(argv, "--bound-hours", "6")) * 3600;
84
+ const dry = argv.includes("--dry-run");
85
+ if (!repo || !(boundSec > 0)) { console.error("release-entry-catch-up: needs --repo (or GITHUB_REPOSITORY) and a positive --bound-hours. Could not look."); return 2; }
86
+
87
+ const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
88
+ const tag = `v${version}`;
89
+ const prerelease = version.includes("-");
90
+
91
+ // THE EXACT REF, as the publish job asks it: a prefix read would take v0.3.1 for v0.3.1-beta.0.
92
+ let tagged;
93
+ try { tagged = gh(["api", `repos/${repo}/git/ref/tags/${tag}`, "--jq", ".ref"]).trim() === `refs/tags/${tag}`; }
94
+ catch (e) { if (/HTTP 404/.test(String(e.stderr))) tagged = false; else { console.error(`release-entry-catch-up: could not read the tag ${tag}: ${String(e.stderr || e.message).trim()}`); return 2; } }
95
+ const entryExists = tagged ? entryFor(tag, repo) : false;
96
+
97
+ let keptBytes = false, visibleExit = null, ageSec = null, work = null;
98
+ if (tagged && !entryExists) {
99
+ const art = JSON.parse(gh(["api", `repos/${repo}/actions/artifacts?name=published-${version}&per_page=1`]));
100
+ const a = (art.artifacts ?? []).find((x) => !x.expired);
101
+ if (a) {
102
+ work = mkdtempSync(join(tmpdir(), "entry-catch-up-"));
103
+ writeFileSync(join(work, "kept.zip"), execFileSync("gh", ["api", `repos/${repo}/actions/artifacts/${a.id}/zip`],
104
+ { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }));
105
+ execFileSync("unzip", ["-q", "-o", join(work, "kept.zip"), "-d", join(work, "kept")]);
106
+ const tgz = readdirSync(join(work, "kept")).find((f) => f.endsWith(".tgz"));
107
+ if (tgz) {
108
+ keptBytes = true;
109
+ const r = spawnSync(process.execPath, [join(ROOT, "scripts", "release-visible-check.mjs"), "--version", version,
110
+ "--tag", prerelease ? "beta" : "latest", "--tarball", join(work, "kept", tgz), "--timeout", "60", "--interval", "15"],
111
+ { encoding: "utf8" });
112
+ process.stdout.write(r.stdout ?? ""); process.stderr.write(r.stderr ?? "");
113
+ visibleExit = r.status;
114
+ }
115
+ }
116
+ const when = gh(["api", `repos/${repo}/commits/${tag}`, "--jq", ".commit.committer.date"]).trim();
117
+ ageSec = Math.max(0, (Date.now() - Date.parse(when)) / 1000);
118
+ }
119
+
120
+ const d = decide({ tagged, entryExists, keptBytes, visibleExit, ageSec, boundSec });
121
+ console.log(`release-entry-catch-up: ${tag}: ${d.why}.`);
122
+ if (d.action === "create") {
123
+ if (dry) { console.log(`release-entry-catch-up: --dry-run, so no entry is created for ${tag}.`); }
124
+ else {
125
+ // THE SAME ENTRY THE PUBLISH JOB WRITES: the changelog's section for this version, flagged as a
126
+ // pre-release for a beta, and GitHub's own notes only when the changelog has no section.
127
+ const notes = spawnSync(process.execPath, [join(ROOT, "scripts", "release-notes-for.mjs"), version], { encoding: "utf8" }).stdout ?? "";
128
+ const args = ["release", "create", tag, "--repo", repo, "--verify-tag", "--title", tag];
129
+ if (notes.trim()) { const f = join(work ?? mkdtempSync(join(tmpdir(), "entry-notes-")), "notes.md"); writeFileSync(f, notes); args.push("--notes-file", f); }
130
+ else args.push("--generate-notes");
131
+ if (prerelease) args.push("--prerelease");
132
+ gh(args);
133
+ console.log(`release-entry-catch-up: created the release entry for ${tag}${prerelease ? " as a pre-release" : ""}.`);
134
+ }
135
+ }
136
+ if (d.exit === 1) console.log(`::error::${tag}: ${d.why}.`);
137
+ else if (d.action === "wait") console.log(`::notice::${tag}: ${d.why}.`);
138
+ if (work) rmSync(work, { recursive: true, force: true });
139
+ return d.exit;
140
+ }
141
+
142
+ if (isEntrypoint(import.meta.url)) process.exitCode = main();
@@ -40,8 +40,10 @@
40
40
  // a door — which is now the difference between a box that installs and a box that crash-loops one.
41
41
 
42
42
  import { join } from "node:path";
43
+ import { homedir } from "node:os";
43
44
  import { challengeVerdict, blockedByAccessChallenge, challengeNote } from "./mcp-challenge.mjs"; // — F57
44
45
  import { whatHoldsPort } from "./os-advice.mjs";
46
+ import { defaultDenylistPath } from "./scope.mjs"; // — the verifier owns the path; see the re-export below
45
47
 
46
48
  /** The unit that runs the client door. Installed like any other; started only by this module. */
47
49
  export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
@@ -59,7 +61,9 @@ export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
59
61
  * file no verifier could read, and a revoked key kept answering 200. The guard existed; one of the two
60
62
  * doors was outside it.
61
63
  */
62
- export const defaultDenylistPath = (home) => join(home, ".config", "clearotron", "token-denylist");
64
+ // ONE definition, owned by the verifier (scope.mjs): the file a door reads when the setting is unset is,
65
+ // by construction, the file every revoker here writes.
66
+ export { defaultDenylistPath } from "./scope.mjs";
63
67
 
64
68
  /**
65
69
  * The denylist path a door should be given — the operator's, if they set one.
@@ -617,9 +621,10 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
617
621
  // ── THE DENYLIST PATH IS NAMED AND CREATED HERE, NEVER ASSUMED ───────────
618
622
  //
619
623
  // Measured on production, owner 2026-08-31: no denylist is configured anywhere — the variable is
620
- // empty in one example env and commented out in the other, and `isRevoked()` returns false when the
621
- // path is unset. So a key minted by a connect that assumed a denylist would be UNREVOKABLE, silently:
622
- // `disconnect` would write a jti into a file no verifier reads, and every check would look done.
624
+ // empty in one example env and commented out in the other, and `isRevoked()` then returned false for
625
+ // every token. So a key minted by a connect that assumed a denylist was UNREVOKABLE, silently:
626
+ // `disconnect` wrote a jti into a file no verifier read, and every check looked done. `isRevoked()`
627
+ // now reads the default list when the path is unset; naming it here still pins every door to one file.
623
628
  //
624
629
  // Armed AT CONNECT TIME, deliberately, not at disconnect: a process reads its environment once, at
625
630
  // start. A variable first written when someone disconnects is one the already-running door never
@@ -917,7 +922,7 @@ export function connectKeyReport(grants, { now = Date.now(), revoked = () => fal
917
922
  * were recorded".
918
923
  */
919
924
  export function disablePlan({ env = {}, unitDir, exists, identity = null, recorded = [],
920
- denylistPath = null } = {}) {
925
+ denylistPath = null, home = homedir() } = {}) {
921
926
  const door = clientDoorState({ env, unitDir, exists });
922
927
  const jtis = (recorded ?? []).map((r) => r.jti).filter(Boolean);
923
928
  // The path the running verifiers were BORN with wins; the caller's default only covers the install
@@ -926,7 +931,9 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
926
931
  // a revocation the environment cannot deliver retroactively.
927
932
  const existing = String(env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
928
933
  const armedPath = existing || (jtis.length && denylistPath ? String(denylistPath) : null);
929
- const lateArm = Boolean(jtis.length && !existing && armedPath);
934
+ // NOT LATE WHEN IT IS THE DEFAULT LIST: a door with the setting unset reads the install's default list
935
+ // (isRevoked, shared/scope.mjs), so writing there reaches the running doors now, not from their next start.
936
+ const lateArm = Boolean(jtis.length && !existing && armedPath && armedPath !== defaultDenylistPath(home));
930
937
 
931
938
  if (!jtis.length) {
932
939
  return { possible: false, nothingOpen: true, door,
@@ -965,7 +972,7 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
965
972
  * everything. Stopping the unit would additionally break the stdio-free local route for readers who
966
973
  * hold no key at all, and would be undone by the next install.
967
974
  */
968
- export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null } = {}) {
975
+ export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null, home = homedir() } = {}) {
969
976
  const rows = Object.entries(grants?.connectKeys ?? {}).map(([jti, r]) => ({ jti, sub: r?.sub ?? null }));
970
977
  const jtis = rows.map((r) => r.jti);
971
978
  const people = [...new Set(rows.map((r) => r.sub).filter(Boolean))];
@@ -980,7 +987,7 @@ export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = nul
980
987
  return {
981
988
  possible: true, nothingOpen: false, jtis, people,
982
989
  denylistPath: armedPath,
983
- lateArm: Boolean(!existing && armedPath),
990
+ lateArm: Boolean(!existing && armedPath && armedPath !== defaultDenylistPath(home)), // see disablePlan
984
991
  // THE COUNT IS THE FIRST THING SAID, and it names both dimensions: five keys held by one person and
985
992
  // five keys held by five people are the same number and not the same act.
986
993
  says: [`This revokes ${jtis.length} issued key(s) held by ${people.length} ${people.length === 1 ? "person" : "people"}`
package/shared/scope.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
21
21
  import { readFileSync, statSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { homedir } from "node:os";
22
24
  import { envFrom } from "./env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
23
25
 
24
26
  const SECRET = () => process.env.TRADEMARK_MCP_TOKEN_SECRET || "";
@@ -29,16 +31,29 @@ const SECRETS = () => [process.env.TRADEMARK_MCP_TOKEN_SECRET, process.env.TRADE
29
31
  .filter((s) => typeof s === "string" && s.length > 0);
30
32
  const b64u = (buf) => Buffer.from(buf).toString("base64url");
31
33
 
32
- // Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments), path in
33
- // TRADEMARK_MCP_TOKEN_DENYLIST. Checked on every token verification. Short TTLs remain the primary
34
- // control; a missing/unreadable file means "nothing revoked yet" (the denylist must never be able
35
- // to take ALL token auth down on an fs blip). Legacy tokens carry no jti and are killed by secret
36
- // rotation instead.
37
- export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_DENYLIST } = {}) {
38
- if (!jti || !denylistPath) return false;
34
+ // Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments). Checked on every
35
+ // token verification, ALWAYS: the path is TRADEMARK_MCP_TOKEN_DENYLIST when it is set, and otherwise the
36
+ // install's default below, the same file every revoker writes when the setting is unset (connect,
37
+ // disconnect, `key`, mint-token's own note). Short TTLs remain the primary control. Legacy tokens carry
38
+ // no jti and are killed by secret rotation instead.
39
+ //
40
+ // REVOCATION USED TO BE OFF WHEN THE SETTING WAS UNSET: this returned "not revoked" for every token, so a
41
+ // door started without the variable (by hand, or by a unit that never loaded it) ignored a revocation
42
+ // written to the default file. Now:
43
+ // - a NAMED list that cannot be read refuses every token (below), as it already did;
44
+ // - a list that EXISTS and cannot be read refuses every token, named or default;
45
+ // - the DEFAULT list being ABSENT means nothing has been revoked. Every revocation creates the file
46
+ // it writes to, and with the setting unset that is this file, so its absence is the proof, not a
47
+ // gap. It is the one absence read as a negative here, and only for that reason.
48
+ export const defaultDenylistPath = (home) => join(home, ".config", "clearotron", "token-denylist");
49
+ export function isRevoked(jti, { denylistPath = undefined, env = process.env, home = homedir() } = {}) {
50
+ if (!jti) return false;
51
+ const named = String(denylistPath ?? env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
52
+ const path = named || defaultDenylistPath(home);
39
53
  let text;
40
- try { text = readFileSync(denylistPath, "utf8"); }
54
+ try { text = readFileSync(path, "utf8"); }
41
55
  catch (e) {
56
+ if (!named && e?.code === "ENOENT") return false; // the default list, never written: nothing revoked
42
57
  // FAIL CLOSED (found in review; ruled and recorded for the owner's
43
58
  // review with the reversal path, which is this function).
44
59
  //
@@ -50,10 +65,10 @@ export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_
50
65
  // The cost is now a VISIBLE outage that names its own cause, rather than an invisible hole. `start`
51
66
  // creates this file before any door starts, so reaching this branch means someone removed or
52
67
  // unreadable-ified it while the door was running — rare, and worth stopping for.
53
- const err = new Error(`revocation could not be checked: the denylist at ${denylistPath} is unreadable `
68
+ const err = new Error(`revocation could not be checked: the denylist at ${path} is unreadable `
54
69
  + `(${e.code ?? e.message}). Refusing the token rather than assuming it was never revoked.`);
55
70
  err.code = "REVOCATION_UNCHECKABLE";
56
- err.denylistPath = denylistPath;
71
+ err.denylistPath = path;
57
72
  throw err;
58
73
  }
59
74
  return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).includes(jti);