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.
- package/INSTALL.md +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +18 -0
- package/driver/company-bundle.mjs +11 -18
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +1 -2
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +14 -5
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +43 -15
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/publish/index.mjs +13 -1
- package/driver/publish/render.mjs +13 -2
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/run-economics.mjs +7 -18
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +61 -31
- package/driver/tokens.mjs +18 -10
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +10 -0
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-DVtz44vH.js} +57 -8
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +142 -0
- package/shared/client-door.mjs +15 -8
- package/shared/scope.mjs +25 -10
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.
|
|
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.",
|
package/mcp-server/server.mjs
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
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": {
|
|
@@ -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
|
|
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
|
|
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
|
|
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();
|
package/shared/client-door.mjs
CHANGED
|
@@ -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
|
-
|
|
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()`
|
|
621
|
-
//
|
|
622
|
-
// `disconnect`
|
|
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
|
-
|
|
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)
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// rotation instead.
|
|
37
|
-
|
|
38
|
-
|
|
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(
|
|
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 ${
|
|
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 =
|
|
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);
|