Skip to content

Commit bab0903

Browse files
committed
test(runtime): pin the actions-door exit for a name the registry does not resolve; changesets
An action body through REST /actions reading an out-of-band table by its unregistered name now answers 404 OBJECT_NOT_FOUND for an administrator and a member, with nothing of the table in the answer; the same body on a registered name is served (the control); the data door's own 404 is the reference. Changesets: core minor (new objectNotFoundError export), objectql minor BREAKING narrowing with its ADR-0087 disposition, metadata-protocol patch, spec patch (contract docblock). Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f5595f commit bab0903

5 files changed

Lines changed: 282 additions & 0 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
New export `objectNotFoundError(object)`: the one `OBJECT_NOT_FOUND` envelope the data door and the engine's in-process verbs refuse an unresolved object name with
6+
7+
Clause-②: yes
8+
9+
`@objectstack/core` exports `objectNotFoundError(object: string): Error`. The error it returns carries `code: 'OBJECT_NOT_FOUND'`, `status: 404`, the requested name on `object`, and the message `Object '<name>' not found`. It lives here beside `recordNotFoundError`, and for the same reason: the engine cannot import `@objectstack/metadata-protocol`, where the data door first wrote this envelope (ADR-0076 D2). The data door's object-existence gate and `@objectstack/objectql`'s resolver both build their refusal from it, so the two answer one name space with one envelope. Additive: nothing that existed before changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
The data door's object-existence gate builds its `OBJECT_NOT_FOUND` from the shared factory, and two best-effort readers treat the engine's refusal of their own object as the not-provisioned case
6+
7+
Clause-②: no
8+
9+
- `assertObjectRegistered` (the data door's object-existence gate) now throws `objectNotFoundError(object)` from `@objectstack/core`. The code, the status, the `object` field and the message are unchanged, byte for byte.
10+
- `SeedLoaderService.resolveSoleOrganizationId` and the history counters `SysMetadataRepository` reads (`version`, `event_seq`) already answered a missing table of their own object as "nothing here yet". `@objectstack/objectql` now refuses an object name its registry does not hold with `OBJECT_NOT_FOUND` instead of reaching the driver, so each reader also answers that refusal as the same absence when the error's own `object` is the object it read. A refusal naming another object, and every other read failure, still propagate. With a registered object nothing changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
An in-process engine verb refuses an object name the registry does not resolve, with the data door's own `OBJECT_NOT_FOUND`, instead of handing it to the driver as a raw table name
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing at the engine's name resolution: no authorable spec key, export or metadata shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What a caller meant by a name the registry does not hold is not decidable by a conversion entry. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what runtime verbs accept, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** accept-set narrowing of the engine's in-process verbs, shipped as `minor` under the repo's launch-window convention for breaking changes.
12+
13+
**What was accepted before.** `find`, `findOne`, `count`, `aggregate`, `insert` (and `insertMany`), `update`, `delete` and `validate` resolved their target through the schema registry and, for a name the registry did not resolve, handed the name to the driver as a raw table name. A caller in the process (a sandboxed action or hook body's `ctx.api`, an action handler, host code) could therefore read or write a table by a name the generic data door refuses with `404 OBJECT_NOT_FOUND`, and every in-process guard keyed by a registered object name could be stepped around by naming the target another way.
14+
15+
**What is refused now.** Such a name is refused with the data door's own envelope (`OBJECT_NOT_FOUND`, `status: 404`, the name on `object`, built by `objectNotFoundError` from `@objectstack/core`) before any hook, middleware or driver runs. A registered name resolves exactly as before. `judgeFilter` still judges the filter for a name the registry does not hold, because it reads nothing and reaches no driver; execution refuses that object before admission.
16+
17+
**Inside the engine.** The single-tenant organization probe asks the registry first: an install that registers no organization object is the lean case it always was, with no organization to derive, and the write proceeds unstamped without a driver read.
18+
19+
**The fix.** Register the object (in the stack, with `registry.registerObject`, or through a plugin manifest) before addressing it through the engine. Host code that must reach storage without a registry entry addresses the driver itself (`datasource(name)`, `getDriverForObject(name)`), a path a sandboxed body cannot reach.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The `IObjectQLEngine.judgeFilter` docblock states that execution refuses an object the registry does not know before admission
6+
7+
Clause-②: no
8+
9+
The comment ships in the package's type declarations (`dist/*.d.ts`); `src/contracts/objectql-engine.ts` itself is not in `files[]`. It used to say that, for an object the registry does not know, the schema-free doors still judge "as at execution". Execution now refuses such an object before admission (`OBJECT_NOT_FOUND`, 404), so the comment says that answer is about the object, not the filter, and is not this member's verdict. ⛔ No schema, parse, export or accept-set change.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21516] One name space for the engine's in-process verbs and the data door,
5+
* pinned at the door a deployment exposes: an action body run through REST
6+
* `/actions`.
7+
*
8+
* A sandboxed body's object API reaches the engine's in-process verbs. Those
9+
* verbs used to hand a name the schema registry does not resolve to the driver
10+
* as a raw table name, so a body could read a table by a name the generic data
11+
* door refuses with `404 OBJECT_NOT_FOUND` — and every in-process guard keyed by
12+
* a registered object name could be stepped around by naming the target some
13+
* other way. The engine now refuses that name with the door's own envelope.
14+
*
15+
* The target is a table that EXISTS and holds a row, created out of band at the
16+
* driver and registered nowhere: the class the card measured, without naming
17+
* any protected table. Pinned, per the triage ruling:
18+
* - the measured exit now answers not-found, for a member and an
19+
* administrator (the action runs elevated, so both reach the same engine);
20+
* - a registered name read the same way is the control;
21+
* - the data door's own answer for the same name is the reference.
22+
*
23+
* Composition: the plugin set, in order, `@objectstack/verify`'s `bootStack`
24+
* uses (it mirrors `objectstack dev` / `serve`), as the #21454 reader-seam pins
25+
* assemble it; the boot is paid in `beforeAll`, never inside a case.
26+
*/
27+
28+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
29+
import { ObjectKernel } from '@objectstack/core';
30+
import { ObjectQLPlugin } from '@objectstack/objectql';
31+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
32+
import { createRestApiPlugin } from '@objectstack/rest';
33+
import { AuthPlugin } from '@objectstack/plugin-auth';
34+
import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security';
35+
import { SharingServicePlugin } from '@objectstack/plugin-sharing';
36+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
37+
import { AppPlugin } from './app-plugin.js';
38+
import { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
39+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
40+
41+
const BOOT_TIMEOUT = 180_000;
42+
const ORIGIN = 'http://localhost:3000';
43+
const API = '/api/v1';
44+
const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' };
45+
const MEMBER = { email: 'pin-21516-member@example.invalid', password: 'Member-Pass-123' };
46+
47+
/** A table that exists at the driver and that no registry entry names. */
48+
const UNREGISTERED = 'pin_offbook_21516';
49+
/** The value the out-of-band row carries: what no answer below may contain. */
50+
const SENTINEL = 'offbook-sentinel-21516';
51+
/** The control row, written through the data door on the registered object. */
52+
const CONTROL_TITLE = 'registered-control-21516';
53+
54+
const body = (source: string) => ({ language: 'js', source, capabilities: ['api.read'], timeoutMs: 5000 });
55+
56+
const PIN_APP: any = {
57+
manifest: { id: 'com.pin.unresolved21516', name: 'Unresolved name pins', version: '1.0.0' },
58+
objects: [
59+
{
60+
name: 'pin_note',
61+
label: 'Pin note',
62+
fields: { title: { type: 'text', label: 'Title' } },
63+
actions: [
64+
{
65+
name: 'body_reads_unregistered',
66+
label: 'Body reads an unregistered name',
67+
type: 'script',
68+
body: body(`const rows = await ctx.api.object('${UNREGISTERED}').find({});\nreturn { rows };`),
69+
},
70+
{
71+
name: 'body_reads_registered',
72+
label: 'Body reads a registered name',
73+
type: 'script',
74+
body: body(`const rows = await ctx.api.object('pin_note').find({});\nreturn { rows };`),
75+
},
76+
],
77+
},
78+
],
79+
permissions: [
80+
{
81+
name: 'pin_21516_member_default',
82+
label: 'Pin member default',
83+
isDefault: true,
84+
objects: { pin_note: { allowRead: true, allowCreate: true } },
85+
},
86+
],
87+
};
88+
89+
let kernel: any;
90+
let httpServer: any;
91+
let app: any;
92+
let engine: any;
93+
let adminToken: string;
94+
let memberToken: string;
95+
let prevNodeEnv: string | undefined;
96+
97+
const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init);
98+
const as = (token: string | undefined, method: string, path: string, payload?: unknown) =>
99+
req(path, {
100+
method,
101+
headers: {
102+
'Content-Type': 'application/json',
103+
...(token ? { Authorization: `Bearer ${token}` } : {}),
104+
},
105+
...(payload !== undefined ? { body: JSON.stringify(payload) } : {}),
106+
});
107+
108+
async function readJson(res: Response): Promise<any> {
109+
const text = await res.text();
110+
try {
111+
return JSON.parse(text);
112+
} catch {
113+
return text;
114+
}
115+
}
116+
117+
/** The ADR-0112 code, wherever the door's envelope carries it. */
118+
const codeOf = (payload: any): unknown => payload?.error?.code ?? payload?.code;
119+
120+
async function signIn(who: { email: string; password: string }): Promise<string> {
121+
const res = await req('/auth/sign-in/email', {
122+
method: 'POST',
123+
headers: { 'Content-Type': 'application/json' },
124+
body: JSON.stringify(who),
125+
});
126+
if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`);
127+
return (await res.json() as any).token;
128+
}
129+
130+
async function signUpMember(): Promise<string> {
131+
// Default audience posture is invite_only: enter through a pending invitation,
132+
// the lane `@objectstack/verify`'s signUp takes.
133+
await engine.insert(
134+
'sys_invitation',
135+
{
136+
id: 'inv_pin_21516',
137+
email: MEMBER.email,
138+
status: 'pending',
139+
organization_id: 'org_pin_21516',
140+
role: 'member',
141+
inviter_id: 'usr_pin_21516',
142+
expires_at: new Date(Date.now() + 3_600_000),
143+
},
144+
{ context: { isSystem: true } },
145+
);
146+
const res = await req('/auth/sign-up/email', {
147+
method: 'POST',
148+
headers: { 'Content-Type': 'application/json' },
149+
body: JSON.stringify({ email: MEMBER.email, password: MEMBER.password, name: 'pin member' }),
150+
});
151+
if (!res.ok) throw new Error(`pin signUp failed: ${res.status} ${await res.text()}`);
152+
return (await res.json() as any).token;
153+
}
154+
155+
beforeAll(async () => {
156+
prevNodeEnv = process.env.NODE_ENV;
157+
process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it
158+
159+
kernel = new ObjectKernel();
160+
await kernel.use(new ObjectQLPlugin());
161+
await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } }));
162+
await kernel.use(new HonoServerPlugin({ port: 0 }));
163+
await kernel.use(new AppPlugin(PIN_APP));
164+
await kernel.use(new PlatformObjectsPlugin());
165+
await kernel.use(new AuthPlugin({ secret: 'unresolved-name-21516-secret', autoDefaultOrganization: false }));
166+
await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP)));
167+
await kernel.use(new SharingServicePlugin());
168+
await kernel.use(createRestApiPlugin({}));
169+
await kernel.use(createDispatcherPlugin({}));
170+
await kernel.bootstrap();
171+
172+
httpServer = await kernel.getServiceAsync('http-server');
173+
app = httpServer.getRawApp();
174+
engine = await kernel.getServiceAsync('objectql');
175+
176+
// The out-of-band table, created and written at the DRIVER (host code's
177+
// declared internal path), and registered nowhere.
178+
const driver = engine.getDriverByName(engine.getDefaultDriverName());
179+
await driver.syncSchema(UNREGISTERED, {
180+
name: UNREGISTERED,
181+
fields: { id: { name: 'id', type: 'text', primaryKey: true }, secret: { name: 'secret', type: 'text' } },
182+
});
183+
await driver.create(UNREGISTERED, { id: 'offbook_1', secret: SENTINEL });
184+
185+
adminToken = await signIn(ADMIN);
186+
memberToken = await signUpMember();
187+
const seeded = await as(adminToken, 'POST', '/data/pin_note', { title: CONTROL_TITLE });
188+
if (seeded.status >= 300) throw new Error(`pin seed refused: ${seeded.status} ${JSON.stringify(await readJson(seeded))}`);
189+
}, BOOT_TIMEOUT);
190+
191+
afterAll(async () => {
192+
try { await httpServer?.close?.(); } catch { /* best-effort */ }
193+
try { await kernel?.shutdown?.(); } catch { /* best-effort */ }
194+
if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
195+
else process.env.NODE_ENV = prevNodeEnv;
196+
}, 60_000);
197+
198+
describe('[#21516] precondition and reference', () => {
199+
it('the table exists at the driver and holds the row; no registry entry names it', async () => {
200+
expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined();
201+
const driver = engine.getDriverByName(engine.getDefaultDriverName());
202+
const rows: any[] = await driver.find(UNREGISTERED, {});
203+
expect(rows.map((r) => r.secret)).toEqual([SENTINEL]);
204+
});
205+
206+
it('the generic data door answers 404 OBJECT_NOT_FOUND for the same name', async () => {
207+
const res = await as(adminToken, 'GET', `/data/${UNREGISTERED}`);
208+
const payload = await readJson(res);
209+
expect(res.status).toBe(404);
210+
expect(codeOf(payload)).toBe('OBJECT_NOT_FOUND');
211+
expect(JSON.stringify(payload)).not.toContain(SENTINEL);
212+
});
213+
});
214+
215+
describe('[#21516] an action body via /actions reading a name the registry does not resolve', () => {
216+
for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) {
217+
it(`invoked by the ${role}: answers not-found, and nothing of the table reaches the answer`, async () => {
218+
const res = await as(token(), 'POST', '/actions/pin_note/body_reads_unregistered', { params: {} });
219+
const payload = await readJson(res);
220+
expect({ status: res.status, code: codeOf(payload) }).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' });
221+
expect(JSON.stringify(payload)).not.toContain(SENTINEL);
222+
});
223+
}
224+
});
225+
226+
describe('[#21516] CONTROL — the same body shape on a registered name', () => {
227+
for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) {
228+
it(`invoked by the ${role}: served`, async () => {
229+
const res = await as(token(), 'POST', '/actions/pin_note/body_reads_registered', { params: {} });
230+
const payload = await readJson(res);
231+
expect(res.status).toBe(200);
232+
expect(JSON.stringify(payload)).toContain(CONTROL_TITLE);
233+
});
234+
}
235+
});

0 commit comments

Comments
 (0)