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
70 changes: 70 additions & 0 deletions .changeset/20193-dispatcher-meta-read-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
'@objectstack/runtime': patch
'@objectstack/rest': minor
---

fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193)

Clause-②: yes

`GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two
implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a
host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s
`createHonoApp`, the documented embed shape, and any adapter built on the public
`HttpDispatcher` API), the dispatcher is the only one that answers. Its item read
and its `/published` read applied **no** per-caller read gate. An authenticated
member who does not hold `crm_admin` got these answers through `dispatch()`:

```
request before after (= RestServer)
GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED
GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED
GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED
GET /meta/app/crm (and /published) 200, every entry 200, pruned
GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED
GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name
GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off
GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied
```

Holders are still served in full. Object reads through the plain item read are
masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`.

**One gate, not two.** `RestServer`'s gate moved unchanged into
`packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs
audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate
and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912
servability gate. Both transports now call it. Each transport supplies only its
own I/O (the caller, the protocol's list read, the security service and a
service probe) and writes the gate's data verdict in its own envelope. There is
no second audience resolver in `packages/runtime`. `RestServer` keeps its
private helper names as delegates, so its own answers are byte-for-byte
unchanged.

A gate input that cannot be read is answered as that fault, never as the
document. This covers a books or doc-list read that throws, and a host whose
protocol has no list read at all (fail closed, ADR-0049).

**`@objectstack/rest`'s published export surface widens**, and that is why this
changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value
and five types:

- `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate
itself;
- `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata
list read, the security service, a service probe, a prune-log set);
- `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or
`refuse` with `absent` / `app-permission` / `docs-audience`);
- `MetaReadGateCaller`: the slice of the execution context the gate reads;
- `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate.

They are public because the runtime dispatcher's `/meta` domain in
`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import
the runtime, so the shared decision has to live here and travel as an export,
the way `repeatedQueryParamMessage` does. A caller outside the platform does not
need them.

Nothing is removed or renamed, and no authorable key moves. The dispatcher's
refusals are not the widening: they pull a second transport back to the gate
the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is
why `@objectstack/runtime` stays a `patch`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20193] Through a REAL `createHonoApp` host, the dispatcher's `/meta` item
* reads refuse and prune exactly as `RestServer` does — the composed-host pin.
*
* ## What this pins
*
* A host that mounts only `@objectstack/hono`'s `${prefix}/*` catch-all — the
* documented edge / serverless embed shape — has no `RestServer` at all: every
* `GET /meta/:type/:name` goes catch-all → `HttpDispatcher.dispatch()` → the
* domain registry → `handleMetadataRequest`. That handler applied no per-caller
* read gate, so a member who does not hold `crm_admin` read the
* `{ permissionSet }`-gated doc body, its `/published` twin and the set-gated
* book, and an app's `requiredPermissions`-gated nav entry reached every member.
* It now asks the one gate `RestServer` asks (`createMetaItemReadGate`,
* `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status,
* `error.code`, and whether the withheld content is in the body — for a
* non-holder, with a holder as the control that the same host still serves.
*
* The four rows are the card's: the doc, the doc's `/published`, the set-gated
* book, and the `requiredPermissions`-gated app entry.
*
* ## ⚠️ Why this lives HERE and not in `packages/adapters/hono`
*
* `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a
* hand-written stub for its whole suite, and `createHonoApp` imports
* `HttpDispatcher` from that specifier — so no test in that package can reach
* the real dispatcher, and a "composed" host built there would compose the
* stub. This package is where the repo already boots the two for real (see
* `hono-dispatcher-result-response.conformance.test.ts`): `@objectstack/hono`
* is aliased to this checkout's adapter SOURCE, and `@objectstack/runtime` is
* the real package (through its `dist/`, a ledgered pair in
* `scripts/check-test-source-alias.mjs`). So a runtime change reaches these
* rows only once `@objectstack/runtime` is rebuilt — which is how CI builds
* the closure before it tests.
*
* ## The one seam that is stubbed, and why
*
* WHO the caller is comes from the dispatcher's identity step
* (`timedResolveExecutionContext`), whose real inputs are a better-auth session
* and the authorization tables — neither of which this boot has. It is replaced
* on the prototype (the instance `createHonoApp` constructs for itself is the
* one that runs) by a lookup keyed on a request HEADER, read off the very Fetch
* `Request` the catch-all hands to `dispatch()`. So the principal still arrives
* on the wire, and everything after identity — the catch-all, the registry,
* the handler, the gate, the adapter's rendering — is the real code.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { HttpDispatcher } from '@objectstack/runtime';
import { createHonoApp } from '@objectstack/hono';

const PREFIX = '/api/v1';
const PRINCIPAL_HEADER = 'x-probe-principal';

// ── Fixtures (the #20156 door census's own, trimmed to the four rows) ─────────

const DOC_SECRET = 'Rotate the tenant signing keys before every release.';
const BOOK_SECRET = 'Restricted spine: the incident playbooks.';

const STORE: Record<string, any[]> = {
book: [
{
name: 'admin_guide', label: 'Admin Guide', description: BOOK_SECRET,
audience: { permissionSet: 'crm_admin' }, _packageId: 'crm',
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
},
{
name: 'help_center', label: 'Help Centre', audience: 'org', _packageId: 'crm',
groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }],
},
],
doc: [
{ name: 'crm_intro', label: 'Getting started', content: 'Welcome aboard.', _packageId: 'crm' },
{ name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' },
],
app: [{
name: 'crm',
label: 'CRM',
navigation: [
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
{ id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] },
],
}],
object: [],
};

const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
const singular = (type: unknown): string => String(type ?? '').replace(/s$/, '');
const find = (type: unknown, name: unknown): any =>
(STORE[singular(type)] ?? []).find((i: any) => i.name === name);

// ── Principals ────────────────────────────────────────────────────────────────

interface Principal { ctx: Record<string, unknown>; holdings: string[] }
const PRINCIPALS: Record<'holder' | 'non-holder', Principal> = {
holder: {
ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access'] },
holdings: ['crm_admin'],
},
'non-holder': {
ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] },
holdings: [],
},
};
type PrincipalName = keyof typeof PRINCIPALS;
const principalOf = (request: any): Principal | undefined => {
const name = request?.headers?.get?.(PRINCIPAL_HEADER) as PrincipalName | null;
return name ? PRINCIPALS[name] : undefined;
};

// ── The composed host ─────────────────────────────────────────────────────────

/**
* A real `LiteKernel` carrying the two services the `/meta` item read consults
* (`protocol`, and `security` for permission-set holdings), and the REAL
* `createHonoApp` over it.
*/
async function boot() {
const getMetaItem = vi.fn(async ({ type, name }: any) => {
const found = find(type, name);
return { type: singular(type), name, item: found ? clone(found) : undefined };
});
const protocol = {
getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])),
getMetaItem,
getMetaItemLayered: vi.fn(async ({ type, name }: any) => {
const found = find(type, name);
const layer = () => (found ? clone(found) : null);
return { type: singular(type), name, code: layer(), overlay: layer(), effective: layer() };
}),
};
const security = {
// Holdings are answered for the context the gate hands in — the
// principal the identity step resolved for THIS request.
resolvePermissionSetNames: async (ctx: any) =>
Object.values(PRINCIPALS).find((p) => p.ctx.userId === ctx?.userId)?.holdings ?? [],
};

const kernel = new LiteKernel();
kernel.use({
metadata: { name: 'test-meta-read-gate-services', version: '1.0.0' },
init: (c: any) => {
c.registerService('protocol', protocol);
c.registerService('security', security);
},
} as any);
await kernel.bootstrap();

vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext')
.mockImplementation(async (opts: any) => clone(principalOf(opts?.request)?.ctx ?? { isSystem: false }));

const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false });
const read = async (as: PrincipalName, path: string) => {
const res = await app.request(`http://localhost${PREFIX}${path}`, { headers: { [PRINCIPAL_HEADER]: as } });
const text = await res.text();
return { status: res.status, text, body: JSON.parse(text) };
};
return { read, getMetaItem };
}

afterEach(() => { vi.restoreAllMocks(); });

const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id);

// ── The four rows, non-holder ─────────────────────────────────────────────────

describe('[#20193] a real createHonoApp host refuses the non-holder what RestServer refuses — the four rows', () => {
const REFUSED: Array<{ row: string; path: string; secret: string }> = [
{ row: 'the gated doc', path: '/meta/doc/crm_admin_runbook', secret: DOC_SECRET },
{ row: 'the gated doc, /published', path: '/meta/doc/crm_admin_runbook/published', secret: DOC_SECRET },
{ row: 'the set-gated book', path: '/meta/book/admin_guide', secret: BOOK_SECRET },
];

for (const { row, path, secret } of REFUSED) {
it(`${row}: 403 PERMISSION_DENIED, and none of it on the wire`, async () => {
const { read } = await boot();
const res = await read('non-holder', path);

// The ADR-0112 envelope — both halves, never a bare status.
expect(res.status).toBe(403);
expect(res.body?.success).toBe(false);
expect(res.body?.error?.code).toBe('PERMISSION_DENIED');
expect(res.text).not.toContain(secret);
}, 30_000);
}

it('the requiredPermissions-gated app entry: 200, the app PRUNED of the entry', async () => {
const { read, getMetaItem } = await boot();
const res = await read('non-holder', '/meta/app/crm');

expect(res.status).toBe(200);
expect(res.body?.success).toBe(true);
expect(navIds(res.body?.data?.item)).toEqual(['nav_leads']);
expect(res.text).not.toContain('nav_finance_ledger');
// Reached through the catch-all's `/meta` domain — the item read ran.
expect(getMetaItem).toHaveBeenCalledTimes(1);
}, 30_000);
});

// ── The control: the same host still serves a holder ──────────────────────────

describe('[#20193] control — the same host serves a holder every row in full', () => {
it('the gated doc and its /published twin: 200, the body', async () => {
const { read } = await boot();
for (const path of ['/meta/doc/crm_admin_runbook', '/meta/doc/crm_admin_runbook/published']) {
const res = await read('holder', path);
expect(res.status, path).toBe(200);
expect(res.text, path).toContain(DOC_SECRET);
}
}, 30_000);

it('the set-gated book: 200, the book', async () => {
const { read } = await boot();
const res = await read('holder', '/meta/book/admin_guide');
expect(res.status).toBe(200);
expect(res.body?.data?.item?.description).toBe(BOOK_SECRET);
}, 30_000);

it('the app: 200, every entry', async () => {
const { read } = await boot();
const res = await read('holder', '/meta/app/crm');
expect(res.status).toBe(200);
expect(navIds(res.body?.data?.item)).toEqual(['nav_leads', 'nav_finance_ledger']);
}, 30_000);
});
38 changes: 28 additions & 10 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,20 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('70 invocation sites, 93 mentions — the thread\'s two control numbers hold', () => {
it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#20193] 70 → 68 sites / 93 → 92 mentions — a MOVE, not a new
// consumer. `metaItemReadGate`'s body and the docs-audience helpers it
// calls moved into `meta-item-read-gate.ts`, the one gate the runtime
// dispatcher's `/meta` domain now asks too. Three same-line CAUGHT sites
// left this file with them (the gate's app and dashboard arms, and
// `resolveAudienceCaller`); ONE took their place — the caller port
// `metaReadAudienceSources` hands the moved gate, still same-line
// `.catch(rethrowAuthzStoreUnavailable)`, still not behind the shared
// anonymous floor. Net −2 sites, all from the CAUGHT half (25 → 23, 17
// → 15 on the invocation line); the bare half (45) is untouched. The
// new port's docblock names the seam once, so mentions fall by one
// fewer than sites (−2 invocations, +1 prose mention).
//
// [#20156] 69 → 70 sites / 92 → 93 mentions — one net site, and it is
// a MOVE plus one. The plain read's app and dashboard gates each
// resolved the context inline (two same-line CAUGHT sites); both moved
Expand Down Expand Up @@ -423,26 +436,26 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// naming the seam is the point of the sentence — and the sentence
// moving only the mention count is this control working: a site was not
// added, and the number that tracks sites did not move.
expect(SITES.length).toBe(70);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(93);
expect(SITES.length).toBe(68);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 25 locally caught / 45 bare — NOT 17 / 45, which does not add to 70', () => {
// 17 sites spell the catch on the invocation line; 8 more spell it on
// the continuation line. A single-line grep sees 17 and the arithmetic
it('the split is 23 locally caught / 45 bare — NOT 15 / 45, which does not add to 68', () => {
// 15 sites spell the catch on the invocation line; 8 more spell it on
// the continuation line. A single-line grep sees 15 and the arithmetic
// silently loses eight sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(17);
expect(CAUGHT.length).toBe(25);
expect(sameLine.length).toBe(15);
expect(CAUGHT.length).toBe(23);
expect(BARE.length).toBe(45);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 25 caught ones is', () => {
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down Expand Up @@ -709,7 +722,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', (
// form would report "0 survivors" and read exactly like a clean pass —
// which is the precise failure that let the four sites through.
const args = catchArguments();
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(16);
// [#20193] 16 → 14, the floor keeping its one site of slack: the inline
// population fell 17 → 15 when three inline sites moved into
// `meta-item-read-gate.ts` with the gate and one inline caller port
// replaced them (§2 has the arithmetic) — the population shrank, the
// reader did not go blind.
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(14);
expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4);
expect(args.length).toBe(CAUGHT.length);
});
Expand Down
Loading
Loading