Skip to content

Commit 48d8ff3

Browse files
os-steveclaude
andauthored
fix(rest): /meta/:type/:name/history and /diff state the org partition they read (#13406) (#13756)
* wip(rest): thread org partition into /meta history + diff read doors * fix(rest): /meta history + diff read doors state the org partition (#13406) * chore(docs): re-anchor the system-context census after the rest-server line shift (#13406) * test(rest): refuse unsupported WHERE combinators in the stub double; register its pins (#13406) * docs(rest): the history door's `?? null` is TS2322, not TS2353 — name the adjacency drift and the diff door's absent guard (#13406) * test(rest): the history/diff stub double holds the caller's limit bound, and a pin proves ?limit= travels the door (#13406) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 469cbc9 commit 48d8ff3

6 files changed

Lines changed: 746 additions & 7 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)
6+
7+
`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
8+
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
9+
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
10+
the full log. Both doors named no organization, and `sys_metadata_history` is a
11+
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
12+
`organization_id` by **strict equality** (no `$or`), so a door that states no
13+
organization does not read "everything" — it reads the **env** partition
14+
(`request.organizationId ?? null`). The write door has stated the org since
15+
#8805; only these two read doors had not.
16+
17+
Direction is **fail-closed**: the caller's OWN org data was under-served. There
18+
was no cross-org read, and the pins added here keep it that way (an org-less
19+
caller, and a second organization, are both refused the rows).
20+
21+
**Call-side only.** `packages/spec` and the protocol implementation are
22+
untouched: `organizationId` was already declared on the request contract
23+
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
24+
legitimate expression of env scope that every correct caller depends on.
25+
26+
**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
27+
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
28+
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
29+
tenant there can only add rows. Under these doors' strict equality, a raw tenant
30+
id would ask the **org** partition for the history of every
31+
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
32+
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
33+
deliberately lands **env-wide** under the #6190 ruling. That would answer
34+
`{ events: [] }` for them: this same defect, newly minted one type family over.
35+
Gating the read on the same registry predicate the write uses is what keeps the
36+
two sides incapable of drifting.
37+
38+
The key is **spread, never `organizationId: x ?? null`**:
39+
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
40+
`string`, not nullable, mirroring the implementation's `organizationId?: string`
41+
— so `?? null` is a compile error on the history door, and on the diff door
42+
(reached through a cast) it type-checks and is a silent runtime no-op.
43+
44+
Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
45+
see the change log and version diffs for overlays their own organization
46+
authored. Org-less callers, and every `allowOrgOverride: false` type, read
47+
exactly what they read before.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

packages/rest/src/execctx-consumer-census.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
309309
// ---------------------------------------------------------------------------
310310

311311
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
312-
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
312+
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
313+
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
314+
// history` and `/diff` read doors resolved NO identity, so neither
315+
// could state which organization's `sys_metadata_history` partition it
316+
// was reading — they read the env one and answered an empty change log
317+
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
318+
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
319+
// which is the family the next case describes: neither door sits behind
320+
// the shared anonymous floor, so each must decide the outage for
321+
// itself — the same shape the `/audit` twin and the `/layers` door
322+
// already carry.
323+
//
324+
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
325+
// call sites, and ONE prose mention in the history door's new
326+
// doc-comment recording that `resolveExecCtx` is memoised per request
327+
// and so this is not a new org-resolution seam.
328+
//
313329
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
314330
// the ONE metadata-touching route in the table that resolved no
315331
// identity at all — the exception this census surfaced — and the
@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
324340
// `enforceAuth` was measured NOT to be the repair). A mention count
325341
// that tracked the site count exactly would be measuring one thing
326342
// twice.
327-
expect(SITES.length).toBe(73);
328-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
343+
expect(SITES.length).toBe(75);
344+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
329345
});
330346

331-
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
347+
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
332348
// 16 sites spell the catch on the invocation line; 4 more spell it on
333349
// the continuation line. A single-line grep sees 16 and the arithmetic
334350
// silently loses four sites.
@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
338354
// be the first of its kind and would break the structural claim below.
339355
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
340356
expect(sameLine.length).toBe(16);
341-
expect(CAUGHT.length).toBe(20);
357+
expect(CAUGHT.length).toBe(22);
342358
expect(BARE.length).toBe(53);
343359
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
344360
});
345361

346-
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
362+
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
347363
// This inverts the reason the thread gave for doing the bare sites
348364
// first ("no local signal that a fault becomes an anonymous subject").
349365
// The bare sites are bare BECAUSE the shared anonymous floor is the

0 commit comments

Comments
 (0)