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
19 changes: 19 additions & 0 deletions .changeset/27728-27729-import-doors-object-existence.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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: two already-published refusals (404 OBJECT_NOT_FOUND, 404 OBJECT_PACKAGE_DISABLED) are now answered up front by one more door, and the exposure gate judges the object a short name resolves to. There is nothing for a migration to rewrite and no tombstone. -->

**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.
43 changes: 28 additions & 15 deletions packages/rest/src/data-action-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const byName = typeof p.getMetaItemForExecution === 'function'
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
218 changes: 218 additions & 0 deletions packages/rest/src/import-door-object-existence.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof boot>>;
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<ReturnType<typeof boot>>;
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<ReturnType<typeof boot>>;
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);
});
});
Loading
Loading