From ec6f33effdff09862acef20ad331faaa9287704c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:21:05 +0000 Subject: [PATCH 1/3] test(rest): pin the batch door's per-op exposure answer to the data door's Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR --- .../src/batch-op-exposure-object-read.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/rest/src/batch-op-exposure-object-read.test.ts diff --git a/packages/rest/src/batch-op-exposure-object-read.test.ts b/packages/rest/src/batch-op-exposure-object-read.test.ts new file mode 100644 index 00000000000..aef9c4efcdf --- /dev/null +++ b/packages/rest/src/batch-op-exposure-object-read.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27899] The cross-object batch door (`POST {basePath}/batch`) judges each + * op's API exposure on the object the data door would serve for the op's + * name, and refuses it with the data door's own answer. + * + * The `/data/:object` exposure gate reads its object by name, through the read + * that resolves a legacy `ns__short` object from its short name exactly as the + * data door's `registry.getObject` does (#27729). The batch door's per-op gate + * keyed the object LIST by each item's declared name instead, and the list + * carries no such resolution: an op naming such an object by its short name + * found "no object" there, passed as unknown, and was then written by the + * engine, which does resolve the name. It now asks the `/data` gate's read, so + * every data door gives one answer about whether an object is exposed. + * + * A REAL `ObjectQL` engine + `ObjectStackProtocolImplementation` on a REAL + * sqlite `:memory:` driver, with `objectQLProvider` wired so the batch door is + * reachable rather than answering 501; nothing below the routes is a double. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +const fields = { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + title: { name: 'title', type: 'text' as const, label: 'Title' }, +}; + +/** Legacy `ns__short` objects of one package, addressed below by short name. */ +const LEGACY_PACKAGE = 'app.ex_legacy'; +/** Exposed: no `enable` block. */ +const WIDGET = { name: 'exl__widget', label: 'Widget', systemFields: false, fields }; +/** Hidden from the API. */ +const VAULT = { name: 'exl__vault', label: 'Vault', systemFields: false, enable: { apiEnabled: false }, fields }; +/** Exposed for reads only. */ +const LEDGER = { name: 'exl__ledger', label: 'Ledger', systemFields: false, enable: { apiMethods: ['get', 'list'] }, fields }; +/** A plain-named object hidden from the API: the gate's answer before this change. */ +const SECRET = { name: 'ex_secret', label: 'Secret', systemFields: false, enable: { apiEnabled: false }, fields }; + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + engine.registry.installPackage({ id: LEGACY_PACKAGE, name: LEGACY_PACKAGE, version: '1.0.0' } as any, 'code'); + for (const o of [WIDGET, VAULT, LEDGER]) engine.registry.registerObject(o as any, LEGACY_PACKAGE); + engine.registry.registerObject(SECRET as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest = new RestServer( + server as any, protocol as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider + (async () => engine) as any, // objectQLProvider (positional arg #8) + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const routes = rest.getRoutes(); + const route = (path: string): any => { + const found = routes.find((r: any) => r.method === 'POST' && r.path === path); + if (!found) throw new Error(`route POST ${path} is not registered`); + return found; + }; + const doors = { + batch: route('/api/v1/batch'), + data: route('/api/v1/data/:object'), + createMany: route('/api/v1/data/:object/createMany'), + }; + const transaction = vi.spyOn(engine, 'transaction'); + const call = async (door: keyof typeof doors, object: string | undefined, body: unknown) => { + const res = makeRes(); + await doors[door].handler({ method: 'POST', params: object ? { object } : {}, query: {}, headers: {}, body } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + return { + engine, + transaction, + batch: (operations: unknown[]) => call('batch', undefined, { operations }), + data: (object: string, record: Record) => call('data', object, record), + createMany: (object: string, records: Array>) => call('createMany', object, records), + /** Rows of a table, counted at the engine. */ + async count(table: string) { + const rows = await engine.find(table, { where: {} }); + return Array.isArray(rows) ? rows.length : 0; + }, + }; +} + +describe('[#27899] the batch door judges each op on the object the data door serves for its name', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot(); }); + + it('premise: the data door resolves a legacy short name and refuses the hidden object behind it', async () => { + expect((h.engine.registry.getObject('vault') as any)?.name).toBe('exl__vault'); + const door = await h.data('vault', { id: 'v0', title: 'x' }); + expect(door.status).toBe(404); + expect(door.body?.code).toBe('OBJECT_API_DISABLED'); + }); + + for (const action of ['create', 'update', 'delete'] as const) { + it(`a ${action} op on a hidden object is refused with the data door's answer, before the transaction opens`, async () => { + const door = await h.data('vault', { id: 'v0', title: 'x' }); + const op = action === 'create' + ? { object: 'vault', action, data: { id: 'v1', title: 'x' } } + : { object: 'vault', action, id: 'v1', ...(action === 'update' ? { data: { title: 'y' } } : {}) }; + const batch = await h.batch([op]); + expect(batch).toEqual({ status: door.status, body: door.body }); + expect(h.transaction).not.toHaveBeenCalled(); + expect(await h.count('exl__vault')).toBe(0); + }); + } + + it('an op the object does not expose is refused with the bulk data door\'s 405, status and body', async () => { + const bulk = await h.createMany('ledger', [{ id: 'l0', title: 'x' }]); + expect(bulk.status).toBe(405); + expect(bulk.body?.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + const batch = await h.batch([{ object: 'ledger', action: 'create', data: { id: 'l1', title: 'x' } }]); + expect(batch).toEqual(bulk); + expect(h.transaction).not.toHaveBeenCalled(); + expect(await h.count('exl__ledger')).toBe(0); + }); + + it('one refused op refuses the whole batch: an exposed op before it is never written', async () => { + const batch = await h.batch([ + { object: 'widget', action: 'create', data: { id: 'w1', title: 'x' } }, + { object: 'vault', action: 'create', data: { id: 'v1', title: 'x' } }, + ]); + expect(batch.status).toBe(404); + expect(batch.body?.code).toBe('OBJECT_API_DISABLED'); + expect(h.transaction).not.toHaveBeenCalled(); + expect(await h.count('exl__widget')).toBe(0); + expect(await h.count('exl__vault')).toBe(0); + }); + + // ── controls: answers that do not move ──────────────────────────────────── + + it('control: the hidden object\'s full name is refused, as it already was', async () => { + const door = await h.data('exl__vault', { id: 'v0', title: 'x' }); + expect(door.status).toBe(404); + const batch = await h.batch([{ object: 'exl__vault', action: 'create', data: { id: 'v1', title: 'x' } }]); + expect(batch).toEqual({ status: door.status, body: door.body }); + expect(await h.count('exl__vault')).toBe(0); + }); + + it('control: an exposed object is written under its short name and under its full name', async () => { + const batch = await h.batch([ + { object: 'widget', action: 'create', data: { id: 'w1', title: 'x' } }, + { object: 'exl__widget', action: 'create', data: { id: 'w2', title: 'x' } }, + ]); + expect(batch.status).toBe(200); + expect(batch.body?.results).toHaveLength(2); + expect(h.transaction).toHaveBeenCalledTimes(1); + expect(await h.count('exl__widget')).toBe(2); + }); + + it('control: a plain-named hidden object is refused, as it already was', async () => { + const door = await h.data('ex_secret', { id: 's0', title: 'x' }); + expect(door.status).toBe(404); + expect(door.body?.code).toBe('OBJECT_API_DISABLED'); + const batch = await h.batch([{ object: 'ex_secret', action: 'create', data: { id: 's1', title: 'x' } }]); + expect(batch).toEqual({ status: door.status, body: door.body }); + expect(h.transaction).not.toHaveBeenCalled(); + expect(await h.count('ex_secret')).toBe(0); + }); +}); From db7513a47eef895991d4dffdad0569ef0f112062 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:22:53 +0000 Subject: [PATCH 2/3] test(rest): read the batch door's exposure pins over a real metadata store Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR --- packages/rest/src/batch-op-exposure-object-read.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/rest/src/batch-op-exposure-object-read.test.ts b/packages/rest/src/batch-op-exposure-object-read.test.ts index aef9c4efcdf..91bd4782de2 100644 --- a/packages/rest/src/batch-op-exposure-object-read.test.ts +++ b/packages/rest/src/batch-op-exposure-object-read.test.ts @@ -23,6 +23,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; const liveEngines: ObjectQL[] = []; @@ -67,6 +68,12 @@ async function boot() { for (const o of [WIDGET, VAULT, LEDGER]) engine.registry.registerObject(o as any, LEGACY_PACKAGE); engine.registry.registerObject(SECRET as any); await engine.syncSchemas(); + // The metadata store every exposure gate reads through. Without it the + // protocol's object reads answer SERVICE_UNAVAILABLE and every gate fails + // OPEN by design (#3545), on this door and on the data door alike. + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } const protocol = new ObjectStackProtocolImplementation(engine as any); const noop = () => {}; From 2019e190f5c40af31fbabccf6fb2d5a3fa612e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:24:15 +0000 Subject: [PATCH 3/3] fix(rest)!: the batch door judges each op's exposure on the object the data door serves The cross-object batch door's per-op exposure gate now reads each op's object through the same by-name read as the /data/:object gate, moved into one private RestServer method both gates call, instead of keying the object list by declared name. Each distinct object is read once per request. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cwgfUuNAvcUg3JzBiR --- .../27899-batch-op-exposure-object-read.md | 18 ++++++++ packages/rest/src/batch-routes.ts | 44 ++++++++++-------- packages/rest/src/meta-item-read-gate.ts | 7 +-- .../src/objectql-slot-consumer-census.test.ts | 2 +- packages/rest/src/rest-server.ts | 46 ++++++++++++------- packages/rest/src/rest.test.ts | 12 ++--- 6 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 .changeset/27899-batch-op-exposure-object-read.md diff --git a/.changeset/27899-batch-op-exposure-object-read.md b/.changeset/27899-batch-op-exposure-object-read.md new file mode 100644 index 00000000000..77a55659af9 --- /dev/null +++ b/.changeset/27899-batch-op-exposure-object-read.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest)!: the cross-object batch door judges each op's API exposure on the object the data door serves for its name (#27899) + +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. +Metadata-protocol: none + + + +**BREAKING** the cross-object batch door (`POST {basePath}/batch`) refuses an op it used to write (`@objectstack/rest`), per triage 6514615922. + +- **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. +- **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. +- **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. + +**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. diff --git a/packages/rest/src/batch-routes.ts b/packages/rest/src/batch-routes.ts index 0707bb5c3fb..0ce906d707b 100644 --- a/packages/rest/src/batch-routes.ts +++ b/packages/rest/src/batch-routes.ts @@ -85,7 +85,7 @@ export interface BatchRouteHost { enforceAuth: RestServer['enforceAuth']; enforceApiAccess: RestServer['enforceApiAccess']; enforceBatchSize: RestServer['enforceBatchSize']; - loadObjectItems: RestServer['loadObjectItems']; + loadObjectItem: RestServer['loadObjectItem']; } /** One operation of the cross-object batch, as the spec contract parses it. */ @@ -279,25 +279,31 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string): // Enforce object-level API exposure (enable.apiEnabled / // apiMethods) for EVERY op BEFORE opening the transaction — the // batch write surface must honour the same per-object gate as the - // single-record routes (ADR-0049 / #1889). Metadata is fetched - // once; each distinct (object, action) is checked once. + // single-record routes (ADR-0049 / #1889). Each distinct object + // is read once; each distinct (object, action) is checked once. + // + // [#27899] Read through the single-record routes' own read + // (`loadObjectItem`), so an op is judged on the object the data + // door serves for its name: a legacy `ns__short` object named by + // its short name is that object, as `registry.getObject` + // resolves it. The object LIST keyed by declared name found + // nothing under the short name, and the op passed on as unknown + // to an engine that does resolve it. const p = await this.resolveProtocol(environmentId, req); - const items = await this.loadObjectItems(p, environmentId); - if (items.length > 0) { - const byName = new Map(items.map((o: any) => [o?.name, o])); - const checked = new Set(); - for (const op of ops) { - const key = `${op.object}\u0000${op.action}`; - if (checked.has(key)) continue; - checked.add(key); - const obj = byName.get(op.object); - if (!obj) continue; // unknown object → surfaced by the op inside the tx - // [#3391] Cross-object batch is a bulk surface: gate each op - // as `bulk ∧ child(op.action)` — the object must grant the - // `bulk` primitive AND the specific write it performs. - const denial = apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action }); - if (denial) { res.status(denial.status).json(denial.body); return; } - } + const objects = new Map(); + const checked = new Set(); + for (const op of ops) { + const key = `${op.object}\u0000${op.action}`; + if (checked.has(key)) continue; + checked.add(key); + if (!objects.has(op.object)) objects.set(op.object, await this.loadObjectItem(p, environmentId, op.object)); + const obj = objects.get(op.object); + if (!obj) continue; // unknown object → surfaced by the op inside the tx + // [#3391] Cross-object batch is a bulk surface: gate each op + // as `bulk ∧ child(op.action)` — the object must grant the + // `bulk` primitive AND the specific write it performs. + const denial = apiAccessDenialFromEnable(obj.enable, op.object, 'bulk', { bulkChild: op.action }); + if (denial) { res.status(denial.status).json(denial.body); return; } } // A write to `sys_api_key` from a request an API key diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 748bc22ec4f..a6688ea7c4d 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -440,9 +440,10 @@ export type NavDocAudienceGate = (entry: any) => boolean; * Load the object metadata items for the current protocol/environment, * coerced to a plain array. Returns `[]` when metadata is unavailable so * callers fail OPEN (the data call itself needs the same metadata and will - * surface any real error). Shared by the cross-object batch route (all ops, - * fetched once) and the nav-servability gate; the single-object exposure gate - * reads its one object by name ({@link loadObjectItem}). + * surface any real error). Read by the nav-servability gate, and by + * {@link loadObjectItem} for a protocol with no by-name read; the exposure + * gate of every data door, the cross-object batch route's per-op gate + * included, reads its object by name through {@link loadObjectItem}. */ export async function loadObjectItems(source: MetaReadGateListSource): Promise { try { diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index a519180a4a6..d4e459a891f 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -87,7 +87,7 @@ * * ⚠️ The healthy control is deliberately driven with `{ operations: [] }` and * not with a real op. On the MULTI-KERNEL wiring a real op continues past this - * door's engine line into `resolveProtocol`/`loadObjectItems`, which this + * door's engine line into `resolveProtocol`/`loadObjectItem`, which this * harness's auth-only kernel cannot serve — measured, that answers 500 * INTERNAL_ERROR for a HEALTHY engine too, which would make a 500 read here * ambiguous between "the seam answered" and "the harness ran out of kernel". diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a7a9838cfc3..e7edfcf3873 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2692,12 +2692,7 @@ export class RestServer { // [#24031] The ONE object this gate judges, read by name — never the // whole object list, whose decoration diagnosed every object on every // request. A protocol with no by-name read is asked the list. - const listSource = this.metaListSource(p, environmentId); - const getObjectItem = this.objectItemReader(p, environmentId); - const obj = await metaReadGate.loadObjectItem( - getObjectItem ? { ...listSource, getObjectItem } : listSource, - objectName, - ); + const obj = await this.loadObjectItem(p, environmentId, objectName); // [#3770] Unknown object → no declared exposure policy to enforce here; // the data path's registry gate 404s it. See the doc comment above. if (!obj) return false; @@ -2901,14 +2896,31 @@ export class RestServer { } /** - * Load the object metadata items for the current protocol/environment, - * coerced to a plain array — `loadObjectItems` in - * `./meta-item-read-gate.ts` (fail OPEN and logged, #3545). Shared by - * `enforceApiAccess` (one object), the cross-object batch route (all ops, - * fetched once) and the nav-servability gate. + * The ONE object the API-exposure gate judges for `name`, or `undefined` + * when no object is behind it — `loadObjectItem` in + * `./meta-item-read-gate.ts` (fail OPEN and logged, #3545), over this + * protocol's by-name read where it has one ({@link objectItemReader}), + * else its list read. + * + * [#27899] The one read of every data door's exposure gate: + * `enforceApiAccess` asks it for the route's `:object`, the cross-object + * batch route for each op's object. The by-name read resolves a legacy + * `ns__short` object from its short name as the data door's + * `registry.getObject` does, so both doors judge the object the data call + * then serves; the batch route used to key the object LIST by declared + * name instead, and the list carries no such resolution. */ - private async loadObjectItems(p: RestProtocol, environmentId: string | undefined): Promise { - return metaReadGate.loadObjectItems(this.metaListSource(p, environmentId)); + private async loadObjectItem( + p: RestProtocol, + environmentId: string | undefined, + name: string, + ): Promise { + const listSource = this.metaListSource(p, environmentId); + const getObjectItem = this.objectItemReader(p, environmentId); + return metaReadGate.loadObjectItem( + getObjectItem ? { ...listSource, getObjectItem } : listSource, + name, + ); } /** @@ -3649,9 +3661,9 @@ export class RestServer { * protocol has no list read at all. * * [#25783] The `object` list on the protocol's EXECUTION face where it has - * one: its readers — the nav-servability gate on `/meta/app`, the batch - * route's exposure gate, the by-name gate's list fallback — read each - * object's `enable` and serve none of it, and the served face diagnosed + * one: its readers — the nav-servability gate on `/meta/app` and the + * by-name exposure gate's list fallback — read each object's `enable` and + * serve none of it, and the served face diagnosed * every object of the registry on every such request (a full schema * `safeParse` each). Same sources, merge and governance either way; the * `book` and `doc` lists stay on the served face, unchanged. @@ -6213,7 +6225,7 @@ export class RestServer { enforceAuth: (...a) => this.enforceAuth(...a), enforceApiAccess: (...a) => this.enforceApiAccess(...a), enforceBatchSize: (...a) => this.enforceBatchSize(...a), - loadObjectItems: (...a) => this.loadObjectItems(...a), + loadObjectItem: (...a) => this.loadObjectItem(...a), }; registerBatchEndpoints.call(host, basePath); } diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index d199b28f507..59566e80e68 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -4053,13 +4053,13 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { // legitimately-empty registry stays silent — so a persistent outage, during // which the gate silently allows every op, is no longer invisible. describe('metadata-unavailable fail-open observability (#3545)', () => { - it('loadObjectItems fails OPEN (returns []) AND logs a THROWN metadata read', async () => { + it('loadObjectItem fails OPEN (no object) AND logs a THROWN metadata read', async () => { const { rest, protocol } = setup(undefined); protocol.getMetaItems = vi.fn().mockRejectedValue(new Error('metadata store down')); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const items = await (rest as any).loadObjectItems(protocol, undefined); - expect(items).toEqual([]); // fail-open preserved: gate abstains + const item = await (rest as any).loadObjectItem(protocol, undefined, 'widget'); + expect(item).toBeUndefined(); // fail-open preserved: gate abstains expect(warnSpy).toHaveBeenCalled(); expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).toContain('api-exposure gate'); } finally { @@ -4067,13 +4067,13 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { } }); - it('loadObjectItems stays SILENT on a legitimately-empty registry (no false alarm)', async () => { + it('loadObjectItem stays SILENT on a legitimately-empty registry (no false alarm)', async () => { const { rest, protocol } = setup(undefined); protocol.getMetaItems = vi.fn().mockResolvedValue([]); // empty, not thrown const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const items = await (rest as any).loadObjectItems(protocol, undefined); - expect(items).toEqual([]); + const item = await (rest as any).loadObjectItem(protocol, undefined, 'widget'); + expect(item).toBeUndefined(); expect(warnSpy).not.toHaveBeenCalled(); // distinguishes real fault from empty } finally { warnSpy.mockRestore();