Skip to content

Commit 5f6dc96

Browse files
fix(rest)!: the batch door judges each op's API exposure on the object the data door serves (#27899) (#27963)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DLpW5ZoNUB4CBh4NAaW3pD3)_ Fixes #27899 domain:server#4 · os-elon-musk · session_016WV4cwgfUuNAvcUg3JzBiR Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no hand-written docs page or published skill states what the cross-object batch door answers about an object's API exposure, or how a short name resolves there (`git grep` of `content/docs` outside `references/`/`releases/` and of `skills/` for `apiEnabled`, `apiMethods` or `OBJECT_API_DISABLED` beside the batch door: 0 hits; `http-protocol.mdx` names the door only as the `transactionalBatch` capability). ## 维护者速读 - 改了什么:跨对象批量接口 `POST /batch` 的逐操作 API 暴露闸,改用 `/data/OBJ` 暴露闸的同一个按名读法(从 `enforceApiAccess` 抽成 `RestServer` 的一个私有方法,两处共用)。每个操作判的,就是数据门按这个名字会服务的那个对象;拒绝时回的状态码与正文和数据门一字不差。 - 为什么改:按分诊 6514615922,这是 #27728 / #27729 一族的最后一扇门。原先它按声明名在对象清单里找对象,清单不做旧式 `ns__short` 短名解析,于是这类对象用短名时被当成未知对象放行,答案与 `/data` 门不一致(实测,见 Tests)。 - 风险与回滚:收窄,changeset 已写 BREAKING 说明与 ADR-0087 处置;只影响用短名指向带 `__` 的旧对象、且该对象限制了 API 暴露的批量操作,已发运的对象都不带 `__`。读法由每请求一次清单读变为每个不同对象一次按名读。回滚即还原 `batch-routes.ts` 的闸块与 `rest-server.ts` 的私有读法两处。 - 先看:`packages/rest/src/batch-routes.ts`(逐操作闸块);`packages/rest/src/rest-server.ts` 的 `loadObjectItem`(从 `enforceApiAccess` 抽出,并接到批量路由的 host 上);新测试 `packages/rest/src/batch-op-exposure-object-read.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/batch-op-exposure-object-read.test.ts`,预期 9/9 通过。 - 需要你做的:无,席位落地(契约复审由席位安排)。 ## What changes - **One read for every data door's exposure gate.** The read the `/data/:object` gate (`enforceApiAccess`) made inline — `loadObjectItem` in `meta-item-read-gate.ts`, over the protocol's by-name execution read, or its list read for a protocol with no by-name read — is now one private method, `RestServer#loadObjectItem(p, environmentId, name)`. `enforceApiAccess` calls it for the route's `:object`, and the cross-object batch route calls it for each op's object through `BatchRouteHost`. - **The batch door's per-op gate.** It used to read the whole object list once and look each op up by the item's declared name. The by-name read resolves a legacy `ns__short` object from its short name as the data door's `registry.getObject` does; the list does not. The gate now judges the object the data door serves for the op's name, so such an op is refused with the data door's answer, status and body (`apiAccessDenialFromEnable`, unchanged), before the transaction opens. Each distinct object is read once per request; each distinct (object, action) is judged once, in op order; the first refusal answers for the whole batch, as before. - **The private list read retired.** `RestServer#loadObjectItems` had this gate as its only caller, so it is gone, and `BatchRouteHost` carries `loadObjectItem` in its place. The two #3545 observability pins in `rest.test.ts` that called it by name now call the method that replaced it, with the same assertions (fail open, a thrown read logged, an empty registry silent). Docblocks that named the batch as a reader of the object list now say otherwise (`meta-item-read-gate.ts`, `metaListSource`, one line of `objectql-slot-consumer-census.test.ts`). ## Why Triage 6514615922: the `/batch` per-op gate reads each op's object through the same read as the `/data` gate and refuses the same way. PR #27897 gave the `/data` gate this read; the batch door was the last data door that answered differently about whether an object is exposed. ## Tests New `packages/rest/src/batch-op-exposure-object-read.test.ts`: a real `ObjectQL` engine, the real protocol and the real `RestServer` routes on a sqlite `:memory:` driver, with `objectQLProvider` wired so the batch door is reachable, and the metadata store registered (without it both object reads answer `SERVICE_UNAVAILABLE` and every exposure gate fails open by design, #3545). Fixtures: legacy-named objects of one package (one unexposed, one exposed for reads only, one exposed), addressed by their short names, and one plain-named unexposed object. - **Refusal pins (red before, green after).** A create, update or delete op on the unexposed object: status and body equal the `/data/:object` answer for the same name, the transaction never opens, no row. A write the object does not expose in bulk: status and body equal `POST /data/:object/createMany`'s 405. One refused op refuses the whole batch: an exposed op ahead of it is not written. - **Controls (green before and after).** Premise: the data door resolves the short name and refuses the unexposed object. The full name is refused, as it already was. An exposed object is written under its short name and its full name in one batch. A plain-named unexposed object is refused, as it already was. Before and after, through the real `POST /batch` door (base `d4064cc1` with only the test committed, then head `2019e190`): | op | before | after | |:--|:--|:--| | create, unexposed object | 200, row written | 404 `OBJECT_API_DISABLED`, equal to `/data` | | update / delete, unexposed object | 404 `RECORD_NOT_FOUND` (passed the gate, reached the engine) | 404 `OBJECT_API_DISABLED`, equal to `/data` | | write the object does not expose in bulk | 200, row written | 405 `OBJECT_API_METHOD_NOT_ALLOWED`, equal to `createMany` | | exposed op, then an unexposed op | 200, both written | 404, nothing written | | the four controls | as listed above | unchanged | <details><summary>Red first, ablation, package suite</summary> - Red first, `ec6f33ef`..`db7513a4` (test only, on the base code): `Tests 5 failed | 4 passed (9)`, the five refusal pins red and the four controls green. - Fix, `2019e190`: `Tests 9 passed (9)`; with `rest-batch-endpoint`, `rest`, `import-door-object-existence`, `objectql-slot-consumer-census`, `rest-batch-blank-id`: `Test Files 6 passed (6) · Tests 294 passed (294)`. - Ablation (`scripts/ablation-replace.mjs`, anchor hit once, blob `0ce906d7` to `23cf0acd`): the gate counted the read item only under the op's declared name, the old list semantics. `Tests 5 failed | 4 passed (9)`, the same five red. Restored: blob equals HEAD `0ce906d7`, `git diff HEAD` empty. - `pnpm --filter @objectstack/rest test`: `Test Files 351 passed (351) · Tests 6775 passed | 332 skipped (7107)`, lock VERDICT command-exit 0. - `pnpm --filter @objectstack/rest typecheck`: lock VERDICT command-exit 0. </details> ## Acceptance notes - **Read cost.** The batch gate made one object-list read per request; it now makes one by-name read per distinct object of the batch, on the protocol's execution face, which decorates nothing. A batch names few distinct objects, and the batch size cap still bounds it. - **Fail-open logging.** A thrown object read still fails open and logs (#3545), measured unchanged in kind: without the metadata store, the list read and the by-name read both answer `SERVICE_UNAVAILABLE`, so the gate's verdict in an outage is the same as before. The log is now one line per distinct object rather than one per request. - **Disabled package.** An object of a disabled package reads as no object here, as the list omitted it, and the op is refused inside the transaction by the data plane, as before (`loadObjectItem`'s `OBJECT_PACKAGE_DISABLED` branch; read from the code, not measured here). - `objectql-slot-consumer-census.test.ts` keeps its measured sentence about where a real op goes on the multi-kernel harness; only the method name changed, the sentence was not re-measured. - `origin/main` moved after the branch point (`d4064cc1`..`ed0e71db`); its `packages/rest` changes touch other files, and `git merge-tree` against it is clean, so the branch was not merged. ## Verification Head `2019e190`, base `d4064cc1`, after a whole-tree build (`turbo run build --filter=!@objectstack/docs`, lock VERDICT command-exit 0). <details><summary>Derived gate families and lint</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 61 families over the 7 changed paths. All 61 exited 0, and `pnpm lint` exited 0. `--ran` reconciliation: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`; `pnpm lint` recorded outside the derivation. `check-adr-0087-registration`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` </details> <details><summary>Dogfood battery (narrowing arm), head 2019e19</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock, no shard: `Test Files 357 passed | 3 skipped (360) · Tests 2875 passed | 20 skipped (2895)`, lock VERDICT command-exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fece781 commit 5f6dc96

7 files changed

Lines changed: 278 additions & 46 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: the cross-object batch door judges each op's API exposure on the object the data door serves for its name (#27899)
6+
7+
Clause-②: no (narrowing) — on `POST {basePath}/batch`, an op that names a legacy `ns__short` object by its short name is now held to that object's own `enable` settings and refused with the data door's 404 or 405, before the transaction opens. No value, key or shape is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the batch door's exposure gate now reads each op's object the way every /data/:object route's gate already reads it, and answers the two already-published refusals (404 OBJECT_API_DISABLED, 405 OBJECT_API_METHOD_NOT_ALLOWED) those routes answer. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** the cross-object batch door (`POST {basePath}/batch`) refuses an op it used to write (`@objectstack/rest`), per triage 6514615922.
13+
14+
- **Before.** The door's per-op exposure gate searched the object list for an item declared under the op's exact name. A legacy `ns__short` object named by its short name was not found there, so the op passed as an unknown object, and the engine, which resolves the short name, then wrote it. `enable.apiEnabled: false` and an `enable.apiMethods` list were not applied to it on this door, while every `/data/:object` route already applied them.
15+
- **Now.** The gate reads each op's object through the same by-name read as the `/data/:object` routes' gate, so it judges the object the data door serves for that name. Such an op is refused with the answer those routes give, status and body: 404 `OBJECT_API_DISABLED`, or 405 `OBJECT_API_METHOD_NOT_ALLOWED` for a write the object does not expose in bulk. The whole batch is refused before the transaction opens, so no op in it is written. Each distinct object is read once per request. No shipped object carries a `__` name.
16+
- **Who sees it.** A client that sends a batch op naming a legacy `ns__short` object by its short name, where that object sets `enable.apiEnabled: false` or an `enable.apiMethods` list that leaves the write or `bulk` out. The same op under the full name was already refused.
17+
18+
**The remedy.** Nothing changes for objects the API exposes. For a restricted `ns__short` object, the short name now answers on the batch door as it does on every `/data/:object` route: lift the object's `enable` restriction if the API should serve it.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27899] The cross-object batch door (`POST {basePath}/batch`) judges each
5+
* op's API exposure on the object the data door would serve for the op's
6+
* name, and refuses it with the data door's own answer.
7+
*
8+
* The `/data/:object` exposure gate reads its object by name, through the read
9+
* that resolves a legacy `ns__short` object from its short name exactly as the
10+
* data door's `registry.getObject` does (#27729). The batch door's per-op gate
11+
* keyed the object LIST by each item's declared name instead, and the list
12+
* carries no such resolution: an op naming such an object by its short name
13+
* found "no object" there, passed as unknown, and was then written by the
14+
* engine, which does resolve the name. It now asks the `/data` gate's read, so
15+
* every data door gives one answer about whether an object is exposed.
16+
*
17+
* A REAL `ObjectQL` engine + `ObjectStackProtocolImplementation` on a REAL
18+
* sqlite `:memory:` driver, with `objectQLProvider` wired so the batch door is
19+
* reachable rather than answering 501; nothing below the routes is a double.
20+
*/
21+
22+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
23+
import { ObjectQL } from '@objectstack/objectql';
24+
import { SqlDriver } from '@objectstack/driver-sql';
25+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
26+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
27+
import { RestServer } from './rest-server';
28+
29+
const liveEngines: ObjectQL[] = [];
30+
afterEach(async () => {
31+
while (liveEngines.length) {
32+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
33+
}
34+
});
35+
36+
const fields = {
37+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
38+
title: { name: 'title', type: 'text' as const, label: 'Title' },
39+
};
40+
41+
/** Legacy `ns__short` objects of one package, addressed below by short name. */
42+
const LEGACY_PACKAGE = 'app.ex_legacy';
43+
/** Exposed: no `enable` block. */
44+
const WIDGET = { name: 'exl__widget', label: 'Widget', systemFields: false, fields };
45+
/** Hidden from the API. */
46+
const VAULT = { name: 'exl__vault', label: 'Vault', systemFields: false, enable: { apiEnabled: false }, fields };
47+
/** Exposed for reads only. */
48+
const LEDGER = { name: 'exl__ledger', label: 'Ledger', systemFields: false, enable: { apiMethods: ['get', 'list'] }, fields };
49+
/** A plain-named object hidden from the API: the gate's answer before this change. */
50+
const SECRET = { name: 'ex_secret', label: 'Secret', systemFields: false, enable: { apiEnabled: false }, fields };
51+
52+
function makeRes() {
53+
const res: any = {
54+
write: () => true, end: () => {},
55+
header: () => res,
56+
status: (code: number) => { res._status = code; return res; },
57+
json: (body: any) => { res._json = body; return res; },
58+
};
59+
return res;
60+
}
61+
62+
async function boot() {
63+
const engine = new ObjectQL();
64+
liveEngines.push(engine);
65+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
66+
await engine.init();
67+
engine.registry.installPackage({ id: LEGACY_PACKAGE, name: LEGACY_PACKAGE, version: '1.0.0' } as any, 'code');
68+
for (const o of [WIDGET, VAULT, LEDGER]) engine.registry.registerObject(o as any, LEGACY_PACKAGE);
69+
engine.registry.registerObject(SECRET as any);
70+
await engine.syncSchemas();
71+
// The metadata store every exposure gate reads through. Without it the
72+
// protocol's object reads answer SERVICE_UNAVAILABLE and every gate fails
73+
// OPEN by design (#3545), on this door and on the data door alike.
74+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
75+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
76+
}
77+
78+
const protocol = new ObjectStackProtocolImplementation(engine as any);
79+
const noop = () => {};
80+
const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
81+
const rest = new RestServer(
82+
server as any, protocol as any, { api: { requireAuth: false } } as any,
83+
undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider
84+
(async () => engine) as any, // objectQLProvider (positional arg #8)
85+
);
86+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
87+
rest.registerRoutes();
88+
const routes = rest.getRoutes();
89+
const route = (path: string): any => {
90+
const found = routes.find((r: any) => r.method === 'POST' && r.path === path);
91+
if (!found) throw new Error(`route POST ${path} is not registered`);
92+
return found;
93+
};
94+
const doors = {
95+
batch: route('/api/v1/batch'),
96+
data: route('/api/v1/data/:object'),
97+
createMany: route('/api/v1/data/:object/createMany'),
98+
};
99+
const transaction = vi.spyOn(engine, 'transaction');
100+
const call = async (door: keyof typeof doors, object: string | undefined, body: unknown) => {
101+
const res = makeRes();
102+
await doors[door].handler({ method: 'POST', params: object ? { object } : {}, query: {}, headers: {}, body } as any, res);
103+
return { status: res._status ?? 200, body: res._json };
104+
};
105+
return {
106+
engine,
107+
transaction,
108+
batch: (operations: unknown[]) => call('batch', undefined, { operations }),
109+
data: (object: string, record: Record<string, unknown>) => call('data', object, record),
110+
createMany: (object: string, records: Array<Record<string, unknown>>) => call('createMany', object, records),
111+
/** Rows of a table, counted at the engine. */
112+
async count(table: string) {
113+
const rows = await engine.find(table, { where: {} });
114+
return Array.isArray(rows) ? rows.length : 0;
115+
},
116+
};
117+
}
118+
119+
describe('[#27899] the batch door judges each op on the object the data door serves for its name', () => {
120+
let h: Awaited<ReturnType<typeof boot>>;
121+
beforeEach(async () => { h = await boot(); });
122+
123+
it('premise: the data door resolves a legacy short name and refuses the hidden object behind it', async () => {
124+
expect((h.engine.registry.getObject('vault') as any)?.name).toBe('exl__vault');
125+
const door = await h.data('vault', { id: 'v0', title: 'x' });
126+
expect(door.status).toBe(404);
127+
expect(door.body?.code).toBe('OBJECT_API_DISABLED');
128+
});
129+
130+
for (const action of ['create', 'update', 'delete'] as const) {
131+
it(`a ${action} op on a hidden object is refused with the data door's answer, before the transaction opens`, async () => {
132+
const door = await h.data('vault', { id: 'v0', title: 'x' });
133+
const op = action === 'create'
134+
? { object: 'vault', action, data: { id: 'v1', title: 'x' } }
135+
: { object: 'vault', action, id: 'v1', ...(action === 'update' ? { data: { title: 'y' } } : {}) };
136+
const batch = await h.batch([op]);
137+
expect(batch).toEqual({ status: door.status, body: door.body });
138+
expect(h.transaction).not.toHaveBeenCalled();
139+
expect(await h.count('exl__vault')).toBe(0);
140+
});
141+
}
142+
143+
it('an op the object does not expose is refused with the bulk data door\'s 405, status and body', async () => {
144+
const bulk = await h.createMany('ledger', [{ id: 'l0', title: 'x' }]);
145+
expect(bulk.status).toBe(405);
146+
expect(bulk.body?.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED');
147+
const batch = await h.batch([{ object: 'ledger', action: 'create', data: { id: 'l1', title: 'x' } }]);
148+
expect(batch).toEqual(bulk);
149+
expect(h.transaction).not.toHaveBeenCalled();
150+
expect(await h.count('exl__ledger')).toBe(0);
151+
});
152+
153+
it('one refused op refuses the whole batch: an exposed op before it is never written', async () => {
154+
const batch = await h.batch([
155+
{ object: 'widget', action: 'create', data: { id: 'w1', title: 'x' } },
156+
{ object: 'vault', action: 'create', data: { id: 'v1', title: 'x' } },
157+
]);
158+
expect(batch.status).toBe(404);
159+
expect(batch.body?.code).toBe('OBJECT_API_DISABLED');
160+
expect(h.transaction).not.toHaveBeenCalled();
161+
expect(await h.count('exl__widget')).toBe(0);
162+
expect(await h.count('exl__vault')).toBe(0);
163+
});
164+
165+
// ── controls: answers that do not move ────────────────────────────────────
166+
167+
it('control: the hidden object\'s full name is refused, as it already was', async () => {
168+
const door = await h.data('exl__vault', { id: 'v0', title: 'x' });
169+
expect(door.status).toBe(404);
170+
const batch = await h.batch([{ object: 'exl__vault', action: 'create', data: { id: 'v1', title: 'x' } }]);
171+
expect(batch).toEqual({ status: door.status, body: door.body });
172+
expect(await h.count('exl__vault')).toBe(0);
173+
});
174+
175+
it('control: an exposed object is written under its short name and under its full name', async () => {
176+
const batch = await h.batch([
177+
{ object: 'widget', action: 'create', data: { id: 'w1', title: 'x' } },
178+
{ object: 'exl__widget', action: 'create', data: { id: 'w2', title: 'x' } },
179+
]);
180+
expect(batch.status).toBe(200);
181+
expect(batch.body?.results).toHaveLength(2);
182+
expect(h.transaction).toHaveBeenCalledTimes(1);
183+
expect(await h.count('exl__widget')).toBe(2);
184+
});
185+
186+
it('control: a plain-named hidden object is refused, as it already was', async () => {
187+
const door = await h.data('ex_secret', { id: 's0', title: 'x' });
188+
expect(door.status).toBe(404);
189+
expect(door.body?.code).toBe('OBJECT_API_DISABLED');
190+
const batch = await h.batch([{ object: 'ex_secret', action: 'create', data: { id: 's1', title: 'x' } }]);
191+
expect(batch).toEqual({ status: door.status, body: door.body });
192+
expect(h.transaction).not.toHaveBeenCalled();
193+
expect(await h.count('ex_secret')).toBe(0);
194+
});
195+
});

‎packages/rest/src/batch-routes.ts‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export interface BatchRouteHost {
8585
enforceAuth: RestServer['enforceAuth'];
8686
enforceApiAccess: RestServer['enforceApiAccess'];
8787
enforceBatchSize: RestServer['enforceBatchSize'];
88-
loadObjectItems: RestServer['loadObjectItems'];
88+
loadObjectItem: RestServer['loadObjectItem'];
8989
}
9090

9191
/** One operation of the cross-object batch, as the spec contract parses it. */
@@ -279,25 +279,31 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string):
279279
// Enforce object-level API exposure (enable.apiEnabled /
280280
// apiMethods) for EVERY op BEFORE opening the transaction — the
281281
// batch write surface must honour the same per-object gate as the
282-
// single-record routes (ADR-0049 / #1889). Metadata is fetched
283-
// once; each distinct (object, action) is checked once.
282+
// single-record routes (ADR-0049 / #1889). Each distinct object
283+
// is read once; each distinct (object, action) is checked once.
284+
//
285+
// [#27899] Read through the single-record routes' own read
286+
// (`loadObjectItem`), so an op is judged on the object the data
287+
// door serves for its name: a legacy `ns__short` object named by
288+
// its short name is that object, as `registry.getObject`
289+
// resolves it. The object LIST keyed by declared name found
290+
// nothing under the short name, and the op passed on as unknown
291+
// to an engine that does resolve it.
284292
const p = await this.resolveProtocol(environmentId, req);
285-
const items = await this.loadObjectItems(p, environmentId);
286-
if (items.length > 0) {
287-
const byName = new Map<string, any>(items.map((o: any) => [o?.name, o]));
288-
const checked = new Set<string>();
289-
for (const op of ops) {
290-
const key = `${op.object}\u0000${op.action}`;
291-
if (checked.has(key)) continue;
292-
checked.add(key);
293-
const obj = byName.get(op.object);
294-
if (!obj) continue; // unknown object → surfaced by the op inside the tx
295-
// [#3391] Cross-object batch is a bulk surface: gate each op
296-
// as `bulk ∧ child(op.action)` — the object must grant the
297-
// `bulk` primitive AND the specific write it performs.
298-
const denial = apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action });
299-
if (denial) { res.status(denial.status).json(denial.body); return; }
300-
}
293+
const objects = new Map<string, any>();
294+
const checked = new Set<string>();
295+
for (const op of ops) {
296+
const key = `${op.object}\u0000${op.action}`;
297+
if (checked.has(key)) continue;
298+
checked.add(key);
299+
if (!objects.has(op.object)) objects.set(op.object, await this.loadObjectItem(p, environmentId, op.object));
300+
const obj = objects.get(op.object);
301+
if (!obj) continue; // unknown object → surfaced by the op inside the tx
302+
// [#3391] Cross-object batch is a bulk surface: gate each op
303+
// as `bulk ∧ child(op.action)` — the object must grant the
304+
// `bulk` primitive AND the specific write it performs.
305+
const denial = apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action });
306+
if (denial) { res.status(denial.status).json(denial.body); return; }
301307
}
302308

303309
// A write to `sys_api_key` from a request an API key

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,10 @@ export type NavDocAudienceGate = (entry: any) => boolean;
440440
* Load the object metadata items for the current protocol/environment,
441441
* coerced to a plain array. Returns `[]` when metadata is unavailable so
442442
* callers fail OPEN (the data call itself needs the same metadata and will
443-
* surface any real error). Shared by the cross-object batch route (all ops,
444-
* fetched once) and the nav-servability gate; the single-object exposure gate
445-
* reads its one object by name ({@link loadObjectItem}).
443+
* surface any real error). Read by the nav-servability gate, and by
444+
* {@link loadObjectItem} for a protocol with no by-name read; the exposure
445+
* gate of every data door, the cross-object batch route's per-op gate
446+
* included, reads its object by name through {@link loadObjectItem}.
446447
*/
447448
export async function loadObjectItems(source: MetaReadGateListSource): Promise<any[]> {
448449
try {

‎packages/rest/src/objectql-slot-consumer-census.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
*
8888
* ⚠️ The healthy control is deliberately driven with `{ operations: [] }` and
8989
* not with a real op. On the MULTI-KERNEL wiring a real op continues past this
90-
* door's engine line into `resolveProtocol`/`loadObjectItems`, which this
90+
* door's engine line into `resolveProtocol`/`loadObjectItem`, which this
9191
* harness's auth-only kernel cannot serve — measured, that answers 500
9292
* INTERNAL_ERROR for a HEALTHY engine too, which would make a 500 read here
9393
* ambiguous between "the seam answered" and "the harness ran out of kernel".

0 commit comments

Comments
 (0)