Skip to content

Commit c28f317

Browse files
objectstack-fleet[bot]claudeos-warren
authored
feat(core): one by-name read of the security catalog (ADR-0131 C2, stage S1) (#22091)
Part of #15196 Clause-②: yes (widening) **The new public surface of `@objectstack/core`.** One value export, `createSecurityCatalogReader(sources)`, re-exported from the root barrel through `packages/core/src/security/index.ts`. It takes `{ registry, metadata }` (type `SecurityCatalogSources`) and returns a `SecurityCatalogReader` with two members. `resolve(type, name)` returns a promise of the `SecurityCatalogEntry` that the name resolves to, or `undefined` when no reader holds it. `list(type)` returns a promise of an array of `SecurityCatalogEntry`, one per name, each equal to what `resolve` answers for that name. `type` is `SecurityCatalogType`, the union of `'position'`, `'permission'` and `'capability'`. Any other value rejects with a `TypeError`. An empty or non-string name resolves to `undefined`. An entry is `{ type, name, definition, source, packageId? }`. `definition` is the reader's own definition object, read-only and shared. `source` is a `SecurityCatalogSourceName`, `'registry'` or `'metadata'`. `packageId` is the definition's `_packageId`, present when the definition names one. The types exported with it are `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader`. `SecurityCatalogRegistry` needs `getItem(type, name)`, `listItems(type)` and `isPackageDisabled(packageId)`; ObjectQL's `SchemaRegistry` satisfies it as it stands. `SecurityCatalogMetadataService` needs `get(type, name)` and `list(type)`, plus the optional `getDiagnosed` and `listDiagnosed`; the `MetadataManager` satisfies it as it stands. Construction throws a `TypeError` when either source lacks a member the read calls. The read rejects with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) when a reader throws, when a metadata by-name read lost a loader and found nothing, or when a metadata list lost a loader at all. Nothing calls the reader yet, so no grant changes. ## What this stage is Stage S1 of the C2 stage plan (ADR-0131 D2–D4): one seam that later stages switch readers onto. **No consumer is switched.** Files: - `packages/core/src/security/security-catalog.ts` (new): the read. - `packages/core/src/security/index.ts`: the export, one block. - Tests only, beyond that: `packages/core/src/security/security-catalog.test.ts` (new), a new describe block at the end of `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`, and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts` (new). - `.changeset/15196-core-security-catalog-read.md`: `minor` for `@objectstack/core`. ## The reader per type, measured before choosing Re-measured on objectstack `main` at `3d9188502e` (the census was taken at `e67ba80049`; the readings are unchanged). Showcase was booted four ways: `single`, `single` with the Default Organization, walled with the real organizations package declared by a temporary host root, and walled `posture-only`. Names per reader, identical in all four boots: | type | engine registry (`SchemaRegistry.listItems`) | metadata service (`list`) | metadata door (`GET /api/v1/meta/:type`) | union of the first two equals the door | |:--|--:|--:|--:|:--| | `position` | 0 | 10 | 10 | yes | | `permission` | 17 | 9 (a subset) | 17 | yes | | `capability` | 2 | 2 (the same names) | 2 | yes | Neither in-process reader holds the whole catalog. The engine registry has no stack-declared position, because the engine's stack-collection list does not decompose `positions`. The metadata service lacks the eight platform permission sets that `plugin-security` ships on its own manifest (`admin_full_access` among them). The door is a serving surface: it reads stored rows on each request and decorates what it serves. So the read is the union of the two in-process readers, in one order: **the engine registry first, then the metadata service** for the names the registry does not hold. The reader that answers on showcase, per type: - **`permission`: the engine registry** (17 of 17). - **`position`: the metadata service** (10 of 10). - **`capability`: the engine registry**, which answers first. Both hold the same 2. For the 11 names both readers hold (9 permission sets and 2 capabilities), the definition bodies are identical across the registry, the metadata service and the door's by-name read. That was measured by comparing every non-underscore key. So the order changes no body on showcase. ## What the read does and does not answer - **It does not answer whether an item is in effect.** Under the standing ruling (#12159 Part 3), the row `active` flag stays authoritative, and no definition carries it. An entry says that a definition exists under a name, never that it grants. A later stage that reads definitions keeps reading the row flag with `isRowActive`. The module doc says so in those words. - **It does not answer the position → permission-set binding**, which stays on the junction until C3, or **organization scope**, because the catalog is environment-level under ADR-0131 D3. - **A name two packages ship** gets today's answer, which is the registry's by-name precedence. With no stored override, the read returns the first-registered package's body. Once one package stores an override bound to itself and boot hydrates it, the read returns that override for every caller. The metadata door's by-name read returns the same body in both cases. Q4 is with the maintainer, so this is pinned as today's answer and not as a policy. - **A definition owned by a disabled package answers neither member.** The registry's `listItems` already hides such items, and the by-name read applies the same rule, so the two members cannot disagree. This is the one rule the read adds on top of the readers' own answers; see the first Acceptance note. ## Pins, each ablated and restored by blob Every ablation went through `scripts/ablation-replace.mjs` in WRAP mode, so its anchor hit exactly once and the blob changed. The restore was proven with "blob == HEAD and `git diff HEAD` is empty". The dist-resolved legs also rebuilt the package, proved the marker with `scripts/ablation-dist-preflight.mjs` (`marker present in 2 built files`), and proved it gone after restore (`marker absent from all … built files`, `working tree clean against HEAD`). - **P1.1, `security-catalog-showcase.dogfood.test.ts`** (31 tests, green). For each type in each of the three postures (`single`, `single` with the Default Organization, walled `posture-only`), the read lists exactly the declared names. The expected names come from the producers, never from a registry: the showcase stack's `positions`, `permissions` and `capabilities`, plus `securityDefaultPermissionSets`. The read also lists exactly the door's names, and every listed name resolves to the entry the list gave. - Ablation A1, *dropping the platform bootstrap-set source*: `plugin-security`'s manifest `permissions:` entry was replaced with an empty array, and `plugin-security` was rebuilt. Red in all three postures: `permission: lists exactly the declared names`, with `admin_full_access` and `organization_admin` missing (expected 17, received 15). The door assertion stayed green because the door lost the same names, which is why the expected sets are derived from the producers. - Ablation A2, *the read drops its engine-registry source*: the read's registry by-name call was made unreachable, and core was rebuilt. Red in all three postures, on both the declared half and the door half (received the metadata service's 9). Positions and capabilities stayed green, as they should. - **P1.2, the new block in `protocol-boot-hydration-scoped.test.ts`** (5 tests, green). It uses the real `SchemaRegistry`, the real `loadMetaFromDb` hydration and the file's existing pinned engine double, for both `permission` and `position`. With no override, the read returns the first-registered package's body. With an override bound to package B, it returns B's override and `packageId` B. The door's `getMetaItem` is asserted beside each case. A position name that two stacks declare shares one metadata-service slot, and the later registration holds it. - Ablation, *swapping the override's package*: the stored override was bound to package A. Red: the 2 override cases (`permission`, `position`). - **Unit pins, `security-catalog.test.ts`** (14 tests, green), each ablated in the core source: - **U1**: metadata service first. Red: `a name both readers hold is answered by the engine registry` and `list gives one entry per name …`. - **U2**: disabled rule dropped. Red: both disabled-package tests. - **U3**: a registry throw read as a miss. Red: `a registry read that throws`. - **U4**: a metadata throw read as a miss. Red: `a metadata read that throws`. - **U5**: a degraded miss read as a miss. Red: `a metadata read that lost a loader and found nothing`. - **U6**: construction no longer refuses a missing metadata source. Red: `construction refuses a missing reader …`. - The failure pins assert the envelope (`code` `SERVICE_UNAVAILABLE`, `status` 503, and the brand), never a bare throw. The ablations ran at `0482468f8e`. The two later commits add the changeset and turn one fixture argument into JSON text. Neither touches an ablated line or assertion. ## Verification, at head `b70dd032be` - `pnpm --filter @objectstack/core test`: `Test Files 78 passed (78)`, `Tests 2192 passed (2192)`. This ran at `46fe89abad`; the last commit changed only an objectql test file. - `pnpm --filter @objectstack/core typecheck`: exit 0, test layer `OK`, debt unchanged. The new test file is in the `tsconfig.test.json` program (checked with `--listFiles`). - `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer `OK`. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - The P1.2 file: `Tests 8 passed (8)`. The P1.1 file (`--project isolated`): `Tests 31 passed (31)`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was re-derived on this change: 70 families, plus the 2 the dispatch named that the re-derivation does not (`check:authz-resolver`, `check:dispatcher-error-vocabulary`). All 72 were run with the exit code captured before any pipe, and all exit 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 7 unbuilt packages); after building them it exited 0, with `106 published require entry point(s) across 66 package(s) load`. `--ran` reports `70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED`. Some verdict lines: `check-engine-double-contract: OK — 980 pinned`, `check-nul-bytes: OK`, `check-test-source-alias OK`, `check:authz-resolver: single shared authorization resolver intact`, `check-adr-0087-registration: … 1 non-breaking changeset(s) seen`, `This diff introduces no major bump`. - ESLint, narrowed and measured: `eslint --no-inline-config --format json` on the 5 changed `.ts` files reports 5 files, 0 errors, 0 warnings, and every file matches the config. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`). The only files it reads are two baselines this diff does not touch, so no untouched file's verdict can move. The repository-wide `pnpm lint` is CI's. - Declared to CI: the rest of the dogfood suite, the objectql suite outside the touched file, Build Core and the workspace type-check lanes. ## Acceptance notes - **The disabled-package rule is a decision for the contract review.** No security code reads package disable today. The catalog rows a disabled package seeded keep granting through the row read. When S8 switches a reader onto this seam, a disabled package's sets would stop resolving, so S8's grant-equivalence goldens should state that. The alternative was to apply the rule to neither member, which leaves `list` and `resolve` disagreeing about whether a name exists, because the registry's `listItems` applies the rule unconditionally. - **Stack-declared positions carry no `_packageId` in the metadata service**, so the disabled-package rule cannot reach them. This is the same root as the positions being absent from the engine registry. - **A waiver reason has drifted (observation, not filed).** `scripts/check-stack-collection-maps.mjs`'s waiver for `positions` says positions "reach the registry through the security bootstrap". Measured, they reach the metadata service and the catalog rows, never the engine registry. - **The census's carrier, "the declared-positions seeder's registry-first / list fallback is an either-or trap"**, is not removed by this stage. No consumer is switched, and the seam is the place a later stage moves that seeder onto. - **No ADR anchor file** was added under `scripts/adr-anchors/`, because the stage's surface is the module and its export. ADR-0131 is cited in the module. - **The walled arm of P1.1 boots `posture-only`.** The real organizations package resolves only from a host root that declares it, and this suite's root does not. The one-off reading with a declared host root gave the same sets. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Warren Buffett <warren@objectstack.ai>
1 parent 8a925b3 commit c28f317

6 files changed

Lines changed: 840 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it.
10+
- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items but no stack-declared position, and the metadata service carries the stack-declared positions but not the platform's permission sets. Both are required; construction refuses a missing one.
11+
- **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body.
12+
- **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level.
13+
- **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member.
14+
- Nothing calls the reader yet; no grant changes.

‎packages/core/src/security/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant
219219
// enforces it and the break-glass guard that simulates a write to it.
220220
export { isRowActive, type ActivatableRow } from './row-active.js';
221221

222+
// ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions,
223+
// permission sets, capabilities) over the engine registry and the metadata
224+
// service. It says a definition EXISTS under a name, never that it is in
225+
// effect: the row `active` flag above stays the authority for that.
226+
export {
227+
createSecurityCatalogReader,
228+
type SecurityCatalogType,
229+
type SecurityCatalogSourceName,
230+
type SecurityCatalogRegistry,
231+
type SecurityCatalogMetadataService,
232+
type SecurityCatalogSources,
233+
type SecurityCatalogEntry,
234+
type SecurityCatalogReader,
235+
} from './security-catalog.js';
236+
222237
// [commit f8eb73601] The measured read surface of the administrator derivation — the
223238
// single source `plugin-auth`'s break-glass standing-key lists correspond to.
224239
export {
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
createSecurityCatalogReader,
6+
type SecurityCatalogMetadataService,
7+
type SecurityCatalogRegistry,
8+
} from './security-catalog.js';
9+
import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
10+
11+
/**
12+
* ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers.
13+
*
14+
* What the real readers answer (the registry's by-name precedence for a name
15+
* two packages ship, the sets a booted showcase holds) is pinned against the
16+
* real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts`
17+
* and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`.
18+
* Here: the read order, the union, the disabled-package rule, and that a read
19+
* which did not happen is never reported as "no such item".
20+
*/
21+
22+
type Def = Record<string, unknown>;
23+
24+
interface RegistryOptions {
25+
disabled?: readonly string[];
26+
getItem?: (type: string, name: string) => unknown;
27+
listItems?: (type: string) => readonly unknown[];
28+
}
29+
30+
/** First-registered-wins by name, and a list that hides disabled packages — the registry's own two rules. */
31+
function registry(items: Record<string, Def[]>, opts: RegistryOptions = {}): SecurityCatalogRegistry {
32+
const disabled = new Set(opts.disabled ?? []);
33+
const isPackageDisabled = (pkg?: string) => (pkg ? disabled.has(pkg) : false);
34+
return {
35+
getItem: opts.getItem ?? ((type, name) => (items[type] ?? []).find((d) => d.name === name)),
36+
listItems:
37+
opts.listItems
38+
?? ((type) => (items[type] ?? []).filter((d) => !isPackageDisabled(d._packageId as string | undefined))),
39+
isPackageDisabled,
40+
};
41+
}
42+
43+
function metadata(items: Record<string, Def[]>, over: Partial<SecurityCatalogMetadataService> = {}): SecurityCatalogMetadataService {
44+
return {
45+
async get(type, name) {
46+
return (items[type] ?? []).find((d) => d.name === name);
47+
},
48+
async list(type) {
49+
return items[type] ?? [];
50+
},
51+
...over,
52+
};
53+
}
54+
55+
async function rejection(promise: Promise<unknown>): Promise<unknown> {
56+
try {
57+
await promise;
58+
} catch (error) {
59+
return error;
60+
}
61+
throw new Error('expected the read to reject, and it resolved');
62+
}
63+
64+
describe('security catalog read — the read order and the union', () => {
65+
it('a name both readers hold is answered by the engine registry', async () => {
66+
const reader = createSecurityCatalogReader({
67+
registry: registry({ permission: [{ name: 'sales', label: 'from the registry', _packageId: 'com.acme' }] }),
68+
metadata: metadata({ permission: [{ name: 'sales', label: 'from the metadata service' }] }),
69+
});
70+
const entry = await reader.resolve('permission', 'sales');
71+
expect(entry?.source).toBe('registry');
72+
expect(entry?.definition.label).toBe('from the registry');
73+
expect(entry?.packageId).toBe('com.acme');
74+
});
75+
76+
it('a name only the metadata service holds is answered by it (stack-declared positions)', async () => {
77+
const reader = createSecurityCatalogReader({
78+
registry: registry({}),
79+
metadata: metadata({ position: [{ name: 'manager', label: 'Manager' }] }),
80+
});
81+
const entry = await reader.resolve('position', 'manager');
82+
expect(entry).toMatchObject({ type: 'position', name: 'manager', source: 'metadata' });
83+
expect(entry?.packageId).toBeUndefined();
84+
});
85+
86+
it('a name neither reader holds resolves to nothing and is not listed', async () => {
87+
const reader = createSecurityCatalogReader({
88+
registry: registry({ capability: [{ name: 'export_data' }] }),
89+
metadata: metadata({ capability: [{ name: 'export_data' }] }),
90+
});
91+
expect(await reader.resolve('capability', 'nobody_declared_this')).toBeUndefined();
92+
expect((await reader.list('capability')).map((e) => e.name)).toEqual(['export_data']);
93+
});
94+
95+
it('list gives one entry per name across both readers, each the entry resolve answers', async () => {
96+
const reader = createSecurityCatalogReader({
97+
registry: registry({
98+
permission: [
99+
{ name: 'admin_full_access', _packageId: 'com.objectstack.plugin-security' },
100+
{ name: 'shared', label: 'registry body', _packageId: 'com.acme' },
101+
],
102+
}),
103+
metadata: metadata({
104+
permission: [
105+
{ name: 'shared', label: 'metadata body', _packageId: 'com.acme' },
106+
{ name: 'declared_only', label: 'metadata only' },
107+
],
108+
}),
109+
});
110+
const listed = await reader.list('permission');
111+
expect(listed.map((e) => [e.name, e.source])).toEqual([
112+
['admin_full_access', 'registry'],
113+
['shared', 'registry'],
114+
['declared_only', 'metadata'],
115+
]);
116+
for (const entry of listed) {
117+
expect(await reader.resolve('permission', entry.name)).toEqual(entry);
118+
}
119+
});
120+
121+
it('an entry states existence only: no activation member, whatever the definition carries', async () => {
122+
const reader = createSecurityCatalogReader({
123+
registry: registry({ permission: [{ name: 'sales', _packageId: 'com.acme' }] }),
124+
metadata: metadata({}),
125+
});
126+
const entry = await reader.resolve('permission', 'sales');
127+
expect(Object.keys(entry ?? {}).sort()).toEqual(['definition', 'name', 'packageId', 'source', 'type']);
128+
});
129+
});
130+
131+
describe('security catalog read — a disabled package\'s definition answers neither read', () => {
132+
it('the registry\'s list rule holds for the by-name read too', async () => {
133+
const reader = createSecurityCatalogReader({
134+
registry: registry(
135+
{ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }, { name: 'kept', _packageId: 'com.acme' }] },
136+
{ disabled: ['com.vendor'] },
137+
),
138+
metadata: metadata({}),
139+
});
140+
expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined();
141+
expect((await reader.list('permission')).map((e) => e.name)).toEqual(['kept']);
142+
});
143+
144+
it('a metadata-service copy owned by the disabled package is not served either', async () => {
145+
const reader = createSecurityCatalogReader({
146+
registry: registry({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }, { disabled: ['com.vendor'] }),
147+
metadata: metadata({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }),
148+
});
149+
expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined();
150+
expect(await reader.list('permission')).toEqual([]);
151+
});
152+
});
153+
154+
describe('security catalog read — a read that did not happen is loud, never "no such item"', () => {
155+
const expectUnavailable = (error: unknown) => {
156+
expect(isAuthzStoreUnavailableError(error)).toBe(true);
157+
expect((error as { code?: unknown }).code).toBe('SERVICE_UNAVAILABLE');
158+
expect((error as { status?: unknown }).status).toBe(503);
159+
};
160+
161+
it('a registry read that throws', async () => {
162+
const reader = createSecurityCatalogReader({
163+
registry: registry({}, {
164+
getItem: () => { throw new Error('registry torn'); },
165+
listItems: () => { throw new Error('registry torn'); },
166+
}),
167+
metadata: metadata({}),
168+
});
169+
expectUnavailable(await rejection(reader.resolve('permission', 'admin_full_access')));
170+
expectUnavailable(await rejection(reader.list('permission')));
171+
});
172+
173+
it('a metadata read that throws', async () => {
174+
const reader = createSecurityCatalogReader({
175+
registry: registry({}),
176+
metadata: metadata({}, {
177+
get: async () => { throw new Error('loader down'); },
178+
list: async () => { throw new Error('loader down'); },
179+
}),
180+
});
181+
expectUnavailable(await rejection(reader.resolve('position', 'manager')));
182+
expectUnavailable(await rejection(reader.list('position')));
183+
});
184+
185+
it('a metadata read that lost a loader and found nothing', async () => {
186+
const reader = createSecurityCatalogReader({
187+
registry: registry({}),
188+
metadata: metadata({}, {
189+
getDiagnosed: async () => ({ data: undefined, degraded: true, errors: ['db loader: connection refused'] }),
190+
listDiagnosed: async () => ({ items: [{ name: 'manager' }], degraded: true, errors: ['db loader: connection refused'] }),
191+
}),
192+
});
193+
expectUnavailable(await rejection(reader.resolve('position', 'manager')));
194+
// A list that lost a loader is partial even when another loader answered.
195+
expectUnavailable(await rejection(reader.list('position')));
196+
});
197+
198+
it('control: a clean miss is a miss, and a degraded read that found the item serves it', async () => {
199+
const clean = createSecurityCatalogReader({
200+
registry: registry({}),
201+
metadata: metadata({}, { getDiagnosed: async () => ({ data: undefined, degraded: false, errors: [] }) }),
202+
});
203+
expect(await clean.resolve('position', 'manager')).toBeUndefined();
204+
205+
const found = createSecurityCatalogReader({
206+
registry: registry({}),
207+
metadata: metadata({}, { getDiagnosed: async () => ({ data: { name: 'manager' }, degraded: true, errors: ['x'] }) }),
208+
});
209+
expect((await found.resolve('position', 'manager'))?.name).toBe('manager');
210+
});
211+
});
212+
213+
describe('security catalog read — refusals at the edge', () => {
214+
it('construction refuses a missing reader, naming what that reader holds', () => {
215+
const md = metadata({});
216+
expect(() => createSecurityCatalogReader({ registry: undefined as never, metadata: md })).toThrow(TypeError);
217+
expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: undefined as never })).toThrow(
218+
TypeError,
219+
);
220+
expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: { get: md.get } as never })).toThrow(
221+
TypeError,
222+
);
223+
});
224+
225+
it('a type outside the catalog is refused, not answered empty', async () => {
226+
const reader = createSecurityCatalogReader({ registry: registry({}), metadata: metadata({}) });
227+
await expect(reader.resolve('view' as never, 'x')).rejects.toBeInstanceOf(TypeError);
228+
await expect(reader.list('object' as never)).rejects.toBeInstanceOf(TypeError);
229+
});
230+
231+
it('an empty or non-string name names nothing', async () => {
232+
const reader = createSecurityCatalogReader({
233+
registry: registry({ permission: [{ name: 'sales' }] }),
234+
metadata: metadata({}),
235+
});
236+
expect(await reader.resolve('permission', '')).toBeUndefined();
237+
expect(await reader.resolve('permission', undefined as never)).toBeUndefined();
238+
});
239+
});

0 commit comments

Comments
 (0)