clearotron 0.3.1-beta.2 → 0.3.1-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/grant.mjs +21 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +33 -0
- package/driver/citation-census.json +3 -3
- package/driver/common-law-receipts.mjs +11 -1
- package/driver/connotation-search.mjs +56 -5
- package/driver/contract-e3-backlog.mjs +31 -31
- package/driver/contract-vocabulary.mjs +59 -58
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/gateway.mjs +29 -2
- package/driver/matter-frame-record.mjs +54 -1
- package/driver/package.json +1 -1
- package/driver/portal-config-view.mjs +26 -1
- package/driver/portal-report.mjs +16 -2
- package/driver/portal-service.mjs +327 -8
- package/driver/publish/render.mjs +0 -45
- package/driver/publish/templates/report.css +1 -30
- package/driver/skills/knockout-assess/SKILL.md +35 -6
- package/driver/skills/knockout-frame/SKILL.md +2 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
- package/driver/stages.mjs +19 -5
- package/driver/suite-census.json +112 -16
- package/driver/verify-knockout.mjs +58 -0
- package/driver/verify.mjs +83 -7
- package/mcp-server/CHANGELOG.md +11 -0
- package/mcp-server/lib/audit.mjs +96 -3
- package/mcp-server/lib/http-handler.mjs +76 -3
- package/mcp-server/lib/runs.mjs +42 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +10 -6
- package/mcp-server/server.mjs +37 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-ChIQsMYp.js} +1375 -817
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-DBIs21e4.css} +37 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/scripts/citation-line-check.mjs +53 -2
- package/scripts/deprecate-below.mjs +146 -0
- package/scripts/test-run.mjs +24 -1
- package/shared/connect-clients.mjs +52 -16
- package/shared/driver-dir.mjs +1 -1
- package/shared/grants-edit.mjs +99 -2
|
@@ -89,7 +89,7 @@ export function opsTokenFor({ bootToken, roster, mint }) {
|
|
|
89
89
|
// has held the signing secret on both start paths for as long as both have existed. The comment has been
|
|
90
90
|
// corrected in place rather than left to be trusted.
|
|
91
91
|
import { mintToken, loadGrants, resolvePerson, addressesInGrants } from "../shared/scope.mjs";
|
|
92
|
-
import { withPerson, withCompany } from "../shared/grants-edit.mjs";
|
|
92
|
+
import { withPerson, withoutPerson, personPoints, withCompany } from "../shared/grants-edit.mjs";
|
|
93
93
|
import { resolvePort } from "../shared/listen.mjs"; // — the port SOURCE, decided once
|
|
94
94
|
import { fileURLToPath } from "node:url";
|
|
95
95
|
|
|
@@ -1082,6 +1082,59 @@ function outcomeRow({ event = "request-refused", method, path, email = null, sta
|
|
|
1082
1082
|
return row;
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* Revoking the connector keys a removed person holds — composed here, exported so it can be driven.
|
|
1087
|
+
*
|
|
1088
|
+
* It is the portal's half of an act `clearotron disconnect` also performs, through the same module, so
|
|
1089
|
+
* one author decides what revoking means. What differs is WHERE each runs, and the difference decides
|
|
1090
|
+
* what each may do: `disconnect` runs on the box beside the door, and this runs in a web request in a
|
|
1091
|
+
* different service with a different environment.
|
|
1092
|
+
*/
|
|
1093
|
+
export function makeConnectorKeyRevoker({ env = process.env, home = null } = {}) {
|
|
1094
|
+
return async ({ email, grants: g }) => {
|
|
1095
|
+
const { recordedKeysFor, disablePlan, applyDisablePlan, denylistPathFor } = await import("../shared/client-door.mjs");
|
|
1096
|
+
const { homedir } = await import("node:os");
|
|
1097
|
+
const { existsSync, mkdirSync, writeFileSync, appendFileSync } = await import("node:fs");
|
|
1098
|
+
const { join, dirname } = await import("node:path");
|
|
1099
|
+
const base = home ?? homedir();
|
|
1100
|
+
const recorded = recordedKeysFor(g, email);
|
|
1101
|
+
const plan = disablePlan({ env, unitDir: join(base, ".config", "systemd", "user"), exists: existsSync,
|
|
1102
|
+
identity: email, recorded, denylistPath: denylistPathFor(env, base) });
|
|
1103
|
+
if (!plan.possible) return { grants: g, revoked: 0, jtis: [], lateArm: false, says: plan.says };
|
|
1104
|
+
if (plan.lateArm) return { grants: g, revoked: 0, jtis: plan.jtis, lateArm: true, says: plan.says ?? [] };
|
|
1105
|
+
// THE RECORD IS NEVER STRUCK FROM HERE, and the ledger step is dropped rather than no-opped.
|
|
1106
|
+
//
|
|
1107
|
+
// `disablePlan` decides `lateArm` from the environment it is handed, and the environment this
|
|
1108
|
+
// process has is the PORTAL's. The connector is a different unit with its own `EnvironmentFile`, so
|
|
1109
|
+
// a box where the portal names a revocation list and the door was started without one reads as
|
|
1110
|
+
// `lateArm: false` here — and the plan would then write the list AND strike the record, for a key
|
|
1111
|
+
// that still works. A record removed while its key works is the only trace of that key, gone: the
|
|
1112
|
+
// failure `client-door.mjs` states its ordering rule to prevent.
|
|
1113
|
+
//
|
|
1114
|
+
// So this process does the half it can vouch for. Writing the list is safe in both configurations —
|
|
1115
|
+
// it is the right act where the door reads it and a file nobody opens where it does not. Leaving the
|
|
1116
|
+
// record is safe in both too: `connectKeyReport` already judges a record valid, expired or revoked,
|
|
1117
|
+
// so a record of a revoked key is an accurate one, and `clearotron doctor` is where an operator sees
|
|
1118
|
+
// which. `clearotron disconnect` runs on the box, beside the door, and still strikes.
|
|
1119
|
+
//
|
|
1120
|
+
// The seam THROWS rather than doing nothing, so that a future change putting the ledger step back
|
|
1121
|
+
// fails here instead of quietly striking again.
|
|
1122
|
+
const revokeOnly = { ...plan, steps: plan.steps.filter((step) => step.id === "revoke") };
|
|
1123
|
+
applyDisablePlan(revokeOnly, {
|
|
1124
|
+
appendDenylist: (path, jtis) => {
|
|
1125
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1126
|
+
if (!existsSync(path)) writeFileSync(path, "# Revoked key ids, one jti per line. Read on every key check.\n", { mode: 0o600 });
|
|
1127
|
+
appendFileSync(path, jtis.map((j) => `${j}\n`).join(""));
|
|
1128
|
+
},
|
|
1129
|
+
strikeRecords: () => {
|
|
1130
|
+
throw new Error("the portal must not strike a key record: it cannot see the connector's environment, "
|
|
1131
|
+
+ "so it cannot know whether the revocation list it just wrote is the one that door loaded");
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
return { grants: g, revoked: plan.jtis.length, jtis: plan.jtis, lateArm: false, recordKept: true };
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1085
1138
|
export function makePortalService({
|
|
1086
1139
|
poolRoot, workspaceRoot, recipesDir = undefined, secret,
|
|
1087
1140
|
grants = null,
|
|
@@ -1094,6 +1147,14 @@ export function makePortalService({
|
|
|
1094
1147
|
// CLEAROTRON_ACCESS_FILE. Null means this service cannot write the file, and the routes that would
|
|
1095
1148
|
// need to say so rather than pretend.
|
|
1096
1149
|
writeGrants = null,
|
|
1150
|
+
// Revoking the connector keys a removed person holds. INJECTED for the reason `writeGrants` is: this
|
|
1151
|
+
// constructor stays pure over its inputs, an arm can watch the revocation without a denylist file on
|
|
1152
|
+
// disk, and boot is where the paths live. It takes the grants object the removal produced and returns
|
|
1153
|
+
// it with the struck records gone, so one write lands both facts.
|
|
1154
|
+
//
|
|
1155
|
+
// Null means this service cannot revoke, and the answer SAYS so — a removal that quietly left a live
|
|
1156
|
+
// key would be the exact failure the page's own sentence promises against.
|
|
1157
|
+
revokeConnectorKeys = null,
|
|
1097
1158
|
// The queue directories the RUNNER drains — the same list it hands checkRunCaps. The allowance counter
|
|
1098
1159
|
// and the quota pre-check read their ledger beside these, so they count what the wall counts (:
|
|
1099
1160
|
// they used to reconstruct a workspace-relative path that resolved to nothing once the queue moved out
|
|
@@ -2316,6 +2377,52 @@ export function makePortalService({
|
|
|
2316
2377
|
// cache, and a failure that answers `null` rather than throwing — a page that cannot read its door says
|
|
2317
2378
|
// so, which is the honest half of this change.
|
|
2318
2379
|
let doorKindCache = { at: 0, url: null, kind: null };
|
|
2380
|
+
// ── WHETHER A READER HAS AN ASSISTANT ON THIS INSTALLATION ────────────────────────────────────────
|
|
2381
|
+
//
|
|
2382
|
+
// The only evidence either process holds is the connector's own access log: enrolment says a person MAY
|
|
2383
|
+
// connect, never that they did. `readConnections` is the connector's reader, imported rather than
|
|
2384
|
+
// rebuilt here — the path this log lives at is the connector's fact, and a portal deriving it from its
|
|
2385
|
+
// own environment is how a live key's record nearly got struck on a split install.
|
|
2386
|
+
//
|
|
2387
|
+
// TWO INSTALLATION SHAPES, AND THE SPLIT IS THE ONE THE ROUTE ALREADY MAKES. A hosted install has a
|
|
2388
|
+
// published client door and many signed-in people, so the question is answered per person: is this
|
|
2389
|
+
// email in the log. A local install has no client door, one reader, and a connector that cannot know
|
|
2390
|
+
// who it is serving — so the local route's own record answers for the only person who could be asking.
|
|
2391
|
+
// Reading a local record as an answer on a HOSTED install would mark every reader connected the moment
|
|
2392
|
+
// one member of staff ran the server by hand, which is why it is gated on there being no client door.
|
|
2393
|
+
//
|
|
2394
|
+
// Returns null, never false, when there was nothing to read. See the route for why that matters.
|
|
2395
|
+
//
|
|
2396
|
+
// CACHED FOR A MINUTE, the same as the door probe above and for the same reason: this is a page load,
|
|
2397
|
+
// not a check. It runs on every report a reader opens, and reading the tail of a log each time to
|
|
2398
|
+
// answer a question whose answer changes once, ever, would be paid on the client-facing screen.
|
|
2399
|
+
// KEYED ON THE FILES IT READ, not on time alone. The log's location is configuration and does not move
|
|
2400
|
+
// under a running service — but a cache that ignored it would answer about the wrong file for a minute
|
|
2401
|
+
// after it did, and it is what makes this cache testable at the route rather than only at the helper.
|
|
2402
|
+
let connectionsCache = { at: 0, key: null, seen: null };
|
|
2403
|
+
const CONNECTIONS_TTL_MS = 60_000;
|
|
2404
|
+
|
|
2405
|
+
async function readerHasConnectedAi(principal) {
|
|
2406
|
+
const email = String(principal?.email ?? "").trim().toLowerCase();
|
|
2407
|
+
let seen = null;
|
|
2408
|
+
// Imported here rather than at the top, the way every other reach into mcp-server/lib from this file
|
|
2409
|
+
// is: the driver does not depend on the connector, and the import-cycle walk is what keeps it so.
|
|
2410
|
+
let audit = null;
|
|
2411
|
+
try { audit = await import("../mcp-server/lib/audit.mjs"); }
|
|
2412
|
+
catch { return null; } // best-effort: never fail a page load
|
|
2413
|
+
const key = audit.auditPaths().join("\u0000");
|
|
2414
|
+
if (connectionsCache.seen && connectionsCache.key === key && Date.now() - connectionsCache.at < CONNECTIONS_TTL_MS) {
|
|
2415
|
+
seen = connectionsCache.seen;
|
|
2416
|
+
} else {
|
|
2417
|
+
try { seen = audit.readConnections(); } catch { return null; }
|
|
2418
|
+
connectionsCache = { at: Date.now(), key, seen };
|
|
2419
|
+
}
|
|
2420
|
+
if (!seen.available) return null;
|
|
2421
|
+
if (email && seen.emails.has(email)) return true;
|
|
2422
|
+
if (!process.env.CLEAROTRON_CLIENT_MCP_URL && seen.local) return true;
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2319
2426
|
const DOOR_KIND_TTL_MS = 60_000;
|
|
2320
2427
|
async function connectorDoorKind(url) {
|
|
2321
2428
|
if (!url) return null;
|
|
@@ -2402,6 +2509,18 @@ async function connectorDoorKind(url) {
|
|
|
2402
2509
|
email: principal.email ?? null, // the identity to sign in with — what they already use
|
|
2403
2510
|
enabled: !!url,
|
|
2404
2511
|
stdio, // the local route, or null for a client
|
|
2512
|
+
// ── HAS THIS READER ALREADY CONNECTED AN ASSISTANT? ──────────────────────────────────────
|
|
2513
|
+
//
|
|
2514
|
+
// Folded into this route rather than given its own, because the Ask-AI control on a report
|
|
2515
|
+
// already loads it and a second request per report open buys nothing.
|
|
2516
|
+
//
|
|
2517
|
+
// `null` IS A THIRD STATE AND THE SCREEN HAS TO DRAW IT. True, false and "no log to read" are
|
|
2518
|
+
// different facts: an installation whose access log has not been written yet, or cannot be
|
|
2519
|
+
// read, has not told us this reader never connected — it has told us nothing. The control
|
|
2520
|
+
// treats null the way it treats false, because offering the menu to somebody with no
|
|
2521
|
+
// assistant reproduces the defect this is fixing, and because the panel carries its own way
|
|
2522
|
+
// past ("Already connected? Ask anyway"). What it must not do is claim the measurement.
|
|
2523
|
+
aiConnected: await readerHasConnectedAi(principal),
|
|
2405
2524
|
// Every client, already resolved: served or not, with what it needs or why it cannot be.
|
|
2406
2525
|
//
|
|
2407
2526
|
// ── `steps` COMES BACK, on the owner's 2026-09-03 ruling ─────────
|
|
@@ -3060,7 +3179,16 @@ async function connectorDoorKind(url) {
|
|
|
3060
3179
|
const p = envFrom(process.env, "CLEAROTRON_ACCESS_FILE");
|
|
3061
3180
|
if (p) grantsFile = { name: basename(p), modifiedAt: new Date(statSync(p).mtimeMs).toISOString() };
|
|
3062
3181
|
} catch { /* reported as unknown; a failed stat must not take down the page that explains access */ }
|
|
3063
|
-
|
|
3182
|
+
// WHETHER AN ISSUED KEY CAN BE WITHDRAWN AT ALL, read the same way `disablePlan` reads it: the
|
|
3183
|
+
// door loaded a revocation list at start, or it did not and never will for the keys already
|
|
3184
|
+
// out. The variable's PRESENCE is the whole question — its value is a path, and this route
|
|
3185
|
+
// must not say where.
|
|
3186
|
+
// WHETHER A REVOCATION LIST IS NAMED FOR THIS PROCESS AT ALL — which is not the same question
|
|
3187
|
+
// as whether the connector loaded one, and the page's words are careful about the difference.
|
|
3188
|
+
// The connector is a separate unit with its own environment; this answers only for here.
|
|
3189
|
+
const keysRevocable = Boolean(revokeConnectorKeys)
|
|
3190
|
+
&& String(process.env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim() !== "";
|
|
3191
|
+
return { status: 200, json: accessView({ grants: grantsHere, viewer: principal, companies, grantsFile, localSignIn, keysRevocable }) };
|
|
3064
3192
|
}
|
|
3065
3193
|
// /portal/admin/people — give someone access. Manage-gated above; everything else is decided here.
|
|
3066
3194
|
//
|
|
@@ -3070,23 +3198,40 @@ async function connectorDoorKind(url) {
|
|
|
3070
3198
|
// as they were, and the answer says which happened. Nobody sets their own switches, and nobody
|
|
3071
3199
|
// gives Run without holding it.
|
|
3072
3200
|
if (parts[2] === "people" && parts.length === 3 && method === "POST") {
|
|
3073
|
-
|
|
3201
|
+
// CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
|
|
3202
|
+
// a client cannot tell the two apart by looking at it — and the one that reached the page as page
|
|
3203
|
+
// copy was a token. A code the client reads as a FIELD is decidable; prose never is.
|
|
3204
|
+
if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
|
|
3074
3205
|
if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
|
|
3075
3206
|
const email = String(body?.email ?? "").trim().toLowerCase();
|
|
3076
3207
|
if (!email || email.indexOf("@") <= 0 || email.indexOf("@") !== email.lastIndexOf("@"))
|
|
3077
3208
|
return { status: 400, json: { error: "Enter one email address." } };
|
|
3209
|
+
// ACCESS TO EVERYTHING IS NOT A POINT, and the handler used to treat it as one. The form draws
|
|
3210
|
+
// "Everything on this Clearotron" only to somebody who holds it, and sent it as
|
|
3211
|
+
// `{kind:"everything"}` alongside the organisation and company points; nothing here matched
|
|
3212
|
+
// that kind, so it fell to the 404 below and the page rendered the refusal it keeps for a
|
|
3213
|
+
// point outside the adder's reach — "You can only give access to what you have access to
|
|
3214
|
+
// yourself" — to the one person on the install for whom that is false. The control had never
|
|
3215
|
+
// worked. It lives under `people` as a switch, not in any organisation, so it is read as one.
|
|
3078
3216
|
const points = [];
|
|
3217
|
+
let wantsEverything = false;
|
|
3079
3218
|
for (const a of Array.isArray(body?.access) ? body.access : []) {
|
|
3080
3219
|
const key = typeof a?.key === "string" ? a.key : "";
|
|
3081
|
-
if (a?.kind === "
|
|
3220
|
+
if (a?.kind === "everything" && seesEverything(principal)) wantsEverything = true;
|
|
3221
|
+
else if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) points.push({ tenant: key });
|
|
3082
3222
|
else if (a?.kind === "company" && principal.accountOrgs?.[key]) points.push({ tenant: principal.accountOrgs[key], account: key });
|
|
3083
3223
|
else return { status: 404, json: { error: "not_found" } };
|
|
3084
3224
|
}
|
|
3085
|
-
if (!points.length) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
|
|
3086
|
-
const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true };
|
|
3225
|
+
if (!points.length && !wantsEverything) return { status: 400, json: { error: "Choose at least one organisation or company this person may see." } };
|
|
3226
|
+
const want = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true, everything: wantsEverything };
|
|
3087
3227
|
if (want.run && !mayRun(principal)) return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
|
|
3088
3228
|
const existing = resolvePerson(email, grantsHere);
|
|
3089
3229
|
const setSwitches = email !== principal.email && (!existing || reachCovers(principal, existing));
|
|
3230
|
+
// THE SWITCHES ARE WHERE ACCESS TO EVERYTHING IS WRITTEN, so a grant of it that cannot write
|
|
3231
|
+
// them writes nothing at all — and `withPerson` would return unchanged grants and this route a
|
|
3232
|
+
// 201 naming a person who gained nothing. Said instead of returned.
|
|
3233
|
+
if (wantsEverything && !setSwitches)
|
|
3234
|
+
return { status: 400, json: { error: "Access to everything is part of what a person may do, and that cannot be set from here for this address. Nothing was saved." } };
|
|
3090
3235
|
let next;
|
|
3091
3236
|
try { next = withPerson(grantsHere, { email, points, switches: want, setSwitches }); }
|
|
3092
3237
|
catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
|
|
@@ -3101,6 +3246,138 @@ async function connectorDoorKind(url) {
|
|
|
3101
3246
|
const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email === email) ?? null;
|
|
3102
3247
|
return { status: 201, json: { person, switchesApplied: setSwitches } };
|
|
3103
3248
|
}
|
|
3249
|
+
|
|
3250
|
+
// /portal/admin/people/change — change what somebody may do and see.
|
|
3251
|
+
//
|
|
3252
|
+
// THE DIFF IS COMPUTED HERE, FROM THE FILE, and never taken from the request. The page sends the
|
|
3253
|
+
// state it wants; this reads what the file holds right now, narrows that to the part of the
|
|
3254
|
+
// person the caller can see, and works out what to add and what to take away between the two. A
|
|
3255
|
+
// page that had been open while somebody else was edited would otherwise write its own stale
|
|
3256
|
+
// copy back over them — and the half it would overwrite is the half outside its own view, which
|
|
3257
|
+
// nobody looking at either screen could see happen.
|
|
3258
|
+
//
|
|
3259
|
+
// NOBODY CHANGES THEMSELVES. The Add form already refuses to set the adder's own switches; this
|
|
3260
|
+
// refuses the whole act, because a manager who can take their own Manage away can lock the
|
|
3261
|
+
// install's last manager out of it with one press, and the way back is a text editor on the box.
|
|
3262
|
+
if (parts[2] === "people" && parts[3] === "change" && parts.length === 4 && method === "POST") {
|
|
3263
|
+
// CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
|
|
3264
|
+
// a client cannot tell the two apart by looking at it — and the one that reached the page as page
|
|
3265
|
+
// copy was a token. A code the client reads as a FIELD is decidable; prose never is.
|
|
3266
|
+
if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
|
|
3267
|
+
if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
|
|
3268
|
+
const email = String(body?.email ?? "").trim().toLowerCase();
|
|
3269
|
+
if (email === principal.email) return { status: 400, json: { error: "You cannot change your own access. Somebody else who manages this install can." } };
|
|
3270
|
+
const existing = resolvePerson(email, grantsHere);
|
|
3271
|
+
const held = personPoints(grantsHere, email);
|
|
3272
|
+
if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
|
|
3273
|
+
|
|
3274
|
+
// What the caller can see of this person, and what they asked for — both in the same shape, so
|
|
3275
|
+
// the difference between them is the change.
|
|
3276
|
+
const inReach = (pt) => pt.account == null
|
|
3277
|
+
? (principal.genericOrgs ?? []).includes(pt.tenant)
|
|
3278
|
+
: principal.accountOrgs?.[pt.account] === pt.tenant;
|
|
3279
|
+
const mine = held.points.filter(inReach);
|
|
3280
|
+
const want = [];
|
|
3281
|
+
for (const a of Array.isArray(body?.access) ? body.access : []) {
|
|
3282
|
+
const key = typeof a?.key === "string" ? a.key : "";
|
|
3283
|
+
if (a?.kind === "organisation" && (principal.genericOrgs ?? []).includes(key)) want.push({ tenant: key, account: null });
|
|
3284
|
+
else if (a?.kind === "company" && principal.accountOrgs?.[key]) want.push({ tenant: principal.accountOrgs[key], account: key });
|
|
3285
|
+
else return { status: 404, json: { error: "not_found" } };
|
|
3286
|
+
}
|
|
3287
|
+
const id = (pt) => `${pt.tenant}/${pt.account ?? "*"}`;
|
|
3288
|
+
const wanted = new Set(want.map(id));
|
|
3289
|
+
const kept = new Set(mine.map(id));
|
|
3290
|
+
const drop = mine.filter((pt) => !wanted.has(id(pt)));
|
|
3291
|
+
const add = want.filter((pt) => !kept.has(id(pt)));
|
|
3292
|
+
// TAKING THE WHOLE OF SOMEBODY'S VISIBLE ACCESS AWAY IS REMOVAL, and it has its own route, its
|
|
3293
|
+
// own confirmation and its own revocation. Reaching it by unticking every row would do half of
|
|
3294
|
+
// that act under the word "save".
|
|
3295
|
+
if (!want.length) return { status: 400, json: { error: "Leave them at least one organisation or company, or remove them instead." } };
|
|
3296
|
+
|
|
3297
|
+
const switches = { run: body?.permissions?.run === true, manage: body?.permissions?.manage === true,
|
|
3298
|
+
everything: held.switches.everything };
|
|
3299
|
+
if (switches.run && !held.switches.run && !mayRun(principal))
|
|
3300
|
+
return { status: 400, json: { error: "You cannot give Run clearances without holding it yourself." } };
|
|
3301
|
+
// Switches belong to the person and not to a point, so they are set only when the whole of this
|
|
3302
|
+
// person sits inside the caller's reach. Otherwise the points move and the switches stay, and
|
|
3303
|
+
// the answer says which happened — the same contract adding already has.
|
|
3304
|
+
const setSwitches = reachCovers(principal, existing);
|
|
3305
|
+
let next;
|
|
3306
|
+
try {
|
|
3307
|
+
next = drop.length ? withoutPerson(grantsHere, { email, points: drop }) : grantsHere;
|
|
3308
|
+
next = withPerson(next, { email, points: add, switches, setSwitches });
|
|
3309
|
+
} catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
|
|
3310
|
+
try { await writeGrants(next); }
|
|
3311
|
+
catch (e) {
|
|
3312
|
+
audit({ event: "person-change", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
|
|
3313
|
+
return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
|
|
3314
|
+
}
|
|
3315
|
+
audit({ event: "person-change", by: principal.email, person: email, added: add.length, removed: drop.length,
|
|
3316
|
+
switchesApplied: setSwitches, ok: true, status: 200 });
|
|
3317
|
+
const { loadProfiles } = await import("./profiles.mjs");
|
|
3318
|
+
const companies = Object.fromEntries([...loadProfiles({ force: true }).values()].map((p) => [p.key, p.name ?? p.key]));
|
|
3319
|
+
const person = accessView({ grants: next, viewer: principal, companies }).people.find((p) => p.email.toLowerCase() === email) ?? null;
|
|
3320
|
+
return { status: 200, json: { person, switchesApplied: setSwitches, added: add.length, removed: drop.length } };
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
// /portal/admin/people/remove — take their access away.
|
|
3324
|
+
//
|
|
3325
|
+
// HOW FAR IT REACHES IS THE SERVER'S ANSWER, NOT THE REQUEST'S. A caller who can see the whole of
|
|
3326
|
+
// this person removes them from the install; a caller who can see one organisation's worth of
|
|
3327
|
+
// them takes away that organisation and nothing else. The page draws a different button for each
|
|
3328
|
+
// — it reads the same `covered` the view computes — but the request carries no scope to get
|
|
3329
|
+
// wrong, so a stale page cannot ask for more than the person pressing it can see.
|
|
3330
|
+
if (parts[2] === "people" && parts[3] === "remove" && parts.length === 4 && method === "POST") {
|
|
3331
|
+
// CODE AS ITS OWN FIELD. `error` carries a sentence on most routes here and a token on a few, so
|
|
3332
|
+
// a client cannot tell the two apart by looking at it — and the one that reached the page as page
|
|
3333
|
+
// copy was a token. A code the client reads as a FIELD is decidable; prose never is.
|
|
3334
|
+
if (localSignIn) return { status: 409, json: { error: "local_sign_in", code: "local_sign_in" } };
|
|
3335
|
+
if (!writeGrants) return { status: 503, json: { error: "cannot_write_grants" } };
|
|
3336
|
+
const email = String(body?.email ?? "").trim().toLowerCase();
|
|
3337
|
+
if (email === principal.email) return { status: 400, json: { error: "You cannot remove your own access. Somebody else who manages this install can." } };
|
|
3338
|
+
const existing = resolvePerson(email, grantsHere);
|
|
3339
|
+
const held = personPoints(grantsHere, email);
|
|
3340
|
+
if (!existing && !held.listed && !held.points.length) return { status: 404, json: { error: "not_found" } };
|
|
3341
|
+
const whole = reachCovers(principal, existing);
|
|
3342
|
+
const inReach = (pt) => pt.account == null
|
|
3343
|
+
? (principal.genericOrgs ?? []).includes(pt.tenant)
|
|
3344
|
+
: principal.accountOrgs?.[pt.account] === pt.tenant;
|
|
3345
|
+
const mine = held.points.filter(inReach);
|
|
3346
|
+
if (!whole && !mine.length) return { status: 404, json: { error: "not_found" } };
|
|
3347
|
+
|
|
3348
|
+
let next;
|
|
3349
|
+
try { next = withoutPerson(grantsHere, whole ? { email, all: true } : { email, points: mine }); }
|
|
3350
|
+
catch (e) { return { status: 400, json: { error: String(e?.message ?? e).slice(0, 300) } }; }
|
|
3351
|
+
|
|
3352
|
+
// THE KEYS, AND ONLY ON A WHOLE REMOVAL. A narrowing leaves the person on the install with
|
|
3353
|
+
// access somewhere else, and their connector key is how they reach that.
|
|
3354
|
+
let keys = { checked: false };
|
|
3355
|
+
if (whole) {
|
|
3356
|
+
if (!revokeConnectorKeys) keys = { checked: false, note: "this installation cannot revoke issued keys from here" };
|
|
3357
|
+
else {
|
|
3358
|
+
try {
|
|
3359
|
+
const { grants: written, ...said } = await revokeConnectorKeys({ email, grants: next });
|
|
3360
|
+
next = written ?? next;
|
|
3361
|
+
keys = { checked: true, ...said };
|
|
3362
|
+
}
|
|
3363
|
+
// A REVOCATION THAT FAILED MUST NOT LOOK LIKE ONE THAT HAPPENED, and it must not take the
|
|
3364
|
+
// removal with it either: the access record is the gate every surface reads, and leaving
|
|
3365
|
+
// it in place because a key file could not be appended would be the larger failure.
|
|
3366
|
+
catch (e) { keys = { checked: true, failed: true, note: String(e?.message ?? e).slice(0, 200) }; }
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
try { await writeGrants(next); }
|
|
3371
|
+
catch (e) {
|
|
3372
|
+
audit({ event: "person-remove", by: principal.email, person: email, ok: false, error: String(e?.message ?? e).slice(0, 200) });
|
|
3373
|
+
return { status: 500, json: { error: "The guest list could not be written, so nothing was changed." } };
|
|
3374
|
+
}
|
|
3375
|
+
audit({ event: "person-remove", by: principal.email, person: email, scope: whole ? "install" : "organisations",
|
|
3376
|
+
organisations: whole ? null : [...new Set(mine.map((pt) => pt.tenant))].length,
|
|
3377
|
+
keysRevoked: keys.revoked ?? 0, keysUnrevokable: keys.lateArm ? (keys.jtis?.length ?? 0) : 0, ok: true, status: 200 });
|
|
3378
|
+
return { status: 200, json: { removed: whole ? "install" : "organisations",
|
|
3379
|
+
organisations: whole ? [] : [...new Set(mine.map((pt) => pt.tenant))], keys } };
|
|
3380
|
+
}
|
|
3104
3381
|
// /portal/admin/observed — who has actually USED this instance lately, from the audit log.
|
|
3105
3382
|
//
|
|
3106
3383
|
// ALWAYS 200, even when the log is missing or unreadable, with an `available` boolean — the
|
|
@@ -3761,9 +4038,39 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
3761
4038
|
// `extra` exists so a response can carry a CSP. Before this the content-type was hardcoded and there
|
|
3762
4039
|
// was no way to attach one — which is why the portal shipped without a policy rather than with a
|
|
3763
4040
|
// permissive one.
|
|
4041
|
+
// ── EVERY JSON RESPONSE SAYS HOW IT MAY BE CACHED, because leaving it unsaid is the defect ────────
|
|
4042
|
+
//
|
|
4043
|
+
// This writer used to send `content-type` and `content-length` and nothing else — no `Cache-Control`,
|
|
4044
|
+
// no `Vary`, no validator — so what a browser did with a signed-in response carrying another
|
|
4045
|
+
// company's material was the browser's decision and not ours. With no `Last-Modified` to work from a
|
|
4046
|
+
// heuristic cache may well store nothing; the defect is not a demonstrated leak, it is that the answer
|
|
4047
|
+
// was never stated for a response behind a session.
|
|
4048
|
+
//
|
|
4049
|
+
// THE ARGUMENT WAS ALREADY MADE HERE, FOR ONE ROUTE. `/portal/api/connect-key` has always set
|
|
4050
|
+
// `no-store` because "a credential sitting in a proxy or a disk cache is the 'outlives the moment'
|
|
4051
|
+
// failure ... arriving by a route the page cannot see". An access list is not a credential and has the
|
|
4052
|
+
// same property, so the rule belongs to the class rather than to the one response that carried a
|
|
4053
|
+
// token.
|
|
4054
|
+
//
|
|
4055
|
+
// `Vary: Accept` IS NOT ABOUT PRIVACY. `/portal/admin/*` is one address served two ways: the app
|
|
4056
|
+
// fetches JSON there, and a browser navigation gets the app document instead (portal-static.mjs
|
|
4057
|
+
// decides on `Accept`, above this router). Nothing told a cache those were different responses, so a
|
|
4058
|
+
// stored JSON body could answer a later navigation and render raw data in a window — with this server
|
|
4059
|
+
// never asked, which is why the negotiation above could not save it.
|
|
4060
|
+
//
|
|
4061
|
+
// A ROUTE'S OWN HEADERS STILL WIN. `extra` is spread last, so connect-key keeps its stricter set
|
|
4062
|
+
// (`no-cache, must-revalidate, private` and the HTTP/1.0 `pragma`) rather than being flattened to this
|
|
4063
|
+
// default. The arm below pins that, because a default that quietly relaxed a stricter route would be
|
|
4064
|
+
// this change making things worse while reading as an improvement.
|
|
3764
4065
|
const send = (res, status, obj, extra = {}) => {
|
|
3765
4066
|
const b = JSON.stringify(obj);
|
|
3766
|
-
res.writeHead(status, {
|
|
4067
|
+
res.writeHead(status, {
|
|
4068
|
+
"content-type": "application/json",
|
|
4069
|
+
"content-length": Buffer.byteLength(b),
|
|
4070
|
+
"cache-control": "no-store",
|
|
4071
|
+
"vary": "accept",
|
|
4072
|
+
...extra,
|
|
4073
|
+
});
|
|
3767
4074
|
res.end(b);
|
|
3768
4075
|
};
|
|
3769
4076
|
return async function handler(req, res) {
|
|
@@ -4302,6 +4609,18 @@ const PORT = PORT_CHOICE.port;
|
|
|
4302
4609
|
atomicWrite(envFrom(process.env, "CLEAROTRON_ACCESS_FILE"), `${JSON.stringify(g, null, 2)}\n`);
|
|
4303
4610
|
};
|
|
4304
4611
|
|
|
4612
|
+
// Revoking a removed person's connector keys, from the same module `clearotron disconnect` revokes
|
|
4613
|
+
// through — one author for what revoking means, and one place the ordering rule lives: the id reaches
|
|
4614
|
+
// the denylist before its record is struck, because a record removed first leaves a live key with no
|
|
4615
|
+
// trace it ever existed.
|
|
4616
|
+
//
|
|
4617
|
+
// IT NEVER NAMES THE LIST IN `.env`. `applyDisablePlan` will do that when a plan is `lateArm` — the
|
|
4618
|
+
// door was started without a revocation list, so the running process never loaded one — and that step
|
|
4619
|
+
// belongs to an operator at a terminal, not to a web request rewriting the install's environment. So
|
|
4620
|
+
// a lateArm plan is NOT applied: nothing is written, and the answer says the keys stay live until they
|
|
4621
|
+
// expire. Striking the records instead would delete the only record of a working key.
|
|
4622
|
+
const revokeConnectorKeys = makeConnectorKeyRevoker({ env: process.env });
|
|
4623
|
+
|
|
4305
4624
|
const { config } = await import("./driver.config.mjs");
|
|
4306
4625
|
const { appendFileSync: append } = await import("node:fs");
|
|
4307
4626
|
const auditPath = process.env.PORTAL_AUDIT || join(HERE, "..", "portal-audit.log");
|
|
@@ -4805,7 +5124,7 @@ const PORT = PORT_CHOICE.port;
|
|
|
4805
5124
|
const service = makePortalService({ poolRoot: config.poolRoot, workspaceRoot: config.workspaceRoot,
|
|
4806
5125
|
// Re-read per request (a getter that rescans), so a workspace created after boot is counted.
|
|
4807
5126
|
queueDirs: () => config.queueDirs,
|
|
4808
|
-
secret, grants, localSignIn: LOCAL_MODE, writeGrants, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
|
|
5127
|
+
secret, grants, localSignIn: LOCAL_MODE, writeGrants, revokeConnectorKeys, trigger, stopRun, audit, auditPath, upstream, composeRead, stopControl,
|
|
4809
5128
|
// — the ONLY place the environment is read for this. `bin/start.mjs` is the
|
|
4810
5129
|
// only thing that sets it, and it sets it explicitly rather than passing the operator's inherited
|
|
4811
5130
|
// environment through, so a stray `.env` can neither put a live install into demo mode nor take a
|
|
@@ -32,7 +32,6 @@ import { registrationSystem } from '../jurisdiction-systems.mjs';
|
|
|
32
32
|
import { READ_LEAD_RE } from '../report-card-record.mjs'; // D3 — the dedupe gate and the card's acceptance are ONE predicate
|
|
33
33
|
import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture } from '../../shared/brand.mjs';
|
|
34
34
|
import { NAV_CSS } from '../../shared/site-nav.mjs';
|
|
35
|
-
import { mintToken } from '../../shared/scope.mjs'; // mint a scoped read-only USER token for "Ask your AI" (dep-free; no-op fallback when no secret)
|
|
36
35
|
import { isEntrypoint } from "../../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
|
|
37
36
|
|
|
38
37
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1745,32 +1744,6 @@ function method(_text) {
|
|
|
1745
1744
|
// lawyer signs. The slim internal toolbar that survived it existed ONLY to host the Flag/Etch
|
|
1746
1745
|
// controls and rendered nothing without them, so took it with them.
|
|
1747
1746
|
|
|
1748
|
-
// "Ask your AI" launcher (§2.7) — its own collapsible banner under the verdict (a native <details>, default
|
|
1749
|
-
// collapsed, no-print). COPY-FIRST, bring-your-own-AI. Same gating as before: rendered only when a mcpUrl is
|
|
1750
|
-
// present (internal = shared read-only MCP; client export = OMITTED unless a scoped mcpUrl is given).
|
|
1751
|
-
function askAi(fm, { mcpUrl, runId }) {
|
|
1752
|
-
const mark = String(fm.title || fm.matter || 'this mark').trim();
|
|
1753
|
-
const prompt = runId ? `Brief me on trademark clearance run ${runId}.` : `Brief me on the ${mark} trademark clearance.`;
|
|
1754
|
-
const pa = escAttr(prompt);
|
|
1755
|
-
const steps = (label, items) => `<details class="askai-steps"><summary>${label}</summary><ol>${items.map(i => `<li>${i}</li>`).join('')}</ol></details>`;
|
|
1756
|
-
return `<details class="askband no-print">
|
|
1757
|
-
<summary>
|
|
1758
|
-
<div class="askband-ic" aria-hidden="true">💬</div>
|
|
1759
|
-
<div class="askband-main">
|
|
1760
|
-
<div class="askband-title">Ask your AI about this run <span class="askband-exp" aria-hidden="true">▾</span></div>
|
|
1761
|
-
<div class="askband-sub">Connect once and interrogate the findings in the Claude or ChatGPT you already use — read-only.</div>
|
|
1762
|
-
</div>
|
|
1763
|
-
</summary>
|
|
1764
|
-
<div class="askband-body">
|
|
1765
|
-
<button type="button" class="util primary askai-copy" data-copy="${pa}">📋 Copy question</button>
|
|
1766
|
-
<p class="askai-hint">Paste into the Claude or ChatGPT you already use — read-only. New chat: <a href="https://claude.ai/new" target="_blank" rel="noopener">Claude →</a> · <a href="https://chatgpt.com/" target="_blank" rel="noopener">ChatGPT →</a></p>
|
|
1767
|
-
<div class="askai-field"><code class="askai-url">${esc(mcpUrl)}</code><button type="button" class="util askai-copy" data-copy="${escAttr(mcpUrl)}">Copy</button></div>
|
|
1768
|
-
${steps('Set up Claude <span class="askai-checked">· ✓ Checked 4 September 2026</span>', ['Settings → Connectors → Add custom connector', 'Paste the address above — it already carries your key', 'Set Authentication to None', 'Add. If it warns that authentication is required, that is its own guess — None is correct here'])}
|
|
1769
|
-
${steps('Set up ChatGPT', ['Settings → Connectors → Advanced → Developer mode', 'Add MCP server, paste the address above', `Sign in when the browser opens (${BRAND.name} email)`])}
|
|
1770
|
-
<p class="askai-hint askai-note">These steps name no button we have not opened ourselves. Your app may word them differently.</p>
|
|
1771
|
-
</div>
|
|
1772
|
-
</details>`;
|
|
1773
|
-
}
|
|
1774
1747
|
|
|
1775
1748
|
// Brand :root tokens are sourced from shared/brand.mjs (REPORT_ROOT) and prepended to the
|
|
1776
1749
|
// report stylesheet, so report.css carries only rules — the palette lives in ONE place across surfaces.
|
|
@@ -1808,7 +1781,6 @@ window.addEventListener('hashchange',_cardHashGo);
|
|
|
1808
1781
|
window.addEventListener('load',_cardHashGo);
|
|
1809
1782
|
window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.dataset.o=d.open?'1':'';d.open=true;});});
|
|
1810
1783
|
window.addEventListener('afterprint',function(){document.querySelectorAll('details').forEach(function(d){d.open=d.dataset.o==='1';});_hidden.forEach(function(c){c.classList.remove('print-hidden');});_hidden=[];});
|
|
1811
|
-
document.addEventListener('click',function(e){var cp=e.target.closest('.askai-copy');if(cp){navigator.clipboard&&navigator.clipboard.writeText(cp.getAttribute('data-copy'));}});
|
|
1812
1784
|
document.addEventListener('click',function(e){var t=e.target.closest('.tb-exp-toggle'),pop=document.querySelector('.tb-exp-pop');if(t){if(pop){pop.hidden=!pop.hidden;t.setAttribute('aria-expanded',String(!pop.hidden));}return;}if(pop&&!pop.hidden&&!e.target.closest('.tb-exp-pop')){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}});
|
|
1813
1785
|
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=document.querySelector('.tb-exp-pop');if(pop&&!pop.hidden){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}}});`;
|
|
1814
1786
|
|
|
@@ -2154,21 +2126,6 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
|
|
|
2154
2126
|
|
|
2155
2127
|
// Excel/audit download — lives inside the topbar Export popover (portal-report strips the link at serve time for non-staff).
|
|
2156
2128
|
const excelBtn2 = !opts.auditFile ? '' : `<a class="util" href="${esc(opts.auditFile)}" download>⬇ Download full audit (Excel)</a>`;
|
|
2157
|
-
// "Ask your AI" target. Explicit opts.mcpUrl wins. Otherwise the staff surface (CLEAROTRON_MCP_URL), with
|
|
2158
|
-
// a run-scoped read-only token embedded when a secret is configured. ONE report: the document always
|
|
2159
|
-
// carries the staff connector; portal-report.mjs strips the whole block (and redacts any surviving MCP
|
|
2160
|
-
// host) for non-staff readers at serve time — the client's connector path is the portal's own
|
|
2161
|
-
// /portal/api/mcp-access, which needs no baked credential. FAIL CLOSED on a missing env: NO placeholder
|
|
2162
|
-
// host — a forgotten env used to render a mcp.example.com connector that looked configured and resolved
|
|
2163
|
-
// nowhere.
|
|
2164
|
-
let mcpUrl = opts.mcpUrl || '';
|
|
2165
|
-
if (!mcpUrl) {
|
|
2166
|
-
const base = process.env.CLEAROTRON_MCP_URL || '';
|
|
2167
|
-
let tok = '';
|
|
2168
|
-
if (base && opts.runId) { try { tok = mintToken({ scope: 'user', runId: opts.runId }); } catch { tok = ''; } }
|
|
2169
|
-
mcpUrl = base ? (tok ? `${base}?token=${tok}` : base) : '';
|
|
2170
|
-
}
|
|
2171
|
-
const askAiHtml = mcpUrl ? askAi(fm, { mcpUrl, runId: opts.runId }) : '';
|
|
2172
2129
|
|
|
2173
2130
|
const cardFor = f => matchCard(f, cards);
|
|
2174
2131
|
const recordsByUri = opts.recordsByUri || new Map(); // Instance #6 — the run's _records/ set (publishReport loads it)
|
|
@@ -2372,8 +2329,6 @@ ${opts.nav || ''}
|
|
|
2372
2329
|
findings.some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
|
|
2373
2330
|
&& (recordsByUri.size > 0 || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length))))))}
|
|
2374
2331
|
|
|
2375
|
-
${askAiHtml}
|
|
2376
|
-
|
|
2377
2332
|
<footer>
|
|
2378
2333
|
<span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
|
|
2379
2334
|
// TWO SENTENCES, and that count is the ruled shape rather than a consequence of trimming.
|
|
@@ -214,22 +214,6 @@
|
|
|
214
214
|
details.method[open]>summary::after{content:"▴"}
|
|
215
215
|
.method .mbody{padding:0 22px 20px;font-size:13px;color:var(--slate);line-height:1.62}
|
|
216
216
|
|
|
217
|
-
/* Ask-your-AI launcher */
|
|
218
|
-
.askai{position:relative;display:inline-block}
|
|
219
|
-
.askai-pop{position:absolute;z-index:30;top:calc(100% + 8px);left:0;width:340px;max-width:88vw;background:var(--card);
|
|
220
|
-
border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);padding:14px}
|
|
221
|
-
.askai-hint{font-size:11.5px;color:var(--slate);margin:9px 0 8px;line-height:1.45}
|
|
222
|
-
.askai-field{display:flex;gap:6px;align-items:center;background:var(--bg2);border-radius:9px;padding:6px 8px;margin-bottom:8px}
|
|
223
|
-
.askai-url{font-family:var(--mono);font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
224
|
-
.askai-steps>summary{cursor:pointer;font-size:11.5px;font-weight:700;color:var(--crimson);padding:4px 0;list-style:none}
|
|
225
|
-
.askai-steps>summary::-webkit-details-marker{display:none}
|
|
226
|
-
.askai-steps ol{margin:4px 0 8px;padding-left:18px;font-size:11.5px;color:var(--slate)}
|
|
227
|
-
/* The dated stamp on a set-up recipe we have actually driven. Muted on purpose: the recipe's own
|
|
228
|
-
summary is crimson and bold, and a stamp that shouted as loudly would read as part of the step. */
|
|
229
|
-
.askai-steps>summary .askai-checked{font-weight:600;color:var(--slate)}
|
|
230
|
-
/* The note under BOTH recipes. `.askband-body .askai-hint` zeroes the top margin for the hint that
|
|
231
|
-
opens the body; this one closes it and needs the gap back. */
|
|
232
|
-
details.askband .askband-body .askai-note{margin-top:10px}
|
|
233
217
|
|
|
234
218
|
footer{margin-top:54px;padding-top:20px;border-top:2px solid var(--ink);font-size:12px;color:var(--faint);line-height:1.6;display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
|
235
219
|
footer .wm{font-size:13px}
|
|
@@ -282,19 +266,6 @@
|
|
|
282
266
|
.rv-tag{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#a06aa3}
|
|
283
267
|
.rv-spacer{flex:1;min-width:16px}
|
|
284
268
|
|
|
285
|
-
/* Ask-AI banner (collapsible) */
|
|
286
|
-
details.askband{display:block;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--crimson);border-radius:14px;padding:16px 18px;margin:20px 0 0;box-shadow:var(--shadow)}
|
|
287
|
-
details.askband>summary{cursor:pointer;list-style:none;display:flex;gap:14px;align-items:flex-start}
|
|
288
|
-
details.askband>summary::-webkit-details-marker{display:none}
|
|
289
|
-
.askband-ic{flex:none;width:38px;height:38px;border-radius:11px;background:var(--high-soft);color:var(--high-tx);display:flex;align-items:center;justify-content:center;font-size:18px}
|
|
290
|
-
.askband-main{flex:1;min-width:0}
|
|
291
|
-
.askband-title{font-weight:900;font-size:16px;letter-spacing:-.01em;margin:0 0 3px}
|
|
292
|
-
.askband-sub{font-size:13px;color:var(--slate);line-height:1.5;margin:0}
|
|
293
|
-
.askband-exp{display:inline-block;transition:.15s;color:var(--faint);font-size:12px;margin-left:4px}
|
|
294
|
-
details.askband[open] .askband-exp{transform:rotate(180deg)}
|
|
295
|
-
details.askband .askband-body{margin-top:12px;padding-left:52px;max-width:560px}
|
|
296
|
-
details.askband .askband-body .askai-hint{margin-top:0}
|
|
297
|
-
@media(max-width:560px){details.askband .askband-body{padding-left:0}}
|
|
298
269
|
|
|
299
270
|
/* cross-region rights-holder rows (.rrow) + secondary region groups (.rgroup) */
|
|
300
271
|
.keypanel{overflow-y:auto}
|
|
@@ -334,7 +305,7 @@
|
|
|
334
305
|
@media (min-width:760px){.rn-why{flex:1 1 46%;min-width:280px}}
|
|
335
306
|
|
|
336
307
|
@media print{
|
|
337
|
-
.topbar,.no-print,.review,.
|
|
308
|
+
.topbar,.no-print,.review,.int-note{display:none!important}
|
|
338
309
|
body{background:#fff}
|
|
339
310
|
.panel,.card,.land,.firstUp,.method{box-shadow:none}
|
|
340
311
|
/* Only PURE TOGGLES hide their summary line in print — the drill toggles ("The read" / "Full detail
|
|
@@ -16,7 +16,8 @@ there is no other value: a band that needs sharpening is the band above it, stat
|
|
|
16
16
|
## Per mark — the mandatory sequence
|
|
17
17
|
|
|
18
18
|
1. **Context framing FIRST** (from the plan; correct it only if plainly wrong): coined vs common
|
|
19
|
-
phrase vs cultural echo
|
|
19
|
+
phrase vs cultural echo. It frames what the name READS as, and the evidence bullets draw on it.
|
|
20
|
+
The band does not: no framing raises or lowers a rating (calibration rule 3, retired).
|
|
20
21
|
2. **Parody / evocation check**: does the name echo a famous mark or property even without identical
|
|
21
22
|
ownership ("Free Range 1s" echoes "Air Force 1s")? Flag it even when nobody owns the echoed form.
|
|
22
23
|
3. **Band per the framework ladder**, applying the calibration rules below.
|
|
@@ -58,11 +59,21 @@ there is no other value: a band that needs sharpening is the band above it, stat
|
|
|
58
59
|
read." It was the only instruction in this seat's context licensing that move, and the readiest
|
|
59
60
|
explanation for a Disney hit rating Very High and an EA hit rating High against a lawyer's Medium and
|
|
60
61
|
Manageable. The rest of the rule stands and is doctrine.)*
|
|
61
|
-
3. **
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
3. **RETIRED — how ordinary the words are is a note for the reviewing lawyer, never a band.**
|
|
63
|
+
This forbade the lowest band for a common English phrase and put a floor a band above it. On a
|
|
64
|
+
scale that ends in Low it kept an everyday name off "no issues found"; on a client scale with no
|
|
65
|
+
Low band the floor landed one band higher still, and three names went out rated above every
|
|
66
|
+
conflict card on their own pages — twelve cards, all at the bottom band, under three names a band
|
|
67
|
+
above them, with nothing on the page to explain it. The reviewing lawyer rated the cards right and
|
|
68
|
+
the names wrong.
|
|
69
|
+
**Removed for every client, not only where it misfired** (owner, 2026-09-15). Keeping it on scales
|
|
70
|
+
that have a Low band was considered and rejected; so was explaining the gap on the report, because
|
|
71
|
+
a report that explains a wrong rating is still wrong.
|
|
72
|
+
Say what the words are in the reading — coined, compound, everyday phrase, cultural echo — and let
|
|
73
|
+
it inform the evidence bullets. It does not set, floor or lift a band. A risk that belongs to the
|
|
74
|
+
NAME rather than to one user is written as its own `findings[]` record and rated there; Famous
|
|
75
|
+
Brand and Cultural Reference are already card types for exactly that.
|
|
76
|
+
*The number is kept and not reused*, for the reason rule 4 gives below.
|
|
66
77
|
4. **RETIRED — the caveat is conditional now, and it is not this file's to state.**
|
|
67
78
|
This rule ordered the pending-register caveat on *every* summary, unconditionally. When the register
|
|
68
79
|
ran and surfaced live filings, that sentence tells a client its ratings are common-law only while the
|
|
@@ -77,6 +88,24 @@ there is no other value: a band that needs sharpening is the band above it, stat
|
|
|
77
88
|
at its "(low)" qualifier.
|
|
78
89
|
7. **Client's prior use mitigates, doesn't eliminate.** Rate the full external landscape first; note
|
|
79
90
|
the mitigation separately in a purple bullet.
|
|
91
|
+
8. **A NAME IS NEVER RATED ABOVE THE WORST CARD ON ITS PAGE.** The page is one judgment: the name's
|
|
92
|
+
band, its basis line, its Why and Why-not boxes and its opening read all have to agree with each
|
|
93
|
+
other and with the cards under them. A reader who sees a band on the name and a milder band on
|
|
94
|
+
every card beneath it has been given two answers and no way to choose.
|
|
95
|
+
**Cards means every rated item on this name's page** — each `findings[]` record, and each
|
|
96
|
+
`registerReads[]` row that carries a band. A row with no band is not a card and does not count.
|
|
97
|
+
**A name with no rated cards takes the bottom band of the client's own ladder.** Nothing was found
|
|
98
|
+
against it; that is what the bottom band is for. It is not a reason to reach up a band.
|
|
99
|
+
**Rating BELOW the worst card is allowed and needs a sentence**, because it is a real judgment:
|
|
100
|
+
the worst card's holder may touch only the edge of the request. Say which card and why it does not
|
|
101
|
+
carry the name.
|
|
102
|
+
**Anything that should lift a name is a card of its own** — a famous-mark echo, a cultural
|
|
103
|
+
reading, a risk that belongs to the NAME rather than to one user. Write it as a `findings[]`
|
|
104
|
+
record and rate it there, where a reader can see what it is and disagree with it. A band lifted
|
|
105
|
+
without a card is a conclusion with its evidence left out.
|
|
106
|
+
The driver checks this and REFUSES, naming the card it read: it never rewrites your band. A
|
|
107
|
+
corrected band you reasoned is the product; a band a script lowered under prose that still argues
|
|
108
|
+
for the old one is worse than the defect it replaced.
|
|
80
109
|
|
|
81
110
|
## Degraded marks (research unavailable or null — never inflate)
|
|
82
111
|
|