|
| 1 | +--- |
| 2 | +"@objectstack/rest": patch |
| 3 | +--- |
| 4 | + |
| 5 | +fix(rest): `GET /meta/books/:name` no longer bypasses the ADR-0046 §6.7 audience gate (#6241) |
| 6 | + |
| 7 | +The single-item metadata read has a cached branch and an uncached one, and the |
| 8 | +ADR-0046 §6.7 audience gate lives in the uncached one. The comment above the |
| 9 | +cached branch's entry condition has always stated why `doc` and `book` must skip |
| 10 | +it: |
| 11 | + |
| 12 | +> `doc` and `book` bypass the shared cache: their §6.7 audience gate is |
| 13 | +> per-caller, and a shared ETag would leak gated content across viewers. |
| 14 | +
|
| 15 | +The condition beneath that sentence compared the **raw** `:type` path segment |
| 16 | +against the literals `'doc'` / `'book'`. The route serves both spellings, and |
| 17 | +Prime Directive #3 makes the **plural** one canonical — so |
| 18 | +`GET /api/v1/meta/books/:name` did not match the exclusion, took the cached |
| 19 | +branch, and the audience gate never ran. `enableCache` defaults to `true`, which |
| 20 | +made the failing path the default one. |
| 21 | + |
| 22 | +Measured against a real `RestServer` — one book declaring |
| 23 | +`audience: { permissionSet: … }`, one signed-in caller holding no permission |
| 24 | +set: |
| 25 | + |
| 26 | +``` |
| 27 | +singular "book" :: cachedCalls=0 status=[403] PERMISSION_DENIED |
| 28 | +plural "books" :: cachedCalls=1 status=[] full gated body served |
| 29 | +``` |
| 30 | + |
| 31 | +Same book, same caller, two spellings of one route. `GET /meta/docs/:name` took |
| 32 | +the same path. This was **fail-open**: the wrong outcome is disclosure of gated |
| 33 | +documentation, not an availability error. |
| 34 | + |
| 35 | +**The fix is structural, not two corrected literals.** This is #3984 recurring |
| 36 | +in the same file eight days later, so the handler now normalizes the type |
| 37 | +**once** at the top (`RestServer.metaTypeSingular`) and every gate below reads |
| 38 | +that local — a per-type gate added later has no raw param in scope to compare |
| 39 | +against by accident. The cache exclusion and the §6.7 gate now read one shared |
| 40 | +predicate, so "which types bypass the cache" and "which types are audience |
| 41 | +gated" can no longer drift apart. A repository guard |
| 42 | +(`pnpm check:meta-type-normalized`, AST-based, zero exemptions) refuses the next |
| 43 | +raw comparison in `packages/rest/src`. |
| 44 | + |
| 45 | +**Behaviour change worth knowing:** `GET /meta/docs/:name` and |
| 46 | +`GET /meta/books/:name` now take the uncached branch, as their singular |
| 47 | +spellings always did, so those two responses no longer carry an `ETag` / |
| 48 | +`Cache-Control` validator and a conditional request no longer answers `304`. No |
| 49 | +other metadata type is affected. The cost is only the 304's saved bytes — |
| 50 | +`getMetaItemCached` delegates to `getMetaItem`, so the server does identical |
| 51 | +work either way — and the ETag it gave up was a hash of the **unfiltered** |
| 52 | +document, which is the cross-viewer leak the exclusion exists to prevent. |
0 commit comments