Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/27899-batch-op-exposure-object-read.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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. -->

**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.
195 changes: 195 additions & 0 deletions packages/rest/src/batch-op-exposure-object-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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 { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
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();
// 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 = () => {};
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<string, unknown>) => call('data', object, record),
createMany: (object: string, records: Array<Record<string, unknown>>) => 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<ReturnType<typeof boot>>;
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);
});
});
44 changes: 25 additions & 19 deletions packages/rest/src/batch-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, any>(items.map((o: any) => [o?.name, o]));
const checked = new Set<string>();
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<string, any>();
const checked = new Set<string>();
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
Expand Down
7 changes: 4 additions & 3 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[]> {
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/rest/src/objectql-slot-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
Loading
Loading