Skip to content

Commit 6cb2399

Browse files
committed
runtime: pin the app read by package id on both hosts; changeset
RestServer and the hono catch-all over one real store each: a member on a withheld app's package id gets the by-name 403, a holder the by-name item, an unknown or unpublished package the absence answer, and a dotted key on another type its own item. RestServer's /history, /audit and /diff stay by name. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 245be87 commit 6cb2399

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/rest': patch
5+
---
6+
7+
feat(spec): the app item read answers by package id. `GET /meta/app/<packageId>` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1)
8+
9+
Clause-②: yes (widening)
10+
11+
ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/<packageId>`. The app item read now accepts that key too.
12+
13+
- **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm.
14+
- **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=<id>`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name:
15+
- a caller the app is withheld from gets `403 PERMISSION_DENIED`;
16+
- an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps;
17+
- a caller who holds the app is served it.
18+
- A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`.
19+
- `/meta/app/<key>/history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only.
20+
21+
From a client: `client.meta.getItem('app', 'com.acme.crm')`.
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23183, ADR-0048 §3.1] The app access verdict is answerable by PACKAGE ID,
5+
* on both hosts: `RestServer`'s `GET /meta/:type/:name` and the runtime
6+
* dispatcher's `/meta` item read (the only answer on a host that mounts just
7+
* `@objectstack/hono`'s `${prefix}/*` catch-all).
8+
*
9+
* ## What was missing
10+
*
11+
* ADR-0048 §3.1 makes the package id the app's route key, so the console opens
12+
* an app at `/apps/<packageId>`, and when that app is not in the caller's list
13+
* it must tell a withheld app (403) from a missing one (404). By name the read
14+
* already answers that (`/meta/app/payroll` → `403 PERMISSION_DENIED`). By
15+
* package id nothing did: `/meta/app/com.acme.payroll` asked for an app NAMED
16+
* `com.acme.payroll` and answered 404 whatever the package held, and the list
17+
* read (`?package=`) drops a withheld app, so it reads as a missing one.
18+
*
19+
* ## The fix, and why neither door carries it
20+
*
21+
* `GetMetaItemRequestSchema.name` declares the arm (`type: 'app'`, a key in
22+
* the package-id grammar, `isAppPackageIdKey`), and its producer resolves it:
23+
* `getMetaItem` in `@objectstack/metadata-protocol` reads the package's one
24+
* app by its own name. Both doors hand the protocol the key they were asked
25+
* for, so each gets the arm with no code of its own, and the per-caller read
26+
* gate judges the resolved app exactly as it judges the by-name read. These
27+
* rows drive both doors over one real store each and hold the two equal, and
28+
* equal to the by-name read.
29+
*
30+
* ## The rig
31+
*
32+
* `meta-package-all-fold.test.ts`'s: per door, a REAL store (better-sqlite3
33+
* `:memory:`, the real `sys_metadata*` objects, a real
34+
* `ObjectStackProtocolImplementation`), with the apps registered the way a
35+
* code package registers them (`registry.registerApp(app, packageId)`), and
36+
* the caller supplied at each door's identity seam.
37+
*
38+
* `RestServer` also serves three doors the dispatcher does not, `/history`,
39+
* `/audit` and `/diff`, which judge "the item the plain read would judge" but
40+
* read their own data by the literal `:name`. The arm is the plain read's, not
41+
* theirs: the last rows pin them by name, as before the arm.
42+
*/
43+
44+
import { describe, it, expect, afterEach } from 'vitest';
45+
import { ObjectQL } from '@objectstack/objectql';
46+
import { SqlDriver } from '@objectstack/driver-sql';
47+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
48+
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
49+
import { RestServer } from '@objectstack/rest';
50+
import { HttpDispatcher } from '../http-dispatcher.js';
51+
52+
const PREFIX = '/api/v1';
53+
const META = `${PREFIX}/meta`;
54+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
55+
const TEST_PACKAGE_ID = 'objectstack-test';
56+
57+
/** A package whose app the member does not hold (`requiredPermissions`). */
58+
const PAYROLL_PKG = 'com.acme.payroll';
59+
const PAYROLL_APP = {
60+
name: 'payroll',
61+
label: 'Payroll',
62+
requiredPermissions: ['payroll.access'],
63+
navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }],
64+
};
65+
/** A package whose app is mid-build (ADR-0045 §3: externally unobservable). */
66+
const BUILD_PKG = 'com.acme.build';
67+
const BUILD_APP = {
68+
name: 'production_management',
69+
label: 'Production',
70+
_unpublished: true,
71+
navigation: [{ id: 'nav_lines', type: 'page', label: 'Lines', pageName: 'lines' }],
72+
};
73+
/** No package of this id is installed. */
74+
const MISSING_PKG = 'com.acme.missing';
75+
/**
76+
* A `view` whose name is in the package-id grammar (`<object>.<viewKey>`), and
77+
* a package of that very id shipping an app: were the arm to fire on `view`,
78+
* the read would resolve that package and miss the view.
79+
*/
80+
const VIEW_KEY = 'task.grid';
81+
const SHADOW_APP = { name: 'grid_app', label: 'Grid app', navigation: [] };
82+
83+
const TASK = {
84+
name: 'task',
85+
label: 'Task',
86+
fields: {
87+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
88+
name: { name: 'name', type: 'text' as const, label: 'Name' },
89+
},
90+
};
91+
92+
const CALLERS = {
93+
member: { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] },
94+
holder: { userId: 'u_holder', isSystem: false, systemPermissions: ['payroll.access'] },
95+
/**
96+
* `/history`, `/audit` and `/diff` admit an authoring capability first, so
97+
* their rows need callers past that door: one who does not hold the app…
98+
*/
99+
builder: { userId: 'u_builder', isSystem: false, systemPermissions: ['studio.access'] },
100+
/** …and one who does. */
101+
builderHolder: { userId: 'u_builder_holder', isSystem: false, systemPermissions: ['studio.access', 'payroll.access'] },
102+
} as const;
103+
type CallerName = keyof typeof CALLERS;
104+
105+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
106+
107+
const liveEngines: ObjectQL[] = [];
108+
afterEach(async () => {
109+
while (liveEngines.length) {
110+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
111+
}
112+
});
113+
114+
/** One real store: the apps registered as their code packages register them, and one stored view. */
115+
async function bootStore() {
116+
const engine = new ObjectQL();
117+
liveEngines.push(engine);
118+
engine.registerDriver(new SqlDriver({
119+
client: 'better-sqlite3',
120+
connection: { filename: ':memory:' },
121+
useNullAsDefault: true,
122+
}), true);
123+
await engine.init();
124+
engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID);
125+
engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
126+
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
127+
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
128+
await engine.syncSchemas();
129+
engine.registry.registerApp(clone(PAYROLL_APP), PAYROLL_PKG);
130+
engine.registry.registerApp(clone(BUILD_APP), BUILD_PKG);
131+
engine.registry.registerApp(clone(SHADOW_APP), VIEW_KEY);
132+
const protocol = new ObjectStackProtocolImplementation(engine as any);
133+
await protocol.saveMetaItem({
134+
type: 'view',
135+
name: VIEW_KEY,
136+
item: { name: VIEW_KEY, label: 'Grid', object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }] },
137+
} as any);
138+
return { protocol };
139+
}
140+
141+
// ── The two doors ─────────────────────────────────────────────────────────────
142+
143+
interface Answer { status: number; code?: string; body: any; envelope?: any }
144+
interface Door { read(who: CallerName, path: string): Promise<Answer> }
145+
146+
/** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */
147+
const codeOf = (body: any): string | undefined =>
148+
body?.error && typeof body.error === 'object' ? body.error.code : body?.code;
149+
150+
function makeRes() {
151+
const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> };
152+
res.status = (code: number) => { res.statusCode = code; return res; };
153+
res.json = (body: any) => { res.body = body; return res; };
154+
res.send = () => res;
155+
res.end = () => res;
156+
res.header = (k: string, v: string) => { res.headers[k] = v; return res; };
157+
res.setHeader = () => {};
158+
res.write = () => true;
159+
return res;
160+
}
161+
162+
/** `RestServer`'s route for a path below `/meta`, and the params the adapter would hand it. */
163+
function restRouteOf(path: string): { route: string; params: Record<string, string> } {
164+
const s = path.replace(/^\/meta\//, '').split('/');
165+
if (s.length === 3) return { route: `${META}/:type/:name/${s[2]}`, params: { type: s[0], name: s[1] } };
166+
if (s.length === 2) return { route: `${META}/:type/:name`, params: { type: s[0], name: s[1] } };
167+
throw new Error(`no RestServer route for ${path}`);
168+
}
169+
170+
async function bootRestServer(): Promise<Door> {
171+
const { protocol } = await bootStore();
172+
const noop = () => {};
173+
const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
174+
const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
175+
let caller: CallerName = 'member';
176+
rest.resolveExecCtx = async () => clone(CALLERS[caller]);
177+
rest.registerRoutes();
178+
return {
179+
read: async (who, path) => {
180+
const { route, params } = restRouteOf(path);
181+
const entry = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === route);
182+
if (!entry) throw new Error(`GET ${route} is not registered`);
183+
caller = who;
184+
const res = makeRes();
185+
await entry.handler({ method: 'GET', path: `${PREFIX}${path}`, params, query: {}, headers: {}, body: undefined }, res);
186+
return {
187+
status: res.statusCode,
188+
code: codeOf(res.body),
189+
body: res.body,
190+
envelope: res.statusCode === 200 ? res.body : undefined,
191+
};
192+
},
193+
};
194+
}
195+
196+
async function bootCatchAll(): Promise<Door> {
197+
const { protocol } = await bootStore();
198+
const get = (n: string) => (n === 'protocol' ? protocol : null);
199+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
200+
const dispatcher = new HttpDispatcher(kernel);
201+
let caller: CallerName = 'member';
202+
// The seam `dispatch()` resolves identity through (the sibling
203+
// dispatch-level pins supply the principal the same way).
204+
(dispatcher as any).timedResolveExecutionContext = async () => clone(CALLERS[caller]);
205+
return {
206+
read: async (who, path) => {
207+
caller = who;
208+
// What `createHonoApp`'s catch-all hands `dispatch()`: the path below
209+
// the prefix, the query, and the raw Fetch `Request`.
210+
const raw = new Request(new URL(`http://localhost${PREFIX}${path}`), { method: 'GET' });
211+
const res = await dispatcher.dispatch('GET', path, undefined, {}, { request: raw } as any, PREFIX);
212+
const status = res.response?.status ?? 0;
213+
const body = res.response?.body;
214+
return { status, code: codeOf(body), body, envelope: status === 200 ? body?.data : undefined };
215+
},
216+
};
217+
}
218+
219+
const DOORS: ReadonlyArray<readonly [string, () => Promise<Door>]> = [
220+
['RestServer', bootRestServer],
221+
['the @objectstack/hono catch-all', bootCatchAll],
222+
];
223+
224+
const text = (a: Answer): string => JSON.stringify(a.body ?? null);
225+
226+
describe.each(DOORS)('[#23183] %s: the app item read answers by package id', (_door, boot) => {
227+
it('a member on a withheld app\'s package id: 403 PERMISSION_DENIED, the by-name read\'s verdict', async () => {
228+
const door = await boot();
229+
const byPackage = await door.read('member', `/meta/app/${PAYROLL_PKG}`);
230+
const byName = await door.read('member', '/meta/app/payroll');
231+
232+
expect(byName.status, text(byName)).toBe(403);
233+
expect(byName.code).toBe('PERMISSION_DENIED');
234+
expect(byPackage.status, text(byPackage)).toBe(403);
235+
expect(byPackage.code).toBe('PERMISSION_DENIED');
236+
// A refusal, never the document.
237+
expect(text(byPackage)).not.toContain('nav_payroll_runs');
238+
}, 60_000);
239+
240+
it('a holder is served the app, under its own name — the item the by-name read serves', async () => {
241+
const door = await boot();
242+
const byPackage = await door.read('holder', `/meta/app/${PAYROLL_PKG}`);
243+
const byName = await door.read('holder', '/meta/app/payroll');
244+
245+
expect(byName.status, text(byName)).toBe(200);
246+
expect(byPackage.status, text(byPackage)).toBe(200);
247+
expect(byPackage.envelope?.name).toBe('payroll');
248+
expect(byPackage.envelope?.item?.name).toBe('payroll');
249+
expect(byPackage.envelope?.item).toEqual(byName.envelope?.item);
250+
}, 60_000);
251+
252+
it('an unknown package id: 404, the door\'s own answer for an app that is not there', async () => {
253+
const door = await boot();
254+
const byPackage = await door.read('holder', `/meta/app/${MISSING_PKG}`);
255+
const byName = await door.read('holder', '/meta/app/no_such_app');
256+
257+
expect(byPackage.status, text(byPackage)).toBe(404);
258+
expect(byName.status, text(byName)).toBe(404);
259+
expect(text(byPackage)).toBe(text(byName));
260+
}, 60_000);
261+
262+
it('a non-holder learns nothing more than by name: a package whose app is unpublished answers exactly as an unknown package', async () => {
263+
const door = await boot();
264+
const unpublished = await door.read('member', `/meta/app/${BUILD_PKG}`);
265+
const unknown = await door.read('member', `/meta/app/${MISSING_PKG}`);
266+
267+
expect(unpublished.status, text(unpublished)).toBe(404);
268+
expect(text(unpublished)).toBe(text(unknown));
269+
}, 60_000);
270+
271+
it('a key in the package-id grammar on another type is read by name, even where a package of that id ships an app', async () => {
272+
const door = await boot();
273+
const answer = await door.read('holder', `/meta/view/${VIEW_KEY}`);
274+
275+
expect(answer.status, text(answer)).toBe(200);
276+
expect(answer.envelope?.item?.name).toBe(VIEW_KEY);
277+
}, 60_000);
278+
});
279+
280+
describe('[#23183] RestServer: /history, /audit and /diff stay by name — the arm is the plain read\'s', () => {
281+
it('a builder who does not hold the app reads a package id\'s history and audit as an unknown key\'s, never the plain read\'s 403', async () => {
282+
const door = await bootRestServer();
283+
// The control: the plain read refuses this caller the app by package id.
284+
const plain = await door.read('builder', `/meta/app/${PAYROLL_PKG}`);
285+
expect(plain.status, text(plain)).toBe(403);
286+
for (const sub of ['history', 'audit']) {
287+
const byPackage = await door.read('builder', `/meta/app/${PAYROLL_PKG}/${sub}`);
288+
const unknown = await door.read('builder', `/meta/app/${MISSING_PKG}/${sub}`);
289+
expect(unknown.status, `${sub} ${text(unknown)}`).toBe(200);
290+
expect(byPackage.status, `${sub} ${text(byPackage)}`).toBe(200);
291+
// The same answer, but for the key each echoes.
292+
expect(text(byPackage).split(PAYROLL_PKG).join('KEY'), sub)
293+
.toBe(text(unknown).split(MISSING_PKG).join('KEY'));
294+
}
295+
}, 60_000);
296+
297+
it('a holder\'s /diff of a package id is the absence answer, as for an unknown key — never an empty diff of the app', async () => {
298+
const door = await bootRestServer();
299+
const byPackage = await door.read('builderHolder', `/meta/app/${PAYROLL_PKG}/diff`);
300+
const unknown = await door.read('builderHolder', `/meta/app/${MISSING_PKG}/diff`);
301+
302+
expect(byPackage.status, text(byPackage)).toBe(404);
303+
expect(byPackage.code).toBe('RESOURCE_NOT_FOUND');
304+
expect(text(byPackage)).toBe(text(unknown));
305+
}, 60_000);
306+
});

0 commit comments

Comments
 (0)