From 3f612b8d571f6aa1e076bd38b0e4326339717af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:09:47 +0000 Subject: [PATCH 1/5] fix(rest): the API-exposure gate reads the one object it judges, by name enforceApiAccess asked for the whole object list and searched it for one name. The list read decorates every object with a full schema diagnostic, so every /data request paid a diagnostic pass over the whole registry to read one object's enable flags. The gate now asks the protocol's by-name read (getMetaItem) for that object. A thrown read still fails open and logs; no object behind the name is still no policy; a protocol with no by-name read is asked the list as before. Nothing is cached. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 57 ++++++++++++++++++++++-- packages/rest/src/rest-server.ts | 23 +++++++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 8fd8cdef649..e9a6080441e 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -145,6 +145,14 @@ export interface MetaReadGateListSource { * an empty doc corpus is "every doc unclaimed, so `org`" — both GRANT. */ listMetaItems(type: 'book' | 'doc' | 'object'): Promise | undefined; + /** + * [#24031] One `getMetaItem` BY-NAME read of the object `name`, env-scoped + * the way {@link listMetaItems} is — absent when this transport's protocol + * has no by-name read, and then {@link loadObjectItem} asks the list as + * before. Answers the read's envelope (`{ item }`). A THROW is a fault, + * ⛔ never "no such object". + */ + getObjectItem?(name: string): Promise; } /** Everything {@link createMetaItemReadGate} reads, supplied by the transport. */ @@ -402,9 +410,9 @@ 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 `enforceApiAccess` (one object), the - * cross-object batch route (all ops, fetched once) and the nav-servability - * gate. + * 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}). */ export async function loadObjectItems(source: MetaReadGateListSource): Promise { try { @@ -431,6 +439,49 @@ export async function loadObjectItems(source: MetaReadGateListSource): Promise { + if (typeof source.getObjectItem !== 'function') { + return (await loadObjectItems(source)).find((o: any) => o?.name === name); + } + try { + const envelope: any = await source.getObjectItem(name); + const item = envelope?.item; + return item !== null && typeof item === 'object' && item.name === name ? item : undefined; + } catch (err) { + logWarn( + '[REST] api-exposure gate: object metadata read failed — failing open ' + + '(auth + CRUD/FLS/RLS still enforce on the data call)', + (err as Error)?.message ?? err, + ); + return undefined; + } +} + /** Whether any of these books carries a `{ permissionSet }` audience. */ export function anyPermissionSetAudience(books: readonly any[]): boolean { return books.some( diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b429a90b19d..ffc716c6a29 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2633,8 +2633,27 @@ export class RestServer { ): Promise { const objectName = req?.params?.object; if (!objectName) return false; - const items = await this.loadObjectItems(p, environmentId); - const obj = items.find((o: any) => o?.name === objectName); + // [#24031] The ONE object this gate judges, read by name — never the + // whole object list, whose decoration diagnosed every object on every + // request. Same env-scoped request shape the list read sends + // (`metaListSource`); a protocol with no by-name read is asked the list. + const listSource = this.metaListSource(p, environmentId); + const obj = await metaReadGate.loadObjectItem( + typeof p.getMetaItem === 'function' + ? { + ...listSource, + getObjectItem: (name) => { + const itemRequest: TransportScopedMetaRequest = { + type: 'object', + name, + ...(environmentId ? { environmentId } : {}), + }; + return p.getMetaItem(itemRequest); + }, + } + : listSource, + 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; From 7a318c4fe046aa21bb664656e5a596c4a70faa07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:40:10 +0000 Subject: [PATCH 2/5] test(rest): pin that the exposure gate makes zero object-list reads per /data request Over the real protocol and a real SchemaRegistry, unscoped and environment-scoped: list, get, create, update and delete each make zero getMetaItems calls; the gate's 404/405 answers are unchanged for a code-registered, an extended and a stored object; an unknown object is answered by the data path's own 404 with nothing logged; a throwing by-name read fails open and logs; a protocol with no by-name read is asked the list. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- ...rest-exposure-gate-one-object-read.test.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 packages/rest/src/rest-exposure-gate-one-object-read.test.ts diff --git a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts new file mode 100644 index 00000000000..7c062ac1781 --- /dev/null +++ b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts @@ -0,0 +1,282 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24031] The API-exposure gate on the `/data` doors reads the ONE object it +// judges, by name, and never the whole object list. +// +// The defect: `enforceApiAccess` asked `getMetaItems({ type: 'object' })` and +// searched the answer for one name. The list read decorates every object it +// serves with a full schema diagnostic, so every authenticated `/data` request +// paid a diagnostic pass over the whole registry to read one object's +// `enable` block — measured at 62-72 % of a data request's time. +// +// What this file holds, over the REAL protocol (`ObjectStackProtocolImplementation`) +// over a REAL `SchemaRegistry`, unscoped and environment-scoped: +// +// 1. COST — a list, get, create, update and delete each make ZERO object-list +// reads. `getMetaItems` (and `getMetaItemsForExecution`) are the only +// faces into the protocol's list body, whose served face is where the +// list-wide diagnostic (`decorateMetadataItems`) runs, so zero calls there +// is zero diagnostic passes. They are counted on the instance, which also +// catches the protocol's own internal `this.getMetaItems` calls. +// 2. ANSWERS — the same 404 / 405 as the list-backed gate gave, for a +// code-registered object, a stored environment object and an object +// another package extends (an extension carries no `enable` of its own: +// `ObjectExtensionSchema` is strict and the registry's merge set is +// closed, so the owner's `enable` is the one that counts); an unknown +// object is still no policy, answered by the data path's own 404. +// 3. FAULTS — a THROWING by-name read fails OPEN and logs, as #3545 requires; +// "no object" logs nothing. +// 4. FALLBACK — a protocol with no by-name read is asked the list, as before. +// +// ⛔ The count rows are the regression this file exists for: put the list read +// back in `enforceApiAccess` and they go red while every answer row stays +// green — which is the point, because "still 405" is exactly what the slow +// gate looked like too. + +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'; +import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +/** Unrestricted — every door reaches its data call. */ +const OPEN = { name: 'open_thing', label: 'Open Thing', fields: { title: { name: 'title', type: 'text' } } }; +/** Code-registered, whitelisted to reads. */ +const CODE = { + name: 'code_widget', label: 'Code Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get', 'list'] }, +}; +/** Code-registered, hidden from the API. */ +const HIDDEN = { + name: 'code_hidden', label: 'Code Hidden', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiEnabled: false }, +}; +/** Owned by one package, extended by another; the owner restricts delete. */ +const OWNER = { + name: 'ext_account', label: 'Account', + fields: { name: { name: 'name', type: 'text' } }, + enable: { apiMethods: ['get', 'list', 'update'] }, +}; +const EXTENSION = { + name: 'ext_account', + fields: { loyalty_tier: { name: 'loyalty_tier', type: 'text' } }, +}; +/** Stored in the environment (`sys_metadata`), registered by no package. */ +const STORED = { + name: 'stored_widget', label: 'Stored Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get'] }, +}; +const UNKNOWN = 'no_such_object'; + +const DATA_METHODS = ['findData', 'getData', 'createData', 'updateData', 'deleteData'] as const; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +interface Host { + rest: RestServer; + protocol: any; + /** Object-list reads since the last `reset()`. */ + listReads: () => number; + reset: () => void; +} + +function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): Host { + const registry = new SchemaRegistry(); + registry.registerObject(clone(OPEN) as never, 'pkg_code', undefined, 'own'); + registry.registerObject(clone(CODE) as never, 'pkg_code', undefined, 'own'); + registry.registerObject(clone(HIDDEN) as never, 'pkg_code', undefined, 'own'); + registry.registerObject(clone(OWNER) as never, 'pkg_owner', undefined, 'own'); + registry.registerObject(clone(EXTENSION) as never, 'pkg_extender', undefined, 'extend', 210); + + const rows = [{ + id: 'row_stored', + type: 'object', + name: STORED.name, + state: 'active', + organization_id: null, + package_id: null, + metadata: JSON.stringify(STORED), + }]; + const matches = (r: Record, w: Record) => + Object.entries(w).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (r[k] ?? null) === (v ?? null); + }); + const engine = { + registry, + find: async (table: string, q: { where: Record }) => + table === 'sys_metadata' ? rows.filter((r) => matches(r, q.where)) : [], + findOne: async (table: string, q: { where: Record }) => { + assertEngineFindOnePredicate(table, q as never); + return table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) : undefined; + }, + }; + + const protocol: any = new ObjectStackProtocolImplementation( + engine as never, + () => new Map(), + opts.environmentId, + ); + + let reads = 0; + for (const face of ['getMetaItems', 'getMetaItemsForExecution'] as const) { + const real = protocol[face].bind(protocol); + protocol[face] = async (...a: unknown[]) => { reads += 1; return real(...a); }; + } + if (opts.withoutByName) protocol.getMetaItem = undefined; + + // The data calls: stubbed for every object this host declares, so a door + // the gate clears answers 200/201; the REAL call for an unknown object, so + // its answer is the data path's own (the registry gate's 404). + for (const m of DATA_METHODS) { + const real = protocol[m].bind(protocol); + protocol[m] = vi.fn(async (req: any) => { + if (req?.object === UNKNOWN) return real(req); + if (m === 'findData') return { object: req.object, records: [], total: 0 }; + if (m === 'deleteData') return { object: req.object, id: req.id, success: true }; + return { object: req.object, id: req.id ?? 'r1', record: { id: req.id ?? 'r1' } }; + }); + } + + const rest = new RestServer(createMockServer() as never, protocol as never, { api: { requireAuth: false } } as never); + (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = + async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + return { rest, protocol, listReads: () => reads, reset: () => { reads = 0; } }; +} + +type Door = 'list' | 'get' | 'create' | 'update' | 'delete'; +const DOORS: Record = { + list: { method: 'GET', path: '/api/v1/data/:object' }, + get: { method: 'GET', path: '/api/v1/data/:object/:id' }, + create: { method: 'POST', path: '/api/v1/data/:object' }, + update: { method: 'PATCH', path: '/api/v1/data/:object/:id' }, + delete: { method: 'DELETE', path: '/api/v1/data/:object/:id' }, +}; + +async function call(h: Host, door: Door, object: string): Promise<{ status: number; body: any }> { + const { method, path } = DOORS[door]; + const route = (h.rest as any).getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`route not registered: ${method} ${path}`); + let status = 200; + let body: any; + const res: any = { + status: (c: number) => { status = c; return res; }, + json: (b: unknown) => { body = b; return res; }, + send: (b: unknown) => { body = b; return res; }, + header: () => res, setHeader: () => res, write: () => true, end: () => res, + }; + const params: Record = { object }; + if (door === 'get' || door === 'update' || door === 'delete') params.id = 'r1'; + const reqBody = door === 'create' || door === 'update' ? { title: 'x' } : {}; + await route.handler({ method, path, params, query: {}, headers: {}, body: reqBody }, res); + return { status, body }; +} + +const errorCode = (body: any): unknown => body?.code ?? body?.error?.code; + +// This file OBSERVES the REST fault log (the #3545 fail-open line, and its +// absence on a not-found), so it declares the level it observes under; the +// package harness runs silent (`vitest.config.ts`, `check:rest-log-spy-declared`). +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); +afterEach(() => { vi.restoreAllMocks(); }); + +for (const scope of [{ label: 'unscoped kernel', environmentId: undefined }, { label: 'environment-scoped kernel', environmentId: 'env_test' }]) { + describe(`[#24031] the exposure gate reads one object by name — ${scope.label}`, () => { + it.each(Object.keys(DOORS) as Door[])('a /data %s makes ZERO object-list reads (no list-wide diagnostic pass)', async (door) => { + const h = host({ environmentId: scope.environmentId }); + h.reset(); + const out = await call(h, door, OPEN.name); + expect(out.status, JSON.stringify(out.body)).toBeLessThan(300); + expect(h.listReads()).toBe(0); + }); + + it('a refused request makes ZERO object-list reads too', async () => { + const h = host({ environmentId: scope.environmentId }); + h.reset(); + const out = await call(h, 'create', CODE.name); + expect(out.status).toBe(405); + expect(h.listReads()).toBe(0); + }); + + it('asks the by-name read for exactly the object in the path', async () => { + const h = host({ environmentId: scope.environmentId }); + const spy = vi.spyOn(h.protocol, 'getMetaItem'); + await call(h, 'list', CODE.name); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toEqual({ type: 'object', name: CODE.name }); + }); + + it.each([ + ['code-registered, whitelisted', CODE.name, 'create', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'], + ['code-registered, whitelisted', CODE.name, 'list', 200, undefined], + ['code-registered, hidden', HIDDEN.name, 'get', 404, 'OBJECT_API_DISABLED'], + ['extended by another package', OWNER.name, 'delete', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'], + ['extended by another package', OWNER.name, 'update', 200, undefined], + ['stored in the environment', STORED.name, 'list', 405, 'OBJECT_API_METHOD_NOT_ALLOWED'], + ['stored in the environment', STORED.name, 'get', 200, undefined], + ] as const)('%s object (%s) — %s answers %i', async (_kind, object, door, status, code) => { + const h = host({ environmentId: scope.environmentId }); + const out = await call(h, door, object); + expect(out.status, JSON.stringify(out.body)).toBe(status); + if (code) { + expect(errorCode(out.body)).toBe(code); + // The data call is never reached on a refusal. + for (const m of DATA_METHODS) expect(h.protocol[m]).not.toHaveBeenCalled(); + } + }); + + it('a 405 still names the EFFECTIVE operation set the object declares', async () => { + const h = host({ environmentId: scope.environmentId }); + const out = await call(h, 'create', STORED.name); + expect(out.status).toBe(405); + expect(out.body.allowed).toEqual(['get']); + }); + + it('an unknown object is no policy here: the data path answers its own 404, and nothing is logged', async () => { + const h = host({ environmentId: scope.environmentId }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const out = await call(h, 'list', UNKNOWN); + expect(h.protocol.findData).toHaveBeenCalledTimes(1); + expect(out.status).toBe(404); + expect(errorCode(out.body)).toBe('OBJECT_NOT_FOUND'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('a THROWING by-name read fails OPEN and logs it (#3545)', async () => { + const h = host({ environmentId: scope.environmentId }); + h.protocol.getMetaItem = vi.fn().mockRejectedValue(new Error('metadata store unreachable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // `create` is refused on this object when its metadata CAN be read. + const out = await call(h, 'create', CODE.name); + expect(out.status).toBe(201); + expect(h.protocol.createData).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![1]).toBe('metadata store unreachable'); + expect(h.listReads()).toBe(0); + }); + }); +} + +describe('[#24031] a protocol with no by-name read is asked the list, as before', () => { + it('answers the same 405 off the list read', async () => { + const h = host({ withoutByName: true }); + h.reset(); + const out = await call(h, 'create', CODE.name); + expect(out.status).toBe(405); + expect(errorCode(out.body)).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(h.listReads()).toBe(1); + }); +}); From e10990366e4de0dd8767e2190bbe1538d43104aa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:23:43 +0000 Subject: [PATCH 3/5] test(rest): the exposure-gate pin's find double honours the caller's limit, and its findOne double is ledgered check:objectql-double-limit graded the new file's find double limit-blind; it now applies a numeric limit after the filter. check:engine-double-contract asked for the new file's findOne double in the pinned ledger (check-engine-double-contract --write added that one entry and nothing else). Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../rest/src/rest-exposure-gate-one-object-read.test.ts | 6 ++++-- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts index 7c062ac1781..943cf22aa1e 100644 --- a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts +++ b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts @@ -115,8 +115,10 @@ function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): H }); const engine = { registry, - find: async (table: string, q: { where: Record }) => - table === 'sys_metadata' ? rows.filter((r) => matches(r, q.where)) : [], + find: async (table: string, q: { where: Record; limit?: number }) => { + const hits = table === 'sys_metadata' ? rows.filter((r) => matches(r, q.where)) : []; + return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; + }, findOne: async (table: string, q: { where: Record }) => { assertEngineFindOnePredicate(table, q as never); return table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) : undefined; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8952c2ceafe..0f9aa1e1bdb 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3866,6 +3866,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/rest/src/rest-exposure-gate-one-object-read.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts", "verb": "findOne", From a6ea3e45beeb021cb1726cafb7f3ffdf921a12c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:35:51 +0000 Subject: [PATCH 4/5] test(rest): exposure doubles answer the by-name read the way their list does; pin the disabled-package class; changeset Three exposure suites' protocol doubles answered getMetaItem with a body their own getMetaItems contradicted. The real protocol's two reads agree, so each double's by-name read now answers the object its list holds; no assertion changes. The gate pin gains the one class whose answer moves: on a disabled package's object a denied operation answers the exposure verdict (405), and an allowed one still reaches the data plane's 404 OBJECT_PACKAGE_DISABLED. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../24031-exposure-gate-one-object-read.md | 15 ++++++ .../rest/src/rest-bulk-path-object.test.ts | 9 +++- .../src/rest-export-permission-gate.test.ts | 7 ++- ...rest-exposure-gate-one-object-read.test.ts | 48 +++++++++++++++++-- packages/rest/src/rest.test.ts | 9 ++++ 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 .changeset/24031-exposure-gate-one-object-read.md diff --git a/.changeset/24031-exposure-gate-one-object-read.md b/.changeset/24031-exposure-gate-one-object-read.md new file mode 100644 index 00000000000..71e86880948 --- /dev/null +++ b/.changeset/24031-exposure-gate-one-object-read.md @@ -0,0 +1,15 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the API-exposure check on every `/data` request reads only the object it checks, not the whole object list + +Clause-②: no + +- **Faster `/data` requests.** Before each `/data` read or write, the REST layer checks the object's `enable.apiEnabled` and `enable.apiMethods`. To read that one object's flags, the check used to load every object's metadata and run a full schema diagnostic on each one. It now reads only the object named in the URL. Measured on the showcase app on a shared machine: 33 `GET /api/v1/data/showcase_account` calls took 647 ms instead of 2421 ms (-73 %), and the metadata read behind the check fell from 56-61 ms to about 4.5 ms per request. +- **Same answers.** Code-registered objects, objects stored in the environment and objects that another package extends get the same `404 OBJECT_API_DISABLED` and `405 OBJECT_API_METHOD_NOT_ALLOWED` as before. An unknown object is still answered by the data path's `404 OBJECT_NOT_FOUND`. When the metadata store cannot be read, the check still lets the request through and logs its `api-exposure gate` warning. Nothing is cached, so a metadata change applies from the next request. +- **One refusal changes, for an object whose package is disabled.** A denied operation on such an object now gets the exposure answer instead of the disabled-package one: + - an operation outside `apiMethods`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `405 OBJECT_API_METHOD_NOT_ALLOWED`; + - any operation on an object with `apiEnabled: false`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `404 OBJECT_API_DISABLED`. + + An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. No accepted request is now refused, and no refused request is now accepted. diff --git a/packages/rest/src/rest-bulk-path-object.test.ts b/packages/rest/src/rest-bulk-path-object.test.ts index af6d62b296a..1696d43ae9b 100644 --- a/packages/rest/src/rest-bulk-path-object.test.ts +++ b/packages/rest/src/rest-bulk-path-object.test.ts @@ -47,7 +47,14 @@ function setup() { { name: 'open' }, { name: 'locked', enable: { apiEnabled: false } }, ]), - getMetaItem: vi.fn().mockResolvedValue({}), + // [#24031] The gate reads its one object BY NAME; this by-name read + // answers the object the list above holds, as the real protocol's two + // reads agree. + getMetaItem: vi.fn(async (req: any) => ({ + type: req?.type, + name: req?.name, + item: (await protocol.getMetaItems({ type: req?.type })).find((o: any) => o?.name === req?.name), + })), findData: vi.fn().mockResolvedValue([]), updateManyData, deleteManyData, diff --git a/packages/rest/src/rest-export-permission-gate.test.ts b/packages/rest/src/rest-export-permission-gate.test.ts index 6715c3f1e31..b29c0a0b68f 100644 --- a/packages/rest/src/rest-export-permission-gate.test.ts +++ b/packages/rest/src/rest-export-permission-gate.test.ts @@ -172,9 +172,12 @@ describe('export route — user-level export axis (#3544)', () => { // export (405), this axis decides whether the CALLER may use it (403). An // object that exposes no export must answer 405 whatever the user holds. const findData = vi.fn().mockResolvedValue({ object: 'task', records: ROWS }); + // [#24031] The gate reads its one object BY NAME; the by-name read answers + // the object the list holds, as the real protocol's two reads agree. + const exposedForGetOnly = { ...TASK, enable: { apiMethods: ['get'] } }; const protocol: any = { - getMetaItems: vi.fn().mockResolvedValue({ items: [{ ...TASK, enable: { apiMethods: ['get'] } }] }), - getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }), + getMetaItems: vi.fn().mockResolvedValue({ items: [exposedForGetOnly] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: exposedForGetOnly }), findData, }; const rest = new RestServer( diff --git a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts index 943cf22aa1e..8c34c977b3e 100644 --- a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts +++ b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts @@ -27,6 +27,10 @@ // 3. FAULTS — a THROWING by-name read fails OPEN and logs, as #3545 requires; // "no object" logs nothing. // 4. FALLBACK — a protocol with no by-name read is asked the list, as before. +// 5. A DISABLED PACKAGE'S OBJECT — the one class whose answer moves: the list +// omits it, the by-name read serves it, so a DENIED operation now answers +// the exposure verdict instead of the data plane's 404 +// `OBJECT_PACKAGE_DISABLED`; an ALLOWED one still gets that 404. // // ⛔ The count rows are the regression this file exists for: put the list read // back in `enforceApiAccess` and they go red while every answer row stays @@ -71,6 +75,13 @@ const STORED = { enable: { apiMethods: ['get'] }, }; const UNKNOWN = 'no_such_object'; +/** Owned by a package the test DISABLES; whitelisted to reads. */ +const OFF = { + name: 'off_widget', label: 'Off Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get', 'list'] }, +}; +const OFF_PACKAGE = 'pkg_off'; const DATA_METHODS = ['findData', 'getData', 'createData', 'updateData', 'deleteData'] as const; @@ -86,6 +97,7 @@ function createMockServer() { interface Host { rest: RestServer; protocol: any; + registry: SchemaRegistry; /** Object-list reads since the last `reset()`. */ listReads: () => number; reset: () => void; @@ -98,6 +110,8 @@ function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): H registry.registerObject(clone(HIDDEN) as never, 'pkg_code', undefined, 'own'); registry.registerObject(clone(OWNER) as never, 'pkg_owner', undefined, 'own'); registry.registerObject(clone(EXTENSION) as never, 'pkg_extender', undefined, 'extend', 210); + registry.installPackage({ id: OFF_PACKAGE, name: OFF_PACKAGE, version: '1.0.0' } as never, 'code'); + registry.registerObject(clone(OFF) as never, OFF_PACKAGE, undefined, 'own'); const rows = [{ id: 'row_stored', @@ -139,12 +153,13 @@ function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): H if (opts.withoutByName) protocol.getMetaItem = undefined; // The data calls: stubbed for every object this host declares, so a door - // the gate clears answers 200/201; the REAL call for an unknown object, so - // its answer is the data path's own (the registry gate's 404). + // the gate clears answers 200/201; the REAL call for an unknown object and + // for the disabled package's object, so their answers are the data path's + // own (the registry gate's 404s). for (const m of DATA_METHODS) { const real = protocol[m].bind(protocol); protocol[m] = vi.fn(async (req: any) => { - if (req?.object === UNKNOWN) return real(req); + if (req?.object === UNKNOWN || req?.object === OFF.name) return real(req); if (m === 'findData') return { object: req.object, records: [], total: 0 }; if (m === 'deleteData') return { object: req.object, id: req.id, success: true }; return { object: req.object, id: req.id ?? 'r1', record: { id: req.id ?? 'r1' } }; @@ -155,7 +170,7 @@ function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): H (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); - return { rest, protocol, listReads: () => reads, reset: () => { reads = 0; } }; + return { rest, protocol, registry, listReads: () => reads, reset: () => { reads = 0; } }; } type Door = 'list' | 'get' | 'create' | 'update' | 'delete'; @@ -282,3 +297,28 @@ describe('[#24031] a protocol with no by-name read is asked the list, as before' expect(h.listReads()).toBe(1); }); }); + +// [#24031] An object whose package is DISABLED: the list read omits it (#7557) +// while the by-name read still serves it, so the gate now judges it by its own +// `enable`. A DENIED operation answers the exposure verdict — it answered the +// data plane's 404 OBJECT_PACKAGE_DISABLED while the gate read the list — and an +// ALLOWED one still reaches the data plane, which refuses it as before. +describe('[#24031] an object of a disabled package', () => { + it('a DENIED operation answers the exposure verdict (405), and the data call is never reached', async () => { + const h = host(); + h.registry.disablePackage(OFF_PACKAGE); + const out = await call(h, 'create', OFF.name); + expect(out.status, JSON.stringify(out.body)).toBe(405); + expect(errorCode(out.body)).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(h.protocol.createData).not.toHaveBeenCalled(); + }); + + it('an ALLOWED operation still answers the data plane\'s 404 OBJECT_PACKAGE_DISABLED', async () => { + const h = host(); + h.registry.disablePackage(OFF_PACKAGE); + const out = await call(h, 'list', OFF.name); + expect(h.protocol.findData).toHaveBeenCalledTimes(1); + expect(out.status, JSON.stringify(out.body)).toBe(404); + expect(errorCode(out.body)).toBe('OBJECT_PACKAGE_DISABLED'); + }); +}); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index b354c0f30ed..bdd68d0879c 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -3891,6 +3891,15 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { ? [{ name: 'widget' }] : [{ name: 'widget', enable }], ); + // [#24031] The gate reads its one object BY NAME. This double's by-name + // read answers the object its own list holds — whatever `getMetaItems` + // answers at call time, so a test that makes the list throw makes this + // throw too — as the real protocol's two reads agree. + protocol.getMetaItem = vi.fn(async (req: any) => { + const listed: any = await protocol.getMetaItems({ type: req?.type }); + const items: any[] = Array.isArray(listed) ? listed : (listed?.items ?? []); + return { type: req?.type, name: req?.name, item: items.find((o: any) => o?.name === req?.name) }; + }); const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); From 70eca6c17eb6dbe65bd6b4ecf4d78a3e89032b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:59:04 +0000 Subject: [PATCH 5/5] test(runtime): the two-door write-gate rig answers the by-name object read from the registry, beside the list The rig's real protocol has no metadata store, so it already answered getMetaItems from the registry; the REST exposure gate now reads its one object by name, and that read threw here, so the gate failed open and twelve exposure rows read as allowed objects. getMetaItem for type object now answers the same registry definition the list holds; other types keep the real read. No assertion changes. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude --- .../src/domains/data-write-request-gate.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/domains/data-write-request-gate.test.ts b/packages/runtime/src/domains/data-write-request-gate.test.ts index 25e7b533f73..8ae177cc4b8 100644 --- a/packages/runtime/src/domains/data-write-request-gate.test.ts +++ b/packages/runtime/src/domains/data-write-request-gate.test.ts @@ -64,9 +64,10 @@ * `{}`). Every row asserts the store, not just the door's word. * * [#23574] Both doors read each object's exposure from the one definition the - * engine's registry holds: `RestServer` through the protocol's object list - * (`getMetaItems`, which this rig's protocol cannot read without a metadata - * store, so it is answered from the registry), and the dispatcher through the + * engine's registry holds: `RestServer` through the protocol's by-name object + * read beside its object list (`getMetaItem` and `getMetaItems`, which this + * rig's protocol cannot read without a metadata store, so both are answered + * from the registry), and the dispatcher through the * kernel's `metadata` service (`getObject`, answered from the same registry). * Without them each door's exposure gate fails open, and every exposure row * below would read as an allowed object. @@ -192,10 +193,14 @@ function makeRes() { async function bootRestServer(): Promise { const { protocol, updateData, createData, findData, state, definition } = await bootStore(); - // `enforceApiAccess` reads the object list through the protocol; see the rig note above. + // `enforceApiAccess` reads the object by name through the protocol, beside its object list; see the rig note above. (protocol as any).getMetaItems = async (request: any) => request?.type === 'object' ? { type: 'object', items: OBJECTS.map((o) => definition(o.name)) } : { type: request?.type, items: [] }; + const readByName = (protocol as any).getMetaItem.bind(protocol); + (protocol as any).getMetaItem = async (request: any) => request?.type === 'object' + ? { type: 'object', name: request.name, item: OBJECTS.some((o) => o.name === request.name) ? definition(request.name) : undefined } + : readByName(request); const noop = () => {}; const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);