Skip to content

Commit 57b8e45

Browse files
committed
fix(qa): teach the authz blind-spot population rule the registerPerItemRoute spelling
The per-item family's later members register through a switch-carrying local helper instead of a direct `this.routeManager.register(` call. The census rule knew only the direct spelling, so it read population 73 / reachable 12 against a recorded 80 / 19 and the Dogfood Regression Gate went red. Re-recording 73/12 was the wrong repair: those 8 routes are still mounted and still registered inside `registerMetadataEndpoints`, so the lower number would have ratified a false population and encoded a 7-route blind spot in the census named for finding them. The rule now counts both spellings, excluding the helper's own forwarding call so it is not double-counted: 72 direct + 8 helper-routed = 80, and 11 + 8 = 19 reachable. Reachability was checked before the count was widened. `registerPerItemRoute` reads `this.routeManager` at call time and every call site is inside `registerMetadataEndpointsInner`, which runs under the anonymous-deny `guardedRouteManager` swap — so the helper hides nothing from the probe. That is now measured rather than argued: rest-meta-auth.test.ts drives an anonymous `GET /meta/:type/:name/history` to 401 with the history read never reached. Both halves of the new rule carry exact positive controls so neither can go silently to zero. Also completes the changeset's BREAKING paragraph: `MetadataEndpointsConfigParsed` gained a required `maintenance: boolean` on the parsed (output) side — an ADR-0087 D8 compiler-carried narrowing that was implemented but not written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
1 parent 4846018 commit 57b8e45

3 files changed

Lines changed: 155 additions & 9 deletions

File tree

.changeset/metadata-endpoints-switch-radius-maintenance-key.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ the mounted route table moves for two of the four keys, in opposite directions:
5454
mounted **loses those eight**. There is no key that restores them — the per-item face is
5555
one face by this ruling — so an embedder that wants the writes keeps `item` on and
5656
closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`.
57+
- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a
58+
REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`,
59+
so it is optional on the way *in* and always present on the way *out* — and
60+
`MetadataEndpointsConfigParsed` is `z.infer<typeof MetadataEndpointsConfigSchema>`, the
61+
OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a
62+
helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal —
63+
stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS
64+
compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it
65+
lands at build time, and no runtime behaviour depends on the author noticing a
66+
changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep
67+
the whole-store family closed. In-repo consumers of the type: none — the narrowing was
68+
measured against a probe compiled from the rebuilt declaration, not assumed.
5769

5870
Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no
5971
shipped boot path** today (`os serve` fixes the config and the dev plugin passes none,
@@ -70,10 +82,14 @@ convert: a `RestServerConfig` is plugin TS configuration, never a stack collecti
7082
and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no
7183
rehydration seam sees it. What changes is a mounted route table at construction time.
7284

73-
Nor is it compiler-carried: every key is an optional boolean, so `{ items: false }`
74-
still compiles and still parses and simply mounts a different table. The two channels
75-
that would otherwise reach a consumer are both blind, which is precisely the residue
76-
D3 exists for — the prescription is registered as
85+
Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a
86+
D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still
87+
compiles and still parses and simply mounts a different table: the author is told
88+
nothing. (The parsed-type narrowing in the third BREAKING bullet above *is*
89+
compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot
90+
reach the embedder who authored `{ items: false }` and now silently gets three routes
91+
back.) So for the change that actually moves the route table, both channels that would
92+
otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as
7793
`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands
7894
it to an upgrading embedder instead of leaving it as prose in a changelog.
7995

packages/qa/dogfood/test/authz-probe-blind-spot.census.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@
7777
//
7878
// `rest-server.ts` is pinned below at its STATIC reading: 80 `routeManager`
7979
// call sites, 17 registrars, 19 sites inside the one mintable registrar, 61
80-
// outside. A RUNTIME census — construct `RestServer` against a recording
80+
// outside. ⭐ [#15542] Those 80 are counted across TWO spellings since the
81+
// per-item family gained a switch-carrying helper — 72 direct
82+
// `this.routeManager.register(` sites plus 8 `registerPerItemRoute(` calls, the
83+
// helper's own forwarding call excluded so it is not counted twice. The total
84+
// did not move; the rule had to learn the second spelling to keep reading it.
85+
// A RUNTIME census — construct `RestServer` against a recording
8186
// `RouteManager` and a protocol implementing every optional capability, then
8287
// call each registrar — reads 85 / 17 / 19 / 66 instead. Both are correct and
8388
// the delta is fully explained: `registerApprovalsEndpoints` builds 12 routes
@@ -312,7 +317,47 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
312317
population: 80,
313318
reachable: 19,
314319
blindSpot: 61,
315-
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
320+
populationRule:
321+
'route registration sites — `this.routeManager.register(` call sites, LESS the one inside ' +
322+
'`registerPerItemRoute` (the shared forwarder, not a route), PLUS `registerPerItemRoute(` call sites; ' +
323+
'reachable = those inside registerMetadataEndpoints',
324+
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
325+
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
326+
//
327+
// WHAT MOVED IN THE SOURCE. The per-item family's later members (`PUT`,
328+
// `DELETE`, `/history`, `/audit`, `/diff`, `/published`, `/publish`,
329+
// `/rollback`) stopped being direct `this.routeManager.register(` call
330+
// sites and became `registerPerItemRoute(` calls — a local helper carrying
331+
// the `endpoints.item` switch. Net -7 on the old one-spelling reading: 8
332+
// sites left that spelling and the helper's own forwarding call added 1
333+
// back. So the old rule read population 73 / reachable 12.
334+
//
335+
// ⛔ 73 / 12 WAS NOT RE-RECORDED, and the reason is this file's whole
336+
// purpose. Those 8 routes are still mounted and still registered exactly
337+
// where they were; only the spelling of the call changed. Writing 73 down
338+
// would have ratified a population 7 short of the real one and encoded a
339+
// 7-route hole in the very blind-spot count this census exists to keep
340+
// honest — the failure it is named for, committed by its own record.
341+
//
342+
// ⚠️ REACHABILITY WAS CHECKED BEFORE THE COUNT WAS WIDENED, because if the
343+
// helper HID those routes from the probe the repair would belong in
344+
// `rest-server.ts` and not here. It does not. `registerPerItemRoute` reads
345+
// `this.routeManager` at CALL time and every one of its 8 call sites is
346+
// lexically inside `registerMetadataEndpointsInner`, which
347+
// `registerMetadataEndpoints` runs with `this.routeManager` swapped to the
348+
// anonymous-deny `guardedRouteManager` and restored in a `finally`. So a
349+
// helper-routed registration goes through the identical wrapping the 11
350+
// remaining direct sites in that registrar do, and the umbrella key
351+
// `meta:rest-server.ts:registerMetadataEndpoints` covers it unchanged.
352+
// Pinned at runtime rather than argued from source:
353+
// `packages/rest/src/rest-meta-auth.test.ts` drives an anonymous
354+
// `GET {meta}/:type/:name/history` — a helper-routed route — to 401.
355+
//
356+
// The decomposition, so the two halves stay legible: 72 direct route
357+
// registrations + 8 helper-routed = 80 population; 11 + 8 = 19 reachable.
358+
// `this.routeManager.register(` reads 73 because the helper's forwarder is
359+
// one of them, and it is sliced out before counting.
360+
//
316361
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
317362
// exists to prove this census is still reading the file it thinks it is, and
318363
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
@@ -329,7 +374,21 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
329374
// 80, `reachable` 19, `private register*Endpoints(` 17 and
330375
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
331376
// and no registrar. `blindSpot` therefore stays 61 as well.
332-
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
377+
// ⚠️ That last figure is the reading AS OF #13214 and is left as written:
378+
// the control is 73 today for the spelling reason recorded above, and the
379+
// population it feeds is still 80. Do not "correct" the paragraph — it is a
380+
// dated measurement, not a live claim.
381+
controls: {
382+
'private register*Endpoints(': 17,
383+
'this.routeManager.register(': 73,
384+
// Both halves of the new rule carry their own control, so neither can go
385+
// silently to zero: a helper deleted and its routes inlined back would
386+
// still read population 80, and only these two controls would notice the
387+
// shape moved and force this provenance to be re-read.
388+
'registerPerItemRoute(': 8,
389+
'const registerPerItemRoute =': 1,
390+
enforceAuth: 64,
391+
},
333392
note:
334393
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
335394
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
@@ -560,21 +619,57 @@ export function deriveProbeFileCensus(): {
560619
}
561620

562621
// ── rest-server.ts ──────────────────────────────────────────────────────
622+
//
623+
// [#15542 / #15854] TWO SPELLINGS, ONE POPULATION. A route registration in
624+
// this file is EITHER a direct `this.routeManager.register(` call site OR a
625+
// `registerPerItemRoute(` call — the local helper the per-item family's later
626+
// members go through, which carries the `endpoints.item` switch and forwards
627+
// to `this.routeManager.register(entry)`. Both are registrations; counting
628+
// only the first spelling reads 7 short.
629+
//
630+
// ⛔ The helper's OWN forwarding call is NOT a registration site — it is the
631+
// one shared mechanism 8 sites go through — so its body is sliced out before
632+
// counting. Counting it would double-count every helper-routed route.
563633
{
564634
const src = read('packages/rest/src/rest-server.ts');
565635
const registrarRe = /^\s*private\s+register[A-Za-z]*Endpoints\s*\(/gm;
566636
const mountRe = /this\.routeManager\.register\(/g;
637+
// Matches the CALL sites only. The declaration reads
638+
// `const registerPerItemRoute = (` and the docblock mentions read
639+
// `{@link registerPerItemRoute}` — in neither is the name followed by `(`.
640+
const helperCallRe = /registerPerItemRoute\(/g;
641+
const helperDeclRe = /const\s+registerPerItemRoute\s*=/;
642+
643+
/**
644+
* Registration sites in one haystack: direct call sites, LESS the helper's
645+
* own forwarding call, PLUS the helper's call sites.
646+
*
647+
* ⛔ Fail-loud, like the ledger marker slice above: a helper declaration
648+
* that moves out of this shape slices to '' and nothing is subtracted, so
649+
* the reading comes out ONE HIGH (81 / 20) and this census goes RED. It
650+
* never silently shrinks — a quietly narrower rule is the failure mode the
651+
* whole file is built against.
652+
*/
653+
const sites = (hay: string): number => {
654+
const at = hay.search(helperDeclRe);
655+
const stop = at < 0 ? -1 : hay.indexOf('\n };', at);
656+
const forwarder = at < 0 || stop < 0 ? '' : hay.slice(at, stop);
657+
return occurrences(hay, mountRe) - occurrences(forwarder, mountRe) + occurrences(hay, helperCallRe);
658+
};
659+
567660
// Slice the mintable registrar's body: from its declaration to the next one.
568661
const decls = [...src.matchAll(registrarRe)].map((m) => ({ at: m.index ?? 0, text: m[0] }));
569662
const metaIdx = decls.findIndex((d) => d.text.includes('registerMetadataEndpoints'));
570663
const start = decls[metaIdx]?.at ?? 0;
571664
const end = decls[metaIdx + 1]?.at ?? src.length;
572665
files.set('packages/rest/src/rest-server.ts', {
573-
population: occurrences(src, mountRe),
574-
reachable: occurrences(src.slice(start, end), /this\.routeManager\.register\(/g),
666+
population: sites(src),
667+
reachable: sites(src.slice(start, end)),
575668
controls: {
576669
'private register*Endpoints(': occurrences(src, /private\s+register[A-Za-z]*Endpoints\s*\(/g),
577670
'this.routeManager.register(': occurrences(src, /this\.routeManager\.register\(/g),
671+
'registerPerItemRoute(': occurrences(src, /registerPerItemRoute\(/g),
672+
'const registerPerItemRoute =': occurrences(src, /const\s+registerPerItemRoute\s*=/g),
578673
enforceAuth: occurrences(src, /enforceAuth/g),
579674
},
580675
});

packages/rest/src/rest-meta-auth.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,41 @@ describe('RestServer metadata routes — anonymous-deny gate (#3963)', () => {
6767
expect(protocol.getMetaItems).toHaveBeenCalled();
6868
});
6969

70+
// [#15542 / #15854] The per-item family's later members register through
71+
// `registerPerItemRoute` rather than by a direct `this.routeManager.register(`
72+
// call. That helper reads `this.routeManager` at CALL time, so it registers
73+
// through the same `guardedRouteManager` swap the direct sites do — but that
74+
// is a source argument, and the gate is worth a MEASUREMENT. Without this
75+
// case the whole helper-routed half of the surface (`PUT`, `DELETE`,
76+
// `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) had
77+
// no test proving it is still anonymously denied, and a future refactor that
78+
// captured `this.routeManager` at definition time would register all eight
79+
// past the gate with every existing test still green.
80+
//
81+
// It is also what licenses `authz-probe-blind-spot.census.ts` counting those
82+
// 8 as REACHABLE by `meta:rest-server.ts:registerMetadataEndpoints`.
83+
it('401s an anonymous helper-routed per-item route — the gate travels with `registerPerItemRoute` (#15542)', async () => {
84+
const protocol: any = { historyMetaItem: vi.fn().mockResolvedValue({ events: [] }) };
85+
const rest = new RestServer(makeServer() as any, protocol, {} as any);
86+
rest.registerRoutes();
87+
const route = rest
88+
.getRoutes()
89+
.find((r) => r.method === 'GET' && /\/meta\/:type\/:name\/history$/.test(r.path));
90+
if (!route) throw new Error('GET /meta/:type/:name/history route not registered');
91+
92+
const { res, state } = makeRes();
93+
await (route.handler as (req: any, res: any) => Promise<void>)(
94+
{ method: 'GET', params: { type: 'object', name: 'sys_metadata' }, query: {}, headers: {} },
95+
res,
96+
);
97+
98+
expect(state.status).toBe(401);
99+
expect(state.body?.error).toBe('UNAUTHENTICATED');
100+
// Short-circuited BEFORE the history read — the per-org event log did
101+
// not leak, which is the same property the direct sites are pinned for.
102+
expect(protocol.historyMetaItem).not.toHaveBeenCalled();
103+
});
104+
70105
it('still 401s an anonymous /meta/object — the opt-out is retired (#3963)', async () => {
71106
// `api.requireAuth: false` used to serve object schemas anonymously. The
72107
// opt-out is gone: object metadata is never public. (Only the book/doc

0 commit comments

Comments
 (0)