clearotron 0.3.2-beta.4 → 0.3.2-beta.5
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 -0
- package/bin/onboard.mjs +65 -0
- package/bin/start.mjs +14 -0
- package/build-info.json +2 -2
- package/demo/knockout-search/run/_driver/predelivery-lint.json +26 -2
- package/driver/CHANGELOG.md +6 -0
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +4 -2
- package/driver/publish/render-knockout.mjs +231 -98
- package/driver/publish/render.mjs +399 -152
- package/driver/publish/templates/report.css +103 -3
- package/driver/registry-fidelity.mjs +28 -0
- package/driver/suite-census.json +17 -17
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/audit.mjs +14 -2
- package/mcp-server/lib/http-handler.mjs +52 -13
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +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/shared/driver-dir.mjs +1 -1
|
@@ -313,8 +313,13 @@
|
|
|
313
313
|
rows (.rrow), secondary groups (.rgroup), the scope section title (details.scope) — print as static
|
|
314
314
|
headers; the old blanket details>summary hide deleted every region/group header and the scope title
|
|
315
315
|
from the exported PDF.
|
|
316
|
-
EVERY <details> THE RENDERERS EMIT MUST BE IN ONE LIST OR THE OTHER
|
|
317
|
-
a
|
|
316
|
+
EVERY <details> THE RENDERERS EMIT MUST BE IN ONE LIST OR THE OTHER. The instrument that says so
|
|
317
|
+
is a browser under print media, and it is NOT in this repository: `render-css.test.mjs`, named
|
|
318
|
+
here until 2026-09-16, exists in no tree. Nothing in the public suite can see this — the frozen
|
|
319
|
+
hash pins the renderer's bytes and the render arms grep its HTML, and on screen a mis-ruled
|
|
320
|
+
disclosure is correct. It is the private print control that walks a rendered page on both lanes
|
|
321
|
+
and refuses a disclosure ruled by neither mechanism, and it runs by hand. Two folds have now
|
|
322
|
+
shipped mis-ruled and been caught there rather than in CI. `details.sub-more` (#470) was in NEITHER, so the exported PDF — the
|
|
318
323
|
client-facing legal document — printed an inert "MORE" label and a right-pointing COLLAPSED marker
|
|
319
324
|
in the hero, directly above text that was already fully expanded. Measured in Chrome: the caption's
|
|
320
325
|
own content was carried correctly and only the control leaked, which is why the commit that added
|
|
@@ -327,7 +332,14 @@
|
|
|
327
332
|
and internal notes (.int-note, hidden above) are the only two things print ever drops. */
|
|
328
333
|
details>*:not(summary){display:block!important}
|
|
329
334
|
details>summary{list-style:none;cursor:default}
|
|
330
|
-
.
|
|
335
|
+
/* `details.searched` is a SECTION TITLE, so it keeps its summary in print like the region rows and
|
|
336
|
+
the secondary groups — a printed report that drops "What was searched" loses the heading over the
|
|
337
|
+
counts, not just a control. What it must not print is the ▸/▾ toggle, which on paper is a
|
|
338
|
+
right-pointing arrow over content that is already fully expanded. Found by the print check, which
|
|
339
|
+
walks a rendered page and refuses any disclosure that is in neither list; the public suite does
|
|
340
|
+
not run it, so the commit that added this fold could ship the half it did not think about. */
|
|
341
|
+
.rrow>summary::before,.rgroup>summary::before,details.scope>summary::before,
|
|
342
|
+
details.searched>summary::before{content:""!important}
|
|
331
343
|
details{border-top:1px solid var(--line)}
|
|
332
344
|
/* …AND THE HERO'S FOLD IS NOT A SECTION. The rule above separates the disclosures that ARE sections
|
|
333
345
|
of the document; `details.sub-more` is one paragraph of the verdict caption folded away from
|
|
@@ -404,3 +416,91 @@
|
|
|
404
416
|
.panel.actions.scope-ran{margin-top:22px}
|
|
405
417
|
.panel.actions.scope-ran h4{margin:0 0 6px;font-size:14px;letter-spacing:-.01em;
|
|
406
418
|
display:flex;align-items:center;gap:9px}
|
|
419
|
+
|
|
420
|
+
/* ── The 2026-09-16 report redesign — the approved report presentation ─────────────────────────────────────────
|
|
421
|
+
Ported from the design's own stylesheet, with its literal colours mapped onto this sheet's tokens
|
|
422
|
+
so the page follows the theme rather than carrying a second palette. Layout only: every string on
|
|
423
|
+
the page is the delivered file's own, a label from the specification, or a count. */
|
|
424
|
+
.panel.about{margin:10px 0 18px;padding:12px 18px;max-width:900px}
|
|
425
|
+
.panel.about .label{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
|
|
426
|
+
.panel.about .row{display:grid;grid-template-columns:170px 1fr;gap:12px;padding:5px 0;border-top:1px solid var(--line);font-size:13.5px;line-height:1.5}
|
|
427
|
+
/* The goods as registered and the record's own dates, inside a finding's Full detail fold on a full
|
|
428
|
+
country search (the 2026-09-16 report redesign). Ported from the design with its fixed greys replaced by tokens.
|
|
429
|
+
`word-break` is load-bearing and not cosmetic: the goods are the register's own wording, which on a
|
|
430
|
+
Japanese or Korean record is one unbroken run of characters with no spaces to wrap at, and without
|
|
431
|
+
it that row sets the width of the whole fold. */
|
|
432
|
+
.record{margin:6px 0 2px}
|
|
433
|
+
.record .row{display:grid;grid-template-columns:150px 1fr;gap:12px;padding:4px 0;border-top:1px solid var(--line);font-size:13px;line-height:1.5}
|
|
434
|
+
.record .k{font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
435
|
+
.record .v{overflow-wrap:anywhere;word-break:break-word}
|
|
436
|
+
@media(max-width:700px){.record .row{grid-template-columns:1fr;gap:2px}}
|
|
437
|
+
.panel.about .row:first-of-type{border-top:0}
|
|
438
|
+
.panel.about .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
439
|
+
.panel.about .v{color:var(--ink)}
|
|
440
|
+
header.hero p.sub .fmark{font-weight:800;color:var(--ink)}
|
|
441
|
+
|
|
442
|
+
.heroGrid{display:block}.panel.gauge{max-width:none}
|
|
443
|
+
.gconc .grow{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start}
|
|
444
|
+
.gconc .gv{text-align:left;display:block}
|
|
445
|
+
.gconds-wrap{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start;margin-top:10px}
|
|
446
|
+
.gconds-wrap .gk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
447
|
+
.gconds{margin:0;padding-left:18px;font-size:13px;line-height:1.45;font-weight:500}
|
|
448
|
+
.gconds li{margin:3px 0}
|
|
449
|
+
.gwhy{border-top:1px solid var(--line);margin:12px 0 0;padding:12px 0 0}
|
|
450
|
+
.gwhy .label{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:8px}
|
|
451
|
+
.gwhy .fa-row{display:grid;grid-template-columns:150px 1fr;gap:14px;align-items:start;padding:7px 0;font-size:13.5px;line-height:1.5}
|
|
452
|
+
.gwhy .fa-k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
453
|
+
.fa-token{padding:2px 8px;border-radius:999px;color:#fff;font-weight:700}
|
|
454
|
+
.fa-token.tone-good{background:var(--clear)}.fa-token.tone-mid{background:var(--med)}.fa-token.tone-bad{background:var(--high)}
|
|
455
|
+
.fa-based{margin:4px 0 2px;font-size:12.5px;line-height:1.45;color:var(--slate)}
|
|
456
|
+
.fa-bk{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-right:6px}
|
|
457
|
+
.gexp,.gconf{display:grid;grid-template-columns:150px 1fr;gap:14px;border-top:1px solid var(--line);margin-top:12px;padding-top:10px;font-size:13px;line-height:1.5}
|
|
458
|
+
.gexp .gk,.gconf .gk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
459
|
+
.gexp .gv,.gconf .gv{text-align:left}
|
|
460
|
+
.gframe{margin-top:10px;font-size:11.5px;color:var(--faint)}
|
|
461
|
+
|
|
462
|
+
.panel.where{padding:10px 18px}
|
|
463
|
+
.wrow{display:grid;grid-template-columns:44px 1fr auto;gap:14px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line);font-size:13.5px}
|
|
464
|
+
.wrow .wname{font-weight:600}.wrow .rcode{font-weight:700;color:var(--faint);font-size:12px}
|
|
465
|
+
.wclean{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:10px 0 4px}
|
|
466
|
+
.wk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--clear);margin-right:8px}
|
|
467
|
+
.wchip{padding:3px 9px;border-radius:999px;background:var(--clear-soft);color:var(--clear-tx);font:600 12px/1.4 var(--font)}
|
|
468
|
+
.panel.courtp{padding:14px 18px}.panel.courtp p{margin:0;font-size:14px;line-height:1.5}
|
|
469
|
+
|
|
470
|
+
.ask-fi{margin-left:auto;font:600 12px/1 var(--font);padding:7px 10px;border-radius:999px;border:1px solid var(--line2);background:transparent;color:var(--ink);cursor:pointer;white-space:nowrap}
|
|
471
|
+
.tb-ask{margin-right:8px}
|
|
472
|
+
.cardhead .who.mark-first{font-size:17px;font-weight:800}
|
|
473
|
+
.cardhead .who .where{font-weight:500;color:var(--slate);font-size:14px}
|
|
474
|
+
.owner-line{padding:0 16px 6px 16px;margin-top:-4px;font-size:13px;color:var(--slate)}
|
|
475
|
+
.owner-line .ok{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-right:6px}
|
|
476
|
+
|
|
477
|
+
.panel.alsocons{padding:12px 18px}
|
|
478
|
+
.card.ruled .rail{background:var(--tan)}.tier.ruled{background:var(--bg2);color:var(--slate)}
|
|
479
|
+
.ruled-cards .card{margin-bottom:10px}
|
|
480
|
+
.card.ruled .roff{margin:6px 16px 12px;font-size:13px}
|
|
481
|
+
.card.ruled .roff summary{cursor:pointer;color:var(--slate);font-size:12px;letter-spacing:.06em;text-transform:uppercase}
|
|
482
|
+
.card.ruled .roff p{margin:6px 0;line-height:1.5}
|
|
483
|
+
.cgroups{margin:8px 0 0}
|
|
484
|
+
.cgroup{margin:6px 0;background:var(--card);border-radius:12px;box-shadow:0 1px 2px rgba(37,9,2,.06)}
|
|
485
|
+
.cgroup>summary{display:flex;align-items:center;gap:10px;padding:12px 18px;cursor:pointer;font:600 14px/1.3 var(--font);list-style:none}
|
|
486
|
+
.cgroup>summary::before{content:'▸';color:var(--faint);font-size:12px}
|
|
487
|
+
.cgroup[open]>summary::before{content:'▾'}
|
|
488
|
+
.cgroup .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
|
|
489
|
+
.cgroup .gbody{padding:0 18px 12px}
|
|
490
|
+
.crow{display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:start;padding:8px 0;border-top:1px solid var(--line);font-size:13px}
|
|
491
|
+
.crow .cm-mark{font-weight:700}.crow .cwho{color:var(--slate);line-height:1.45}
|
|
492
|
+
.crow .curl{color:var(--crimson);text-decoration:none;font-size:12.5px;word-break:break-all}
|
|
493
|
+
.cmore{padding:10px 0 2px;font-size:12.5px}.wb{color:var(--crimson);text-decoration:none;font-weight:600}
|
|
494
|
+
|
|
495
|
+
details.searched{margin:14px 0;background:var(--card);border-radius:12px;box-shadow:var(--shadow)}
|
|
496
|
+
details.searched>summary{display:flex;align-items:center;gap:10px;padding:12px 18px;cursor:pointer;font:600 14px/1.3 var(--font);list-style:none}
|
|
497
|
+
details.searched>summary::before{content:'▸';color:var(--faint);font-size:12px}
|
|
498
|
+
details.searched[open]>summary::before{content:'▾'}
|
|
499
|
+
details.searched .gcount{margin-left:auto;color:var(--slate);font-weight:500;font-size:12.5px}
|
|
500
|
+
details.searched .gbody{padding:0 18px 12px}
|
|
501
|
+
details.searched .row{display:grid;grid-template-columns:200px 1fr;gap:12px;padding:7px 0;border-top:1px solid var(--line);font-size:13.5px}
|
|
502
|
+
details.searched .k{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);padding-top:3px}
|
|
503
|
+
.openrows,.provwrap{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
|
|
504
|
+
.openrows .rk,.provwrap .rk{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
|
|
505
|
+
.provnote{margin:0;font-size:12.5px;line-height:1.5;color:var(--slate)}
|
|
506
|
+
.topbar .tb-lockup{margin-right:10px;flex:0 0 auto}
|
|
@@ -478,6 +478,34 @@ export const REC = {
|
|
|
478
478
|
// designated countries; never imply "international = global". Corsearch bodies carry the
|
|
479
479
|
// per-designated-country statuses as `onomaticsJurisdictionsStatuses` (array of strings or of
|
|
480
480
|
// {jurisdiction,status} objects — fail-open on either shape; null when the record lacks it).
|
|
481
|
+
// The 2026-09-16 report redesign — the goods the mark is REGISTERED for, in the register's own words. A finding
|
|
482
|
+
// argues about goods, and until now the only goods on the page were the class numbers; the record
|
|
483
|
+
// carried the wording all along. NOTHING IS TRANSLATED HERE. Where a record holds an English entry
|
|
484
|
+
// the display picks it (the register wrote it); where it holds only the local language, the local
|
|
485
|
+
// language is what "as registered" means, and a translation composed at render time would be this
|
|
486
|
+
// renderer asserting the scope of somebody's right in words no register ever granted.
|
|
487
|
+
goods: (r) => {
|
|
488
|
+
const gs = Array.isArray(r.goodsServices) ? r.goodsServices : [];
|
|
489
|
+
const rows = gs.map((g) => ({
|
|
490
|
+
classes: (Array.isArray(g?.classes) ? g.classes : []).map(Number).filter((x) => !Number.isNaN(x)),
|
|
491
|
+
language: String(g?.language ?? "").trim().toLowerCase(),
|
|
492
|
+
description: String(g?.description ?? "").trim(),
|
|
493
|
+
})).filter((g) => g.description);
|
|
494
|
+
if (!rows.length) return null;
|
|
495
|
+
const en = rows.filter((g) => g.language === "en" || g.language === "eng");
|
|
496
|
+
return (en.length ? en : rows).map((g) => ({ classes: g.classes, description: g.description }));
|
|
497
|
+
},
|
|
498
|
+
// The three dates a reader acts on, as the record holds them and never re-derived. `filingYear` and
|
|
499
|
+
// `regYear` above answer a different question — the one-line summary — and a year cannot say whether
|
|
500
|
+
// a registration lapses this month.
|
|
501
|
+
dates: (r) => {
|
|
502
|
+
const out = [];
|
|
503
|
+
const add = (label, v) => { const s = String(v ?? "").trim(); if (/^\d{4}-\d{2}-\d{2}/.test(s)) out.push([label, s.slice(0, 10)]); };
|
|
504
|
+
add("filed", r.applicationDate);
|
|
505
|
+
add("registered", r.registrationDate);
|
|
506
|
+
add("expires", r.expiryDate ?? r.renewalDate);
|
|
507
|
+
return out.length ? out : null;
|
|
508
|
+
},
|
|
481
509
|
designations: (r) => {
|
|
482
510
|
const j = r.onomaticsJurisdictionsStatuses ?? r.jurisdictions ?? null;
|
|
483
511
|
if (!Array.isArray(j) || !j.length) return null;
|
package/driver/suite-census.json
CHANGED
|
@@ -1912,8 +1912,8 @@
|
|
|
1912
1912
|
"todos": 0
|
|
1913
1913
|
},
|
|
1914
1914
|
"doctor-reads-the-units-not-the-shell.test.mjs": {
|
|
1915
|
-
"tests":
|
|
1916
|
-
"asserts":
|
|
1915
|
+
"tests": 40,
|
|
1916
|
+
"asserts": 115,
|
|
1917
1917
|
"skips": 1,
|
|
1918
1918
|
"todos": 0
|
|
1919
1919
|
},
|
|
@@ -2669,7 +2669,7 @@
|
|
|
2669
2669
|
},
|
|
2670
2670
|
"knockout-structured-read.test.mjs": {
|
|
2671
2671
|
"tests": 11,
|
|
2672
|
-
"asserts":
|
|
2672
|
+
"asserts": 55,
|
|
2673
2673
|
"skips": 0,
|
|
2674
2674
|
"todos": 0
|
|
2675
2675
|
},
|
|
@@ -3059,7 +3059,7 @@
|
|
|
3059
3059
|
},
|
|
3060
3060
|
"pipeline.mock.test.mjs": {
|
|
3061
3061
|
"tests": 94,
|
|
3062
|
-
"asserts":
|
|
3062
|
+
"asserts": 795,
|
|
3063
3063
|
"skips": 0,
|
|
3064
3064
|
"todos": 0
|
|
3065
3065
|
},
|
|
@@ -3718,8 +3718,8 @@
|
|
|
3718
3718
|
"todos": 0
|
|
3719
3719
|
},
|
|
3720
3720
|
"render.test.mjs": {
|
|
3721
|
-
"tests":
|
|
3722
|
-
"asserts":
|
|
3721
|
+
"tests": 144,
|
|
3722
|
+
"asserts": 572,
|
|
3723
3723
|
"skips": 0,
|
|
3724
3724
|
"todos": 0
|
|
3725
3725
|
},
|
|
@@ -3802,8 +3802,8 @@
|
|
|
3802
3802
|
"todos": 0
|
|
3803
3803
|
},
|
|
3804
3804
|
"report-record-link-host.test.mjs": {
|
|
3805
|
-
"tests":
|
|
3806
|
-
"asserts":
|
|
3805
|
+
"tests": 4,
|
|
3806
|
+
"asserts": 28,
|
|
3807
3807
|
"skips": 0,
|
|
3808
3808
|
"todos": 0
|
|
3809
3809
|
},
|
|
@@ -4822,8 +4822,8 @@
|
|
|
4822
4822
|
"todos": 0
|
|
4823
4823
|
},
|
|
4824
4824
|
"the-knockout-page-leads-with-the-read.test.mjs": {
|
|
4825
|
-
"tests":
|
|
4826
|
-
"asserts":
|
|
4825
|
+
"tests": 30,
|
|
4826
|
+
"asserts": 81,
|
|
4827
4827
|
"skips": 0,
|
|
4828
4828
|
"todos": 0
|
|
4829
4829
|
},
|
|
@@ -5033,7 +5033,7 @@
|
|
|
5033
5033
|
},
|
|
5034
5034
|
"the-report-carries-what-the-assessment-wrote.test.mjs": {
|
|
5035
5035
|
"tests": 10,
|
|
5036
|
-
"asserts":
|
|
5036
|
+
"asserts": 39,
|
|
5037
5037
|
"skips": 0,
|
|
5038
5038
|
"todos": 0
|
|
5039
5039
|
},
|
|
@@ -5189,13 +5189,13 @@
|
|
|
5189
5189
|
},
|
|
5190
5190
|
"the-writing-standard-backlog-is-a-floor.test.mjs": {
|
|
5191
5191
|
"tests": 3,
|
|
5192
|
-
"asserts":
|
|
5192
|
+
"asserts": 9,
|
|
5193
5193
|
"skips": 2,
|
|
5194
5194
|
"todos": 0
|
|
5195
5195
|
},
|
|
5196
5196
|
"the-writing-standard-check-refuses-five-classes.test.mjs": {
|
|
5197
5197
|
"tests": 15,
|
|
5198
|
-
"asserts":
|
|
5198
|
+
"asserts": 61,
|
|
5199
5199
|
"skips": 0,
|
|
5200
5200
|
"todos": 0
|
|
5201
5201
|
},
|
|
@@ -5721,8 +5721,8 @@
|
|
|
5721
5721
|
"todos": 0
|
|
5722
5722
|
},
|
|
5723
5723
|
"http-handler.test.mjs": {
|
|
5724
|
-
"tests":
|
|
5725
|
-
"asserts":
|
|
5724
|
+
"tests": 17,
|
|
5725
|
+
"asserts": 40,
|
|
5726
5726
|
"skips": 0,
|
|
5727
5727
|
"todos": 0
|
|
5728
5728
|
},
|
|
@@ -5859,8 +5859,8 @@
|
|
|
5859
5859
|
"todos": 0
|
|
5860
5860
|
},
|
|
5861
5861
|
"the-portal-can-tell-who-has-connected-an-assistant.test.mjs": {
|
|
5862
|
-
"tests":
|
|
5863
|
-
"asserts":
|
|
5862
|
+
"tests": 12,
|
|
5863
|
+
"asserts": 33,
|
|
5864
5864
|
"skips": 0,
|
|
5865
5865
|
"todos": 0
|
|
5866
5866
|
},
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/lib/audit.mjs
CHANGED
|
@@ -40,14 +40,26 @@ export function summarize(body) {
|
|
|
40
40
|
return out;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function appendAudit({ email, sub, body, status, transport, path = DEFAULT_AUDIT_PATH }) {
|
|
43
|
+
export function appendAudit({ email, sub, body, status, transport, door, path = DEFAULT_AUDIT_PATH }) {
|
|
44
44
|
// `sub` = the inner-token PRINCIPAL (ops-token issuance, INSTALL.md §8) — distinguishes
|
|
45
45
|
// two automations sharing a transport identity. null for internal/user sessions without a sub claim.
|
|
46
46
|
//
|
|
47
47
|
// `transport` is written only where it changes an answer: "stdio" marks a call that arrived over the
|
|
48
48
|
// local route, which has no signed-in identity to record and therefore no email. Omitted on the HTTP
|
|
49
49
|
// doors, whose records are identified by the email they already carry.
|
|
50
|
-
|
|
50
|
+
//
|
|
51
|
+
// `door` NAMES WHICH SURFACE TOOK THE CALL, and it is a separate field from `transport` on purpose.
|
|
52
|
+
// `transport` answers "how did this arrive" and the reader tests it for exactly one value, `"stdio"`,
|
|
53
|
+
// to mean the local route. Two HTTP doors write to this one file — the staff surface and the client
|
|
54
|
+
// surface a customer's own assistant connects through — and "which door" is a different question from
|
|
55
|
+
// "which route". Folding the second into the first would make a client-key call read as a new kind of
|
|
56
|
+
// transport, and any reader that tested `transport` for the local route would keep working while
|
|
57
|
+
// meaning something it was never asked.
|
|
58
|
+
//
|
|
59
|
+
// WRITTEN ONLY WHEN GIVEN, the same rule as `transport` and for the same reason: the existing log
|
|
60
|
+
// shape must not move for records that have no answer to this. A door that does not name itself is a
|
|
61
|
+
// record with no `door` key, not a record claiming to be from nowhere.
|
|
62
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}), ...(door ? { door } : {}) }) + "\n";
|
|
51
63
|
try { mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, line); } catch { /* best-effort */ }
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -83,6 +83,26 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
83
83
|
if (tokenOnly && devMode) throw new Error("makeHttpHandler: tokenOnly and devMode are mutually exclusive (devMode's synthetic identity would defeat the mandatory key)");
|
|
84
84
|
if (tokenOnly && verify) throw new Error("makeHttpHandler: tokenOnly is for a door with no auth proxy in front — pass verify:null");
|
|
85
85
|
|
|
86
|
+
// ── A TURNED-AWAY CALL IS A RECORD, NOT A GAP ─────────────────────────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// Every refusal below used to return without writing anything, so this log held calls that SUCCEEDED
|
|
89
|
+
// and nothing else. An absent line then meant either "never asked" or "asked and was turned away",
|
|
90
|
+
// and nobody reading it back can tell those apart — which is the one question the log exists to
|
|
91
|
+
// answer. Measured on the production access log, 2026-09-16: zero lines in its most recent five
|
|
92
|
+
// thousand carry a refused or error status, which is what never being able to write one looks like.
|
|
93
|
+
//
|
|
94
|
+
// IT INVENTS NO IDENTITY. The key refusals fire BEFORE any identity is established. `user` is in
|
|
95
|
+
// scope there, but only as the placeholder this function starts with, and writing that would name a
|
|
96
|
+
// caller who does not exist. Those lines carry no email and no principal, and say a call was refused
|
|
97
|
+
// at this door at this time — which is true, and is the shape the local route already uses for the
|
|
98
|
+
// same reason: a synthesized identity would match somebody who did nothing.
|
|
99
|
+
const door = clientSurface ? "client" : undefined;
|
|
100
|
+
const refuse = (res, status, obj, who = {}) => {
|
|
101
|
+
try { appendAudit({ email: who.email ?? null, sub: who.sub ?? null, body: who.body ?? null, status: "refused", door }); }
|
|
102
|
+
catch { /* best-effort: a write failure must never change what the caller is told */ }
|
|
103
|
+
return send(res, status, obj);
|
|
104
|
+
};
|
|
105
|
+
|
|
86
106
|
/**
|
|
87
107
|
* Has this identity been taken off the guest list since its session was opened? Null when it has not,
|
|
88
108
|
* or when the question does not apply — and the body to send back when it has.
|
|
@@ -182,7 +202,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
182
202
|
// reach it — the protection is the filesystem — so it is disclosure rather than exposure,
|
|
183
203
|
// and it buys nothing: an operator needs to know a local socket is where to look, and
|
|
184
204
|
// already has the path in the boot line and the unit file. A stranger gets nothing usable.
|
|
185
|
-
return
|
|
205
|
+
return refuse(res, 401, { error: "this listener takes an auth-proxy JWT and never an access key — a key has no door here"
|
|
186
206
|
+ (keyDoorPath ? ". A key is taken on this deployment's local socket; the engine's boot line names it" : ". This deployment has no key door configured") });
|
|
187
207
|
}
|
|
188
208
|
log(`auth reject ${status}: ${e.message}`);
|
|
@@ -197,7 +217,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
197
217
|
const tok = readInnerToken(url, req.headers, { allowAuthorization: true });
|
|
198
218
|
if (!tok) {
|
|
199
219
|
log("auth reject 401: no key presented on the token-only door");
|
|
200
|
-
return
|
|
220
|
+
return refuse(res, 401, { error: "this address needs an access key — put it in your assistant's API-key field, or add ?token=<key> to the URL" });
|
|
201
221
|
}
|
|
202
222
|
let t;
|
|
203
223
|
try { t = verifyToken(tok); }
|
|
@@ -209,26 +229,26 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
209
229
|
// the original defect survived, silently, on every default install.
|
|
210
230
|
if (e.code === "REVOCATION_UNCHECKABLE") {
|
|
211
231
|
log(`DOOR FAULT — refusing every key: ${e.message} Create it, or point TRADEMARK_MCP_TOKEN_DENYLIST at the list this install actually uses; \`clearotron doctor\` reports the state.`);
|
|
212
|
-
return
|
|
232
|
+
return refuse(res, 401, { error: `this install cannot check whether keys have been revoked, so it is refusing all of them: ${e.message}` });
|
|
213
233
|
}
|
|
214
234
|
log(`auth reject 401: ${e.message}`);
|
|
215
|
-
return
|
|
235
|
+
return refuse(res, 401, { error: `invalid access key: ${e.message}` });
|
|
216
236
|
}
|
|
217
237
|
user = { email: t.sub || t.runId || t.jti || "unnamed-key" };
|
|
218
238
|
}
|
|
219
|
-
if (!limiter.take(user.email)) return
|
|
239
|
+
if (!limiter.take(user.email)) return refuse(res, 429, { error: "rate limit exceeded — retry shortly" }, { email: user.email });
|
|
220
240
|
|
|
221
241
|
if (req.method === "POST") {
|
|
222
242
|
let body;
|
|
223
243
|
try { body = await readJsonBody(req, maxBody); }
|
|
224
|
-
catch (e) { return
|
|
244
|
+
catch (e) { return refuse(res, 400, { error: `bad request body: ${e.message}` }, { email: user.email }); }
|
|
225
245
|
|
|
226
246
|
const sid = hdr(req.headers["mcp-session-id"]);
|
|
227
247
|
let entry = sid ? sessions.get(sid) : null;
|
|
228
248
|
let stampScope = null;
|
|
229
249
|
if (!entry) {
|
|
230
|
-
if (sid) return
|
|
231
|
-
if (!isInitializeRequest(body)) return
|
|
250
|
+
if (sid) return refuse(res, 404, { error: "unknown or expired session" }, { email: user.email, body });
|
|
251
|
+
if (!isInitializeRequest(body)) return refuse(res, 400, { error: "no session — the first request must be an MCP initialize" }, { email: user.email, body });
|
|
232
252
|
if (sessions.size >= sessionMax) evictOldest(sessions);
|
|
233
253
|
// INNER authz token (rides the /mcp?token= query or the X-Trademark-Token header) → the session's
|
|
234
254
|
// scope: ops (full), run-bound user (read-only one run), or — no token but firm staff — internal
|
|
@@ -274,20 +294,39 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
274
294
|
} else {
|
|
275
295
|
if (entry.email && entry.email !== user.email) {
|
|
276
296
|
log(`session owner mismatch: ${user.email} presented a session created by another identity`);
|
|
277
|
-
return
|
|
297
|
+
return refuse(res, 403, { error: "session belongs to another identity" }, { email: user.email, sub: entry.sub ?? null, body });
|
|
278
298
|
}
|
|
279
299
|
const gone = revokedMidSession(entry);
|
|
280
|
-
if (gone) return
|
|
300
|
+
if (gone) return refuse(res, 403, gone, { email: user.email, sub: entry.sub ?? null, body });
|
|
281
301
|
entry.lastSeen = Date.now();
|
|
282
302
|
}
|
|
283
303
|
// OPS-TOKENS item 6 — automation principals get their own (lower) bucket, keyed by the token's
|
|
284
304
|
// sub, ON TOP of the transport-identity limit above: a runaway connector throttles itself, not
|
|
285
305
|
// the interactive staff sharing the proxy identity.
|
|
286
306
|
if (opsLimiter && entry.kind === "ops" && !opsLimiter.take(`ops:${entry.sub ?? "unnamed"}`))
|
|
287
|
-
return
|
|
307
|
+
return refuse(res, 429, { error: "ops principal rate limit exceeded — retry shortly" }, { email: user.email, sub: entry.sub ?? null, body });
|
|
288
308
|
// Audit AFTER scope resolution so the line names the PRINCIPAL (token sub), not just the
|
|
289
|
-
// transport identity
|
|
290
|
-
|
|
309
|
+
// transport identity. Best-effort, never blocks.
|
|
310
|
+
//
|
|
311
|
+
// ── ONE LINE, WRITTEN WHEN THE OUTCOME IS KNOWN ────────────────────────────────────────────
|
|
312
|
+
//
|
|
313
|
+
// This used to be written here, before dispatch, and therefore with `status: null` — a record
|
|
314
|
+
// that a call was made and no record of what happened to it. On a deployed log every one of the
|
|
315
|
+
// five thousand most recent lines carries a null status, so "did this key read that report" was
|
|
316
|
+
// answerable only as "it asked".
|
|
317
|
+
//
|
|
318
|
+
// THE TRADE MADE, SAID PLAINLY: writing before dispatch survives a crash mid-call, and writing
|
|
319
|
+
// on `finish` does not. One line carrying the outcome is what a reader needs and what this was
|
|
320
|
+
// asked for, and a crash that loses the line also loses the response, so the caller is not left
|
|
321
|
+
// believing a lost call succeeded. The refusals above are recorded at their own sites and do not
|
|
322
|
+
// depend on this hook at all, which is the case a review actually asks about.
|
|
323
|
+
//
|
|
324
|
+
// `finish` fires once the response is fully sent, which is when `res.statusCode` is the answer
|
|
325
|
+
// the caller got rather than the default it started as.
|
|
326
|
+
res.once("finish", () => {
|
|
327
|
+
try { appendAudit({ email: user.email, sub: entry.sub ?? null, body, status: res.statusCode, door }); }
|
|
328
|
+
catch { /* best-effort */ }
|
|
329
|
+
});
|
|
291
330
|
const answered = entry.transport.handleRequest(req, res, body);
|
|
292
331
|
if (stampScope) { try { await answered; } finally { stampScope(); } }
|
|
293
332
|
return answered;
|
package/mcp-server/lib/scrub.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// because a lawyer defending a filing decision needs the records, and it is bounded by a different
|
|
14
14
|
// mechanism (a structured-field projection, never prose) in lib/evidence.mjs. Read that file's header
|
|
15
15
|
// before widening anything here; the line it draws is evidence vs METHOD, and method stays sealed.
|
|
16
|
-
// publish/render.mjs renders
|
|
16
|
+
// publish/render.mjs no longer renders a Methodology section to the client (the 2026-09-16 report redesign) and
|
|
17
17
|
// deliberately names the register provider (provenance honesty, receipts, enforcement telemetry). A scrubber
|
|
18
18
|
// stricter than the report would delete content the client was already sent, and would quietly make the MCP
|
|
19
19
|
// a different product from the PDF in their inbox.
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.5",
|
|
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/package.json
CHANGED
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.3.2-beta.
|
|
5
|
+
"version": "0.3.2-beta.5",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.5",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
|
package/shared/driver-dir.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// its own population needs an exception list — which would rebuild this issue's defect inside its fix:
|
|
36
36
|
//
|
|
37
37
|
// · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
|
|
38
|
-
// · `driver/pipeline.mjs:
|
|
38
|
+
// · `driver/pipeline.mjs:15757 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
|
|
39
39
|
// directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
|
|
40
40
|
//
|
|
41
41
|
// ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────
|