diff --git a/.changeset/27728-27729-import-doors-object-existence.md b/.changeset/27728-27729-import-doors-object-existence.md new file mode 100644 index 00000000000..750deea89e9 --- /dev/null +++ b/.changeset/27728-27729-import-doors-object-existence.md @@ -0,0 +1,19 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest)!: both import doors answer for the object as the data door does — the synchronous import refuses an unregistered object 404, and a legacy short name is the object behind it at every door (#27728, #27729) + +Clause-②: no (narrowing) — `POST /data/:object/import` no longer answers 200 for an unregistered object or an object of a disabled package; it answers the data door's 404. And on every `/data/:object` route, a legacy `ns__short` object addressed by its short name is now held to its own `enable` settings (404 or 405). No value, key or shape is added. +Metadata-protocol: none + + + +**BREAKING** the synchronous import door refuses what it used to accept with a 200, and the API-exposure gate of every `/data/:object` route now judges a legacy `ns__short` object addressed by its short name (`@objectstack/rest`), per triage 6509235875 (#27728) and 6509225810 (#27729). + +- **Before.** `POST /data/:object/import` ran no existence check. For a name with no object behind it, it answered 200 and repeated `OBJECT_NOT_FOUND` inside every row's result; for an object of a disabled package it did the same with `OBJECT_PACKAGE_DISABLED`. An importer that reads the status saw success. +- **Now.** It answers the data door's own refusal for the name, status and body: 404 `OBJECT_NOT_FOUND`, or 404 `OBJECT_PACKAGE_DISABLED`. It is asked after the API-exposure gate (so a hidden object keeps its 404 and the door is no existence oracle) and before the body is parsed, so nothing is written, dry run included. The async job door (`POST /data/:object/import/jobs`) already refused both; its disabled-package refusal is now pinned with zero job rows. +- **One read for existence and exposure.** A legacy `ns__short` object addressed by its short name is the object behind that name at every door, as `registry.getObject` resolves it for the data door. The async job door used to refuse such a name `OBJECT_NOT_FOUND` while the data door served it; it now creates the job. The API-exposure gate used to treat such a name as "no object" and pass it on, so an `ns__short` object with `enable.apiEnabled: false` or an `enable.apiMethods` list was served under its short name; the gate now judges that object's `enable` and refuses the same way it refuses the full name. No shipped object carries a `__` name. +- **Who sees it.** A client that imports into a misspelled or unregistered object name, or into an object of a disabled package, now gets a 404 instead of a 200 with per-row errors. A client that addresses a legacy `ns__short` object by its short name on any `/data/:object` route, where that object sets `enable.apiEnabled: false` or an `enable.apiMethods` list that leaves the operation out, now gets that object's 404 `OBJECT_API_DISABLED` or 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which it already got under the full name. + +**The remedy.** Import into a registered object name (or re-enable the package). A client that already read the per-row `OBJECT_NOT_FOUND` should read the response status and `code` instead. For a restricted `ns__short` object, the short name now answers as the full name does: lift the object's `enable` restriction if the API should serve it. diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 29a28a90dac..89110024eeb 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -319,21 +319,26 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro } /** - * [#27501] Refuse an import job for an object nothing is behind, with the data - * door's own answer for that name: `objectNotFoundError` (`@objectstack/core`), - * the one factory the protocol's existence gate and the engine's verbs throw, - * classified by the same `handleRouteError` — 404 `OBJECT_NOT_FOUND`, the same - * envelope `POST /data/OBJ` answers. + * [#27501, #27728] Refuse an import for an object nothing is behind, with the + * data door's own answer for that name: `objectNotFoundError` + * (`@objectstack/core`), the one factory the protocol's existence gate and the + * engine's verbs throw, classified by the same `handleRouteError` — 404 + * `OBJECT_NOT_FOUND`, the same envelope `POST /data/OBJ` answers. Both import + * doors ask it, the synchronous one (`POST /data/OBJ/import`) and the job door + * (`POST /data/OBJ/import/jobs`), right after the exposure gate and before the + * body is parsed, so a refused request writes no row and no job. * * The object is read by name as the exposure gate reads it (the execution face - * where the protocol has one, else `getMetaItem`), and counts only under its - * own name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its - * by-name read's answer for an object of a disabled package, and the data - * door's code for it too) propagates as thrown. Any other thrown read fails - * OPEN and logs, as the exposure gate's same read does (`loadObjectItem`, - * #3545): it is no answer about existence, and each row's data call still - * gives the data door's own. A protocol with no by-name read cannot answer, - * and is not asked. + * where the protocol has one, else `getMetaItem`). [#27729] Whatever item that + * read serves counts, under whichever name it carries: the by-name read + * resolves a legacy `ns__short` object from its short name exactly as the data + * door's `registry.getObject` does, so this answer is the data door's for every + * name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its by-name + * read's answer for an object of a disabled package, and the data door's code + * for it too) propagates as thrown. Any other thrown read fails OPEN and logs, + * as the exposure gate's same read does (`loadObjectItem`, #3545): it is no + * answer about existence, and each row's data call still gives the data door's + * own. A protocol with no by-name read cannot answer, and is not asked. */ async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise { const byName = typeof p.getMetaItemForExecution === 'function' @@ -345,12 +350,12 @@ async function assertImportObjectExists(p: any, objectName: string, environmentI envelope = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) }); } catch (err) { if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') throw err; - logWarn('[REST] import job: object metadata read failed — not refused up front ' + logWarn('[REST] import: object metadata read failed — not refused up front ' + '(each row\'s data call still answers for the object)', (err as Error)?.message ?? err); return; } const item = envelope?.item; - if (item !== null && typeof item === 'object' && item.name === objectName) return; + if (item !== null && typeof item === 'object') return; throw objectNotFoundError(objectName); } @@ -517,6 +522,14 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: return; } if (await this.enforceApiAccess(req, res, p, environmentId, 'import', context)) return; + + // [#27728] The object must exist before any row is read. The + // exposure gate above passes an unknown object through by design + // (#3770), and the data door's existence refusal used to fire only + // inside each row's write, so a misspelled name answered 200 with + // every row refused. Refused here, with the data door's answer. + await assertImportObjectExists(p, objectName, environmentId); + const body = req.body ?? {}; // [#24348] Who may take the object's declared controls off this diff --git a/packages/rest/src/import-door-object-existence.test.ts b/packages/rest/src/import-door-object-existence.test.ts new file mode 100644 index 00000000000..ee28f678433 --- /dev/null +++ b/packages/rest/src/import-door-object-existence.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27728, #27729] Both import doors answer for the object exactly as the data + * door does (`POST /data/:object`), before any row or job is written: + * + * - the synchronous door (`POST /data/:object/import`) refuses an unregistered + * object with the data door's 404 `OBJECT_NOT_FOUND`, where it used to answer + * 200 with the refusal repeated inside every row's result (#27728); + * - both doors refuse an object of a DISABLED package with the data door's + * 404 `OBJECT_PACKAGE_DISABLED`, status and body, and write no row and no + * `sys_import_job` row (#27729 item 1); + * - a legacy `ns__short` object addressed by its short name is the object + * behind that name at every door: the existence read and the API-exposure + * gate both take the item the by-name read resolves, as the data door's + * `registry.getObject` does, so the job door no longer refuses a name the + * data door serves, and the gate judges that object's `enable` instead of + * passing the short name through (#27729 item 2). + * + * A REAL `ObjectQL` engine + `ObjectStackProtocolImplementation` on a REAL + * sqlite `:memory:` driver; nothing below the routes is a double. + */ + +import { describe, it, expect, beforeEach, afterEach } 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 { SysImportJob } from '@objectstack/platform-objects/audit'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +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' }, +}; + +/** A plain object, the control. */ +const TASK = { name: 'ex_task', label: 'Task', systemFields: false, fields }; +/** An object of a package the test disables. */ +const OFF_PACKAGE = 'app.ex_off'; +const GADGET = { name: 'ex_gadget', label: 'Gadget', systemFields: false, fields }; +/** Legacy `ns__short` objects, addressed below by their short names. */ +const LEGACY_PACKAGE = 'app.ex_legacy'; +const WIDGET = { name: 'exl__widget', label: 'Widget', systemFields: false, fields }; +const VAULT = { name: 'exl__vault', label: 'Vault', 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: OFF_PACKAGE, name: OFF_PACKAGE, version: '1.0.0' } as any, 'code'); + engine.registry.installPackage({ id: LEGACY_PACKAGE, name: LEGACY_PACKAGE, version: '1.0.0' } as any, 'code'); + engine.registry.registerObject(TASK as any); + engine.registry.registerObject(GADGET as any, OFF_PACKAGE); + engine.registry.registerObject(WIDGET as any, LEGACY_PACKAGE); + engine.registry.registerObject(VAULT as any, LEGACY_PACKAGE); + engine.registry.registerObject(SysImportJob as any); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9). + await installDefaultOrganization(engine); + 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); + (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 = { + data: route('/api/v1/data/:object'), + sync: route('/api/v1/data/:object/import'), + jobs: route('/api/v1/data/:object/import/jobs'), + }; + return { + engine, + async call(door: keyof typeof doors, object: string, body: any) { + const res = makeRes(); + await doors[door].handler({ params: { object }, body, query: {} } as any, res); + // The synchronous door answers with `res.json` alone on success. + return { status: res._status ?? 200, body: res._json }; + }, + /** 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; + }, + }; +} + +const rowsBody = (id: string) => ({ format: 'json', rows: [{ id, title: 'x' }] }); + +describe('[#27728] the synchronous import door refuses an unregistered object as the data door does', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot(); }); + + it('answers 404 OBJECT_NOT_FOUND, the data door\'s envelope byte for byte, not 200', async () => { + const door = await h.call('data', 'ex_no_such_object', { id: 'n1', title: 'x' }); + expect(door.status).toBe(404); + expect(door.body?.code).toBe('OBJECT_NOT_FOUND'); + + const sync = await h.call('sync', 'ex_no_such_object', rowsBody('n2')); + expect(sync.status).toBe(404); + expect(sync.body).toEqual(door.body); + }); + + it('a dry run and a CSV body get the same refusal, before the body is parsed', async () => { + const door = await h.call('data', 'ex_no_such_object', { id: 'n1', title: 'x' }); + const dry = await h.call('sync', 'ex_no_such_object', { ...rowsBody('n3'), dryRun: true }); + expect(dry).toEqual({ status: 404, body: door.body }); + // Not even a parseable body is needed: the object is asked first. + const csv = await h.call('sync', 'ex_no_such_object', { format: 'csv', csv: 'not,a\n"broken' }); + expect(csv).toEqual({ status: 404, body: door.body }); + }); + + it('control: a registered object still imports (200, one row created)', async () => { + const sync = await h.call('sync', 'ex_task', rowsBody('t1')); + expect(sync.status).toBe(200); + expect(sync.body).toMatchObject({ object: 'ex_task', total: 1, ok: 1, created: 1, errors: 0 }); + expect(await h.count('ex_task')).toBe(1); + }); +}); + +describe('[#27729 item 1] both import doors refuse a disabled package\'s object with the data door\'s answer', () => { + let h: Awaited>; + beforeEach(async () => { + h = await boot(); + h.engine.registry.disablePackage(OFF_PACKAGE); + }); + + it('the data door\'s answer is 404 OBJECT_PACKAGE_DISABLED (the reference)', async () => { + const door = await h.call('data', 'ex_gadget', { id: 'g0', title: 'x' }); + expect(door.status).toBe(404); + expect(door.body?.code).toBe('OBJECT_PACKAGE_DISABLED'); + }); + + for (const kind of ['sync', 'jobs'] as const) { + it(`${kind}: status and body equal the data door's, and no row and no job row is written`, async () => { + const door = await h.call('data', 'ex_gadget', { id: 'g0', title: 'x' }); + const answer = await h.call(kind, 'ex_gadget', rowsBody('g1')); + expect(answer.status).toBe(door.status); + expect(answer.body).toEqual(door.body); + expect(await h.count('sys_import_job')).toBe(0); + expect(await h.count('ex_gadget')).toBe(0); + }); + } +}); + +describe('[#27729 item 2] a legacy ns__short object is the object behind its short name at every door', () => { + let h: Awaited>; + beforeEach(async () => { h = await boot(); }); + + it('premise: the data door serves the short name (registry.getObject resolves it)', async () => { + expect((h.engine.registry.getObject('widget') as any)?.name).toBe('exl__widget'); + const door = await h.call('data', 'widget', { id: 'w0', title: 'x' }); + expect(door.status).toBe(201); + }); + + it('the job door creates the job, where it used to answer OBJECT_NOT_FOUND', async () => { + const jobs = await h.call('jobs', 'widget', rowsBody('w1')); + expect(jobs.status).toBe(201); + expect(jobs.body).toMatchObject({ object: 'widget', status: 'pending', total: 1 }); + expect(await h.count('sys_import_job')).toBe(1); + }); + + it('the synchronous door imports under the short name', async () => { + const sync = await h.call('sync', 'widget', rowsBody('w2')); + expect(sync.status).toBe(200); + expect(sync.body).toMatchObject({ object: 'widget', ok: 1, created: 1 }); + }); + + it('an unregistered short name is still OBJECT_NOT_FOUND at both import doors', async () => { + const door = await h.call('data', 'gizmo', { id: 'z0', title: 'x' }); + expect(door.status).toBe(404); + expect(await h.call('sync', 'gizmo', rowsBody('z1'))).toEqual(door); + expect(await h.call('jobs', 'gizmo', rowsBody('z2'))).toEqual(door); + expect(await h.count('sys_import_job')).toBe(0); + }); + + it('the exposure gate judges the resolved object: apiEnabled:false hides it under its short name too', async () => { + const full = await h.call('data', 'exl__vault', { id: 'v0', title: 'x' }); + expect(full.status).toBe(404); + expect(full.body?.code).toBe('OBJECT_API_DISABLED'); + for (const kind of ['data', 'sync', 'jobs'] as const) { + const short = await h.call(kind, 'vault', kind === 'data' ? { id: 'v1', title: 'x' } : rowsBody('v1')); + expect(short.status).toBe(404); + expect(short.body?.code).toBe('OBJECT_API_DISABLED'); + } + expect(await h.count('exl__vault')).toBe(0); + expect(await h.count('sys_import_job')).toBe(0); + }); +}); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 5d14f27dd89..748bc22ec4f 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -481,11 +481,16 @@ export async function loadObjectItems(source: MetaReadGateListSource): Promise