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

fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237)

Clause-②: yes

`GET /meta/:type` has 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 list branch applied **no** per-caller filter. An
authenticated member who does not hold `crm_admin` got these answers through
`dispatch()`:

```
request before after (= RestServer)
GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out
GET /meta/book the set-gated book listed the book left out
GET /meta/app an app whose requiredPermissions the that app left out; the other app
member lacks, and an ungated app with pruned of its gated entry
its requiredPermissions-gated entry
GET /meta/dashboard (anyone) every widget minus the widget whose service is off
```

The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the
same. Holders are still listed everything in full. Object lists are masked as
before. An anonymous caller still gets `401 UNAUTHENTICATED`.

**One gate, not two.** `RestServer`'s list filters moved unchanged into
`createMetaListReadGate`, beside the item gate in
`packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc
and book audience prunes, the app nav filter (`requiredPermissions`, the
ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057
D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list
branch both call it, over the same ports the item gate takes, and each rewraps
the pruned items in its own list envelope. There is no second audience
resolver in `packages/runtime`. `RestServer`'s list answers are unchanged.

Every exit of the dispatcher's list branch now runs the gate and the ADR-0106
object mask: the protocol list and the two fallbacks, the runtime metadata
service's list and the ObjectQL registry. The fallbacks used to serve
unmasked object schemas as well as ungated docs, books and apps. A gate input
that cannot be read (a doc list's books read throws) is answered as that fault,
never as the unfiltered list.

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

- `createMetaListReadGate(sources, metaType)`: the list gate. It takes the
same `MetaItemReadGateSources` the item gate takes, and it answers a judge
from a list's items to the items this caller may be served.

It is 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 `createMetaItemReadGate` does. A caller outside the platform does not
need it.

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

/**
* [#20237] Through a REAL `createHonoApp` host, the dispatcher's `/meta/:type`
* LIST prunes exactly as `RestServer`'s 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` goes catch-all → `HttpDispatcher.dispatch()` → the domain
* registry → `handleMetadataRequest`'s list branch. That branch applied no
* per-caller gate, so a member who does not hold `crm_admin` listed the
* `{ permissionSet }`-gated doc WITH its body (`?include=content`), the
* set-gated book, an app whose `requiredPermissions` they lack, and an ungated
* app with its `requiredPermissions`-gated nav entry. It now asks the one list
* gate `RestServer`'s `GET /meta/:type` asks (`createMetaListReadGate`,
* `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status,
* the listed names, 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 three rows are the card's: the doc list with bodies, the book list, and
* the app list. What `RestServer` answers the same caller is pinned beside the
* dispatcher's in `meta-list-read-gate-parity.test.ts` (`@objectstack/runtime`).
*
* ## Where this lives, and the one stubbed seam
*
* Beside `hono-meta-item-read-gate.conformance.test.ts`, for its reasons:
* `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a
* stub for its whole suite, so no test there can compose the real dispatcher;
* here `@objectstack/hono` is 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`) — a runtime change reaches
* these rows only once `@objectstack/runtime` is rebuilt. WHO the caller is
* comes from the dispatcher's identity step (`timedResolveExecutionContext`),
* replaced on the prototype by a lookup keyed on a request HEADER read off the
* very Fetch `Request` the catch-all hands to `dispatch()`; 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 item pin's, as lists) ───────────────────────────────────────

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'] },
],
},
{
name: 'payroll',
label: 'Payroll',
requiredPermissions: ['payroll.access'],
navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }],
},
],
object: [],
};

const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
const singular = (type: unknown): string => String(type ?? '').replace(/s$/, '');

// ── 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', 'payroll.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` list consults
* (`protocol`, and `security` for permission-set holdings), and the REAL
* `createHonoApp` over it.
*/
async function boot() {
const getMetaItems = vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? []));
const protocol = { getMetaItems };
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-list-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();
const body = JSON.parse(text);
const data = body?.data;
const items: any[] = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
return { status: res.status, text, body, items };
};
return { read, getMetaItems };
}

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

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

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

describe('[#20237] a real createHonoApp host lists the non-holder what RestServer lists — the three rows', () => {
it('the doc list with bodies: 200, the gated doc left out, and its body nowhere on the wire', async () => {
const { read, getMetaItems } = await boot();
const res = await read('non-holder', '/meta/doc?include=content');

expect(res.status).toBe(200);
expect(res.body?.success).toBe(true);
expect(names(res.items)).toEqual(['crm_intro']);
// The opt-in still serves the bodies the caller may read.
expect(res.items[0]?.content).toBe('Welcome aboard.');
expect(res.text).not.toContain('crm_admin_runbook');
expect(res.text).not.toContain(DOC_SECRET);
// Reached through the catch-all's `/meta` domain — the list read ran.
expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'doc' }));
}, 30_000);

it('the book list: 200, the set-gated book left out', async () => {
const { read } = await boot();
const res = await read('non-holder', '/meta/book');

expect(res.status).toBe(200);
expect(names(res.items)).toEqual(['help_center']);
expect(res.text).not.toContain('admin_guide');
expect(res.text).not.toContain(BOOK_SECRET);
}, 30_000);

it('the app list: 200, the requiredPermissions-gated app left out and the ungated app PRUNED of its gated entry', async () => {
const { read } = await boot();
const res = await read('non-holder', '/meta/app');

expect(res.status).toBe(200);
expect(names(res.items)).toEqual(['crm']);
expect(navIds(res.items[0])).toEqual(['nav_leads']);
expect(res.text).not.toContain('payroll');
expect(res.text).not.toContain('nav_finance_ledger');
}, 30_000);
});

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

describe('[#20237] control — the same host lists a holder every row in full', () => {
it('the doc list with bodies: both docs, the gated body included', async () => {
const { read } = await boot();
const res = await read('holder', '/meta/doc?include=content');
expect(res.status).toBe(200);
expect(names(res.items)).toEqual(['crm_intro', 'crm_admin_runbook']);
expect(res.text).toContain(DOC_SECRET);
}, 30_000);

it('the book list: both books', async () => {
const { read } = await boot();
const res = await read('holder', '/meta/book');
expect(res.status).toBe(200);
expect(names(res.items)).toEqual(['admin_guide', 'help_center']);
expect(res.text).toContain(BOOK_SECRET);
}, 30_000);

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

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('66 invocation sites, 90 mentions — the thread\'s two control numbers hold', () => {
// [#20237] 68 → 66 sites / 92 → 90 mentions — a MOVE, not a lost
// consumer. `GET /meta/:type`'s app nav filter and dashboard widget
// gate each resolved the context inline (two same-line CAUGHT sites);
// both moved into `createMetaListReadGate` in `meta-item-read-gate.ts`,
// the one list gate the runtime dispatcher's `/meta` list branch now
// asks too, where they read the caller through the SAME port the item
// gate uses (`metaReadAudienceSources`'s `resolveCaller`, already
// counted, still same-line `.catch(rethrowAuthzStoreUnavailable)`). So
// no site replaced them: −2 sites, both from the CAUGHT half (23 → 21,
// 15 → 13 on the invocation line); the bare half (45) is untouched. No
// prose mention was added, so the two numbers move together (−2 / −2).
//
// [#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
Expand Down Expand Up @@ -436,26 +448,27 @@ 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(68);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(66);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(90);
});

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.
it('the split is 21 locally caught / 45 bare — NOT 13 / 45, which does not add to 66', () => {
// 13 sites spell the catch on the invocation line; 8 more spell it on
// the continuation line. A single-line grep sees 13 and the arithmetic
// silently loses eight sites. [#20237] 15 → 13 and 23 → 21: the list
// route's app and dashboard sites moved into the shared list gate (§2).
//
// [#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(15);
expect(CAUGHT.length).toBe(23);
expect(sameLine.length).toBe(13);
expect(CAUGHT.length).toBe(21);
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 23 caught ones is', () => {
it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 21 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 @@ -727,7 +740,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', (
// `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);
//
// [#20237] 14 → 12, the same one site of slack: the inline population
// fell 15 → 13 when the list route's app and dashboard sites moved into
// `createMetaListReadGate`, and no port replaced them (§2) — again the
// population shrank, not the reader's sight.
expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(12);
expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4);
expect(args.length).toBe(CAUGHT.length);
});
Expand Down
Loading
Loading