diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md new file mode 100644 index 00000000000..980162dd16f --- /dev/null +++ b/.changeset/22135-security-catalog-one-holder.md @@ -0,0 +1,27 @@ +--- +'@objectstack/objectql': major +--- + +feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders + +Clause-②: no + + + +**BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. + +**Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. + +**What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are: + +- another installed package; +- the environment catalog: an item authored in this environment, in the registry's bare slot with no package; +- a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). + +The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. + +**What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. + +**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on #22307, which lands separately. + +**Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. diff --git a/packages/core/src/security/security-catalog.test.ts b/packages/core/src/security/security-catalog.test.ts index 716b794eb0c..028b5268ea0 100644 --- a/packages/core/src/security/security-catalog.test.ts +++ b/packages/core/src/security/security-catalog.test.ts @@ -11,10 +11,12 @@ import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; /** * ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers. * - * What the real readers answer (the registry's by-name precedence for a name - * two packages ship, the sets a booted showcase holds) is pinned against the - * real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts` - * and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. + * What the real readers answer (a name a second package is refused, so every + * reader answers its one holder; the sets a booted showcase holds) is pinned + * against the real readers elsewhere: the `security catalog read — a name two + * packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts` + * (the refusal itself, door by door: `registry-security-catalog-namespace.test.ts` + * beside it) and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. * Here: the read order, the union, the disabled-package rule, and that a read * which did not happen is never reported as "no such item". */ diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts index 72246cf6226..7da2520996d 100644 --- a/packages/core/src/security/security-catalog.ts +++ b/packages/core/src/security/security-catalog.ts @@ -50,18 +50,19 @@ * misses the platform's own permission sets, so a resolver reading it would * fail the platform administrator anchor closed. * - * ## A name two packages ship — today's answer, pinned until it is ruled + * ## A name two packages ship — ruled: there is only ever one holder * * The by-name read takes no package context, because an assignment carries - * only the name (ADR-0131 D4). So which body a name two installed packages both - * ship resolves to is decided by the registry's own by-name precedence: a - * stored override in the bare slot first (ADR-0005), else the FIRST-registered - * package's body. Measured: with no override every by-name read answers the - * first-registered package; once one package stores an override bound to - * itself, every by-name read answers that override. Whether the catalog should - * refuse a shared name instead is an open maintainer question; until it is - * ruled, the pins beside this module hold today's answer, and a change to it - * is a decision, not a refactor. + * only the name (ADR-0131 D4). So a name two installed packages both shipped + * would resolve by the registry's own precedence — measured before the ruling: + * the FIRST-registered package's body, or whichever package stored an override. + * The maintainer ruled that ambiguity out instead (Q4 = A on #15196): each + * catalog type holds one name per deployment, and the engine registry refuses + * a package registering a name an installed package, the environment catalog + * or a built-in already holds (`@objectstack/objectql`, + * `security-catalog-namespace.ts`). So this read never chooses between two + * packages' bodies; a stored override in the bare slot is the holder's own + * (ADR-0005), and it answers ahead of the holder's shipped body. * * ## What this read does NOT answer * diff --git a/packages/objectql/src/engine-capability-provenance.test.ts b/packages/objectql/src/engine-capability-provenance.test.ts index b60120cd529..95389836bca 100644 --- a/packages/objectql/src/engine-capability-provenance.test.ts +++ b/packages/objectql/src/engine-capability-provenance.test.ts @@ -46,6 +46,7 @@ import { describe, it, expect } from 'vitest'; import { pluralToSingular } from '@objectstack/spec/shared'; import { ObjectQL } from './engine'; +import { NAMESPACE_CONFLICT_CODE } from './registry'; /** * The exact read `bootstrapDeclaredCapabilities` performs on the engine, kept @@ -112,14 +113,27 @@ describe('registerApp — declared capabilities carry registry provenance (#5870 expect(cap?._provenance).toBe(permission?._provenance); }); - it('keeps two packages\' same-named capabilities attributed to their own owner', () => { + // This case used to pin two packages' same-named capabilities COEXISTING, + // each attributed to its own owner (ADR-0048 §3.4's coexistence). The + // maintainer's ruling Q4 = A on #15196 takes the security catalog out of + // §3.4: one capability name, one holder per deployment — the second package + // is refused at registration, and the first keeps its attribution. The + // refusal is pinned door by door in `registry-security-catalog-namespace.test.ts`. + it('refuses a second package\'s same-named capability; the first keeps its attribution', () => { const engine = new ObjectQL(); engine.registerApp({ id: 'com.acme.crm', capabilities: [{ name: 'export_data', label: 'CRM Export' }] }); - engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] }); + let refusal: (Error & { code?: string; status?: number; existingHolder?: unknown }) | undefined; + try { + engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] }); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: 'com.acme.crm' }); expect(engine.registry.getItem('capability', 'export_data', 'com.acme.crm')?.label).toBe('CRM Export'); - expect(engine.registry.getItem('capability', 'export_data', 'com.acme.hr')?.label).toBe('HR Export'); - expect(readDeclaredShape(engine, 'capability')).toHaveLength(2); + expect(readDeclaredShape(engine, 'capability').map((c) => c._packageId)).toEqual(['com.acme.crm']); }); it('stamps capabilities declared by a NESTED plugin too (the second seam)', () => { diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index ca9944d6b5c..8d155789d66 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -23,7 +23,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { MetadataManager } from '@objectstack/metadata'; import { createSecurityCatalogReader } from '@objectstack/core'; -import { SchemaRegistry } from './registry.js'; +import { SchemaRegistry, NAMESPACE_CONFLICT_CODE } from './registry.js'; import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; @@ -204,55 +204,69 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# /** * ADR-0131 D4 — which body the security catalog read * (`createSecurityCatalogReader`, `@objectstack/core`) resolves for a name two - * installed packages both ship. TODAY'S answer, pinned until the maintainer - * rules on shared catalog names: a change here is that ruling landing, never a - * refactor. + * installed packages both ship. RULED (maintainer, Q4 = A on #15196): there is + * never a second body to choose between. Positions, permission sets and + * capabilities each hold one name per deployment, so the second package to + * register a held name is refused at registration, naming both holders + * (`security-catalog-namespace.ts`; the doors are pinned in + * `registry-security-catalog-namespace.test.ts`). * - * It lives beside the #4624 cases because it is their consequence: the row a - * package stored for itself is hydrated into the bare slot, and the catalog - * read asks the registry's by-name precedence first. An assignment carries - * only the NAME (ADR-0131 D4), so there is no package context to prefer one - * package's body over another's: + * This describe was the S1 stage's pin of the pre-ruling answer — the + * FIRST-registered package's body with no stored override, a stored override + * for every caller with one, and the LATER registration for a position two + * stacks declared — kept "until the maintainer rules on shared catalog names". + * It now pins the ruled answer at the same seams: an assignment carries only + * the NAME, and with one holder every reader answers that holder. * - * - no stored override: the FIRST-registered package's body; - * - an override one package stored, bound to itself and hydrated at boot: - * that override, for every caller. - * - * The metadata door's by-name read (`getMetaItem`) is asserted beside each - * answer, so the pin cannot drift from what the door serves. A name the - * registry does not hold falls to the metadata service, where two stacks - * declaring one position name share ONE in-memory slot: the later - * registration holds it. + * It lives beside the #4624 cases because the override case is their + * consequence: the row the holder stored for itself is hydrated into the bare + * slot, and the catalog read asks the registry's by-name precedence first. The + * metadata door's by-name read (`getMetaItem`) is asserted beside each answer, + * so the pin cannot drift from what the door serves. */ -describe('security catalog read — a name two packages ship (ADR-0131 D4, today\'s answer)', () => { - /** The package whose stored override the second case hydrates. */ - const OVERRIDE_PACKAGE = PKG_B; - +describe('security catalog read — a name two packages ship (ADR-0131 D4, ruled: one holder per name)', () => { /** A body each catalog type's schema accepts, so hydration reports it valid. */ const catalogBody = (type: 'permission' | 'position', name: string, label: string) => type === 'permission' ? { name, label, objects: {} } : { name, label }; - function bootWithSharedName(type: 'permission' | 'position', name: string, rows: Row[]) { + type Refusal = Error & { code?: string; status?: number; incomingPackageId?: string; existingHolder?: unknown }; + const refusalOf = (fn: () => unknown): Refusal | undefined => { + try { + fn(); + return undefined; + } catch (e) { + return e as Refusal; + } + }; + + function bootWithHolder(type: 'permission' | 'position', name: string, rows: Row[]) { const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; - // Package A registers FIRST. + // Package A registers FIRST, and holds the name. registry.registerItem(type, catalogBody(type, name, `${PKG_A} body`), 'name', PKG_A); - registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B); const protocol = new ObjectStackProtocolImplementation(makeEngine(registry, rows)); const reader = createSecurityCatalogReader({ registry, metadata: new MetadataManager({ formats: ['json'], loaders: [] }), }); - return { protocol, reader }; + return { registry, protocol, reader }; } describe.each(['permission', 'position'] as const)('%s', (type) => { const name = `shared_${type}`; - it('no stored override: the first-registered package\'s body', async () => { - const { protocol, reader } = bootWithSharedName(type, name, []); - expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); + it('a second package registering the name is refused, naming both holders; every reader answers the one holder', async () => { + const { registry, protocol, reader } = bootWithHolder(type, name, []); + const refusal = refusalOf(() => + registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B), + ); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.incomingPackageId).toBe(PKG_B); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: PKG_A }); + + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); const entry = await reader.resolve(type, name); expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); expect(entry?.definition.label).toBe(`${PKG_A} body`); @@ -263,41 +277,51 @@ describe('security catalog read — a name two packages ship (ADR-0131 D4, today expect(door.item?.label).toBe(`${PKG_A} body`); }); - it('an override one package stored for itself: that override, for every caller', async () => { + it('an override the holder stored for itself: that override, for every caller', async () => { const rows = [ overlayRow({ type, name, - package_id: OVERRIDE_PACKAGE, + package_id: PKG_A, metadata: JSON.stringify(catalogBody(type, name, 'stored override')), }), ]; - const { protocol, reader } = bootWithSharedName(type, name, rows); + const { protocol, reader } = bootWithHolder(type, name, rows); expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); const entry = await reader.resolve(type, name); - expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_B }); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); expect(entry?.definition.label).toBe('stored override'); expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); const door: any = await protocol.getMetaItem({ type, name }); expect(door.item?.label).toBe('stored override'); - expect(door.item?._packageId).toBe(PKG_B); + expect(door.item?._packageId).toBe(PKG_A); }); }); - it('a position name two stacks declare: one metadata-service slot, the later registration holds it', async () => { + it('a position name two packages declare: the package door refuses the second, so one stack\'s declaration reaches the metadata service', async () => { const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; const metadata = new MetadataManager({ formats: ['json'], loaders: [] }); - // What two app stacks declaring the same position name do at boot. + // What two app stacks declaring the same position name do at boot: each + // package is installed (Phase 1, `AppPlugin.init` → `registerApp`) before + // its in-memory registrar runs (Phase 2, `AppPlugin.start`). + const stack = (id: string, label: string) => + ({ id, name: id, version: '1.0.0', type: 'app', positions: [{ name: 'regional_manager', label }] }) as never; + registry.installPackage(stack(PKG_A, 'first stack')); metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'first stack' }); - metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'second stack' }); - const reader = createSecurityCatalogReader({ registry, metadata }); + const refusal = refusalOf(() => registry.installPackage(stack(PKG_B, 'second stack'))); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.incomingPackageId).toBe(PKG_B); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: PKG_A }); + + const reader = createSecurityCatalogReader({ registry, metadata }); const entry = await reader.resolve('position', 'regional_manager'); expect(entry).toMatchObject({ name: 'regional_manager', source: 'metadata' }); - expect(entry?.definition.label).toBe('second stack'); + expect(entry?.definition.label).toBe('first stack'); expect(await reader.list('position')).toEqual([entry]); }); }); diff --git a/packages/objectql/src/registry-security-catalog-namespace.test.ts b/packages/objectql/src/registry-security-catalog-namespace.test.ts new file mode 100644 index 00000000000..7122b94af11 --- /dev/null +++ b/packages/objectql/src/registry-security-catalog-namespace.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one holder — the security catalog's namespace rule at the engine's + * package door and at the registry's item seam (maintainer ruling Q4 = A on + * #15196; `security-catalog-namespace.ts` states the rule and its holders). + * + * The door under test is the real one: the `manifest` service ObjectQLPlugin + * registers, which every package registration reaches — `AppPlugin.init` at + * boot (an artifact boot included), a hot install (`install-local`), a + * post-start `manifest.register` — through `ObjectQL.registerApp` → + * `SchemaRegistry.installPackage`. + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`) and by + * the two holders it names; the message's prose is not pinned. + * + * The controls are what keep the refusal as narrow as the ruling: a same-package + * reload is one holder; a non-catalog type shared by two packages still + * coexists (ADR-0048 §3.4); an environment registration over a package-held + * name (the bare slot every `sys_metadata` hydration writes) is not judged; + * `collisionPolicy: 'warn'` does not downgrade it. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { SchemaRegistry, NAMESPACE_CONFLICT_CODE } from './registry.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; +type Holder = { kind: string; packageId?: string }; +type Refusal = Error & { + code?: string; + status?: number; + incomingPackageId?: string; + existingHolder?: Holder; + conflicts?: Array<{ catalogType: string; name: string; incomingPackageId: string; existingHolder: Holder }>; +}; + +type CatalogType = 'position' | 'permission' | 'capability'; +const TYPES: CatalogType[] = ['position', 'permission', 'capability']; +const COLLECTION: Record = { position: 'positions', permission: 'permissions', capability: 'capabilities' }; + +/** A body each catalog type's schema accepts. */ +const body = (type: CatalogType, name: string, label: string) => + type === 'permission' ? { name, label, objects: {} } : { name, label }; + +/** A flat package payload — the shape `AppPlugin.init` hands the manifest service. */ +const pkg = (id: string, decl: Partial> = {}, extra: Record = {}) => { + const out: Record = { id, name: id.split('.').pop(), version: '1.0.0', type: 'app', ...extra }; + for (const type of TYPES) { + const names = decl[type]; + if (names) out[COLLECTION[type]] = names.map((n) => body(type, n, `${id} ${n}`)); + } + return out; +}; + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +const kernels: ObjectKernel[] = []; +const boot = async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + return { registry: ql.registry, register: (m: unknown) => caught(() => manifest.register(m)) }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +/** The envelope, and the two holders, of one refusal. */ +function expectRefusal(err: Refusal | undefined, incoming: string, holder: Holder) { + expect(err, 'the registration was refused').toBeDefined(); + expect(err!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.status).toBe(422); + expect(err!.incomingPackageId).toBe(incoming); + expect(err!.existingHolder).toEqual(holder); + expect(err!.message).toContain(incoming); + if (holder.packageId) expect(err!.message).toContain(holder.packageId); +} + +describe.each(TYPES)('%s — a second package declaring a name another package holds', (type) => { + const name = `shared_${type}`; + + it('is refused at the package door, naming both packages, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + + const err = await register(pkg('com.acme.second', { [type]: [name] })); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + expect(err!.conflicts).toEqual([ + { catalogType: type, name, incomingPackageId: 'com.acme.second', existingHolder: { kind: 'package', packageId: 'com.acme.first' } }, + ]); + // Ahead of every mutation: no package record, so nothing to uninstall. + expect(registry.getPackage('com.acme.second')).toBeUndefined(); + expect(registry.getPackage('com.acme.first')).toBeDefined(); + }); + + it('same-package reload is one holder, not two', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + }); + + it('uninstalling the holder releases the name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + expect(registry.uninstallPackage('com.acme.first')).toBe(true); + expect(await register(pkg('com.acme.second', { [type]: [name] }))).toBeUndefined(); + }); + + it('a nested plugin declares under its parent package, and is held to the same rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', {}, { plugins: [{ name: 'inner', [COLLECTION[type]]: [body(type, name, 'inner')] }] }))).toBeUndefined(); + const err = await register(pkg('com.acme.second', { [type]: [name] })); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + }); + + it('the item seam refuses a package-bound registration over another package\'s name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + const err = await caught(() => registry.registerItem(type, body(type, name, 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'package', packageId: 'com.acme.first' }); + }); +}); + +describe('the built-in holders', () => { + it.each([ + ['position', 'everyone'], + ['position', 'org_admin'], + ['capability', 'manage_users'], + ] as const)('%s "%s" is a built-in no package can declare', async (type, name) => { + const { register } = await boot(); + const err = await register(pkg('com.acme.app', { [type]: [name] })); + expectRefusal(err, 'com.acme.app', { kind: 'built-in' }); + }); + + it('the platform registers its own built-in positions at the item seam, under its own package id', async () => { + const { registry } = await boot(); + expect(await caught(() => registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined(); + }); + + // An environment item under a built-in name exists only where an environment + // save went over the platform's name (outside the ruling), and boot hydration + // registers it BEFORE the platform's `start()` declares the built-ins. The + // declaration is the built-in holder's own registration, not a second holder: + // it is admitted, and the stored definition keeps answering first. + it('the platform\'s declaration of a built-in name is admitted over an environment item under that name, which keeps answering first', async () => { + const { registry } = await boot(); + registry.registerItem('position', body('position', 'org_admin', 'stored at the door'), 'name'); + expect(await caught(() => registry.registerItem('position', body('position', 'org_admin', 'Organization Admin'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined(); + expect(registry.getItem<{ label: string }>('position', 'org_admin')?.label).toBe('stored at the door'); + }); + + it('…while a second PACKAGE registering a built-in name at the item seam is refused, in either order', async () => { + const first = await boot(); + first.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'); + const after = await caught(() => first.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other')); + expectRefusal(after, 'com.acme.other', { kind: 'package', packageId: 'com.objectstack.plugin-security' }); + + const second = await boot(); + second.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other'); + const before = await caught(() => second.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security')); + expectRefusal(before, 'com.objectstack.plugin-security', { kind: 'package', packageId: 'com.acme.other' }); + }); + + it('CONTROL — for a name that is NOT built in, an environment item still refuses a package-bound registration at the item seam', async () => { + const { registry } = await boot(); + registry.registerItem('position', body('position', 'env_position', 'stored at the door'), 'name'); + const err = await caught(() => registry.registerItem('position', body('position', 'env_position', 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'environment' }); + }); + + // The platform's permission sets are declared by plugin-security on its own + // manifest, so they are held as that package's; which side the refusal stops + // depends on which registers first, and both sides are named either way. + const platform = () => pkg('com.objectstack.plugin-security', { permission: ['admin_full_access'] }); + + it('a permission set the platform declares is refused to an app registering after it', async () => { + const { register } = await boot(); + expect(await register(platform())).toBeUndefined(); + const err = await register(pkg('com.acme.app', { permission: ['admin_full_access'] })); + expectRefusal(err, 'com.acme.app', { kind: 'package', packageId: 'com.objectstack.plugin-security' }); + }); + + it('…and an app that registered first stops the platform\'s registration, naming the app', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['admin_full_access'] }))).toBeUndefined(); + const err = await register(platform()); + expectRefusal(err, 'com.objectstack.plugin-security', { kind: 'package', packageId: 'com.acme.app' }); + }); +}); + +describe('the environment catalog as a holder', () => { + it('a package declaring a name an environment-authored item holds is refused', async () => { + const { registry, register } = await boot(); + // The bare slot, with no package — what `sys_metadata` hydration registers. + registry.registerItem('permission', body('permission', 'env_set', 'authored here'), 'name'); + const err = await register(pkg('com.acme.app', { permission: ['env_set'] })); + expectRefusal(err, 'com.acme.app', { kind: 'environment' }); + }); + + it('CONTROL — an environment registration over a package-held name is not judged (outside the ruling)', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + expect(await caught(() => registry.registerItem('permission', body('permission', 'pkg_set', 'env over package'), 'name'))).toBeUndefined(); + }); + + it('a package\'s own stored override in the bare slot is that package\'s, not a second holder', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + registry.registerItem('permission', { ...body('permission', 'pkg_set', 'override'), _packageId: 'com.acme.app' }, 'name'); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.other', { permission: ['pkg_set'] })); + expectRefusal(err, 'com.acme.other', { kind: 'package', packageId: 'com.acme.app' }); + }); +}); + +describe('the refusal reports, and only refuses, what the ruling covers', () => { + it('every conflicting name is listed in one refusal, and a refused package claims none of its names', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { position: ['p1'], permission: ['s1'], capability: ['c1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.second', { position: ['p1', 'free_position'], permission: ['s1'], capability: ['c1'] })); + expect(err?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.conflicts!.map((c) => `${c.catalogType}/${c.name}`)).toEqual(['position/p1', 'permission/s1', 'capability/c1']); + // The refused package's free name was never claimed. + expect(await register(pkg('com.acme.third', { position: ['free_position'] }))).toBeUndefined(); + }); + + it('CONTROL — a non-catalog type shared by two packages still coexists (ADR-0048 §3.4)', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', {}, { pages: [{ name: 'home', label: 'first home', type: 'app' }] }))).toBeUndefined(); + expect(await register(pkg('com.acme.second', {}, { pages: [{ name: 'home', label: 'second home', type: 'app' }] }))).toBeUndefined(); + expect(registry.getItem<{ label: string }>('page', 'home', 'com.acme.second')?.label).toBe('second home'); + }); + + it('collisionPolicy "warn" does not downgrade it', async () => { + const registry = new SchemaRegistry({ collisionPolicy: 'warn' }); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.first', { position: ['p1'] }) as never); + const err = await caught(() => registry.installPackage(pkg('com.acme.second', { position: ['p1'] }) as never)); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + }); + + it('a manifest-stage `permissions` grant block is not read as permission sets', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { permission: ['services'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.second', {}, { permissions: { services: ['data'] } }))).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5b4161ea96b..fc1eac1a80d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -52,7 +52,18 @@ import { applyProtection } from '@objectstack/spec/shared'; // (`id || name`). The install gate's co-ownership set must name packages by the // same string the artifact loader ordered them by, or a co-owner would be // admitted — or refused — under a key nothing else in the path uses. -import { artifactPackageId } from '@objectstack/core'; +import { artifactPackageId, type SecurityCatalogType } from '@objectstack/core'; +// One name, one holder for positions, permission sets and capabilities — the +// rule, the holders and the one place a declaration is read from (module doc). +import { + BUILT_IN_SECURITY_CATALOG_NAMES, + declaredSecurityCatalogNames, + describeSecurityCatalogHolder, + isSecurityCatalogType, + securityCatalogHolderKey, + securityCatalogTypeLabel, + type SecurityCatalogHolder, +} from './security-catalog-namespace.js'; // [#14553] The ONE resolution of "does this nav group id exist?" and the ONE // wording of the diagnostic when it does not. `os build` calls the same two // (`checkNavContributionGroups`), so the compile-time door and this read-time @@ -360,7 +371,10 @@ export interface SchemaRegistryOptions { * explicitly. Same-package reinstall and shareable platform namespaces * (`base`/`system`/`sys`) are never treated as conflicts. (The per-item * cross-package collision throw was retired in ADR-0048 §3.4 — distinct - * package ids are always disambiguable by package-scoped resolution.) + * package ids are always disambiguable by package-scoped resolution — except + * for positions, permission sets and capabilities, whose second-holder + * refusal ({@link SecurityCatalogNameConflictError}) this policy does NOT + * downgrade: no ruling extends `warn` to it.) */ collisionPolicy?: 'error' | 'warn'; @@ -1555,6 +1569,82 @@ export class NamespaceConflictError extends Error { } } +/** One second holder {@link SecurityCatalogNameConflictError} refused. */ +export interface SecurityCatalogNameConflict { + /** The catalog type, as the registry keys it. */ + readonly catalogType: SecurityCatalogType; + /** The name both holders claim. */ + readonly name: string; + /** The package whose registration this refusal stopped. */ + readonly incomingPackageId: string; + /** Who already holds the name. */ + readonly existingHolder: SecurityCatalogHolder; +} + +/** + * Raised when a package registers a position, permission set or capability + * whose name another holder already holds — an installed package, the + * environment catalog or a built-in (`security-catalog-namespace.ts` states the + * rule, the holders, and what it deliberately does not judge). + * + * The namespace gate's sibling, in its shape: the ADR-0112 envelope (`code` + + * `status: 422`) so a wire door answers `422`, never the `500` fallback, and + * the code the namespace gate already registers — the condition is the same + * one, "a name in a deployment-wide namespace is already taken", and so is the + * caller's remedy: rename, or uninstall the other holder. ⛔ Not the namespace + * gate's class: that one names a `manifest.namespace` and offers the + * `OS_METADATA_COLLISION=warn` downgrade, neither of which is true here. + * + * Every conflict the registration carries is listed — `conflicts` — so one boot + * reports all of them; the top-level fields repeat the first. + */ +export class SecurityCatalogNameConflictError extends Error { + readonly code = NAMESPACE_CONFLICT_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** Every second holder this registration would have created, in declaration order. */ + readonly conflicts: readonly SecurityCatalogNameConflict[]; + /** The first conflict's catalog type. */ + readonly catalogType: SecurityCatalogType; + /** The first conflict's name. */ + readonly catalogName: string; + /** The package whose registration this refusal stopped. */ + readonly incomingPackageId: string; + /** The first conflict's existing holder. */ + readonly existingHolder: SecurityCatalogHolder; + + constructor(conflicts: readonly SecurityCatalogNameConflict[]) { + const [first] = conflicts; + const lines = conflicts.map( + (c) => + `${securityCatalogTypeLabel(c.catalogType)} "${c.name}" is already held by ` + + describeSecurityCatalogHolder(c.existingHolder), + ); + super( + `Security catalog name conflict: package "${first.incomingPackageId}" cannot register ` + + `${conflicts.length === 1 ? 'a name' : `${conflicts.length} names`} another holder ` + + `already holds — ${lines.join('; ')}. Positions, permission sets and capabilities each ` + + `hold one name per deployment: an assignment names a position or a permission set by ` + + `its bare name, with no package to tell two definitions apart, so with two holders ` + + `which definition grants would depend on registration order. Rename the item in ` + + `"${first.incomingPackageId}"${ + first.existingHolder.kind === 'package' + ? `, rename it in "${first.existingHolder.packageId}", or uninstall one of the two packages` + : first.existingHolder.kind === 'environment' + ? ', or rename or delete the environment\'s item first' + : ' — a built-in name is never available to a package' + }. See ADR-0048.`, + ); + this.name = 'SecurityCatalogNameConflictError'; + this.conflicts = conflicts; + this.catalogType = first.catalogType; + this.catalogName = first.name; + this.incomingPackageId = first.incomingPackageId; + this.existingHolder = first.existingHolder; + } +} + /** * [ADR-0130 D1] What the install gate is told about the artifact now installing. * @@ -2154,6 +2244,127 @@ export class SchemaRegistry { return owners ? Array.from(owners) : []; } + // ========================================== + // Security catalog namespace — one name, one holder + // ========================================== + + /** + * Catalog type → name → the installed package that declared it, recorded by + * {@link installPackage} once its declarations pass + * {@link refuseSecurityCatalogNameConflicts}, forgotten by + * {@link uninstallPackage}. + * + * The registry's item store answers who holds a name only for items + * something registered into it, and `installPackage` registers none: a + * package's collections reach the item store through `ObjectQL.registerApp`'s + * collection loop (positions only where that loop carries them), never + * through a bare `installPackage`. The claim is how the package door + * remembers every name a package declared, so a second package declaring one + * of them is refused like any other second holder. + */ + private securityCatalogClaims = new Map>(); + + /** + * Every holder of `(type, name)` other than `exceptPackageId` — the ONE + * predicate both refusals ({@link refuseSecurityCatalogNameConflicts} at the + * package door, the item seam in {@link registerItem}) ask. + * + * Read, in this order: a built-in (only when `builtIns` — see + * {@link BUILT_IN_SECURITY_CATALOG_NAMES} for why the item seam does not ask); + * the bare slot (an override a package bound to itself, else an + * environment-authored item — the latter only when `environment`); every + * composite `:` slot; the package claims. A package that + * holds the name more than one way is one holder. A disabled package is still + * installed, and still holds its names. + */ + private securityCatalogHoldersOtherThan( + type: SecurityCatalogType, + name: string, + exceptPackageId: string | undefined, + opts: { builtIns: boolean; environment: boolean }, + ): SecurityCatalogHolder[] { + const found = new Map(); + const add = (holder: SecurityCatalogHolder) => { + if (holder.kind === 'package' && holder.packageId === exceptPackageId) return; + found.set(securityCatalogHolderKey(holder), holder); + }; + if (opts.builtIns && BUILT_IN_SECURITY_CATALOG_NAMES[type].has(name)) add({ kind: 'built-in' }); + const suffix = `:${name}`; + for (const [key, item] of this.metadata.get(type) ?? []) { + const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; + if (key === name) { + if (typeof stamped === 'string' && stamped !== '') add({ kind: 'package', packageId: stamped }); + else if (opts.environment) add({ kind: 'environment' }); + } else if (key.endsWith(suffix)) { + add({ + kind: 'package', + packageId: typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, -suffix.length), + }); + } + } + const claimant = this.securityCatalogClaims.get(type)?.get(name); + if (claimant !== undefined) add({ kind: 'package', packageId: claimant }); + return [...found.values()]; + } + + /** + * The package door's half of the rule: refuse a package whose declared + * positions, permission sets or capabilities name something another holder + * already holds — built-ins included — ahead of every mutation + * {@link installPackage} makes, so a refused package leaves no record, no + * namespace ownership and no claim behind (the namespace gate's disposition). + * + * Every conflict is collected, so one refusal reports all of them. A package + * with no identity (`artifactPackageId` answers nothing) registers under no + * package at all and has nothing to hold; it is not judged here. + * + * @throws {SecurityCatalogNameConflictError} ADR-0112 envelope, naming both + * holders of each conflicting name. + */ + private refuseSecurityCatalogNameConflicts(manifest: ObjectStackManifest, selfId: string | undefined): void { + if (selfId === undefined) return; + const conflicts: SecurityCatalogNameConflict[] = []; + const seen = new Set(); + for (const { type, name } of declaredSecurityCatalogNames(manifest)) { + // One package declaring one name twice is one holder (an in-package + // repeat is the package's own business, and not this rule's). + const key = `${type}|${name}`; + if (seen.has(key)) continue; + seen.add(key); + for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true, environment: true })) { + conflicts.push({ catalogType: type, name, incomingPackageId: selfId, existingHolder }); + } + } + if (conflicts.length > 0) throw new SecurityCatalogNameConflictError(conflicts); + } + + /** + * Record `packageId`'s claims. Additive, like the item store beside it: a + * re-install does not unregister the items an earlier install of the same + * package registered, so it does not release their names either — and a + * re-install whose manifest carries no collections at all (`POST + * /api/v1/packages` stores the manifest alone) must not strip the names the + * package's code still declares. {@link uninstallPackage} is what releases + * them. + */ + private recordSecurityCatalogClaims(packageId: string, manifest: ObjectStackManifest): void { + for (const { type, name } of declaredSecurityCatalogNames(manifest)) { + let byName = this.securityCatalogClaims.get(type); + if (!byName) { + byName = new Map(); + this.securityCatalogClaims.set(type, byName); + } + byName.set(name, packageId); + } + } + + /** Drop every claim `packageId` holds. */ + private forgetSecurityCatalogClaims(packageId: string): void { + for (const byName of this.securityCatalogClaims.values()) { + for (const [name, claimant] of byName) if (claimant === packageId) byName.delete(name); + } + } + // ========================================== // Object Registration (Ownership Model) // ========================================== @@ -3533,6 +3744,46 @@ export class SchemaRegistry { const collection = this.metadata.get(type)!; const baseName = String(item[keyField]); + // The security catalog's item seam: a package registering a position, + // permission set or capability under a name another holder holds is + // refused BEFORE anything below stamps or stores it — the per-item + // cross-package throw ADR-0048 §3.4 retired, kept for these three types + // (maintainer ruling Q4 = A on #15196; `security-catalog-namespace.ts`). + // The package door ({@link installPackage}) has already refused a package's + // declared names, built-ins included; this is the seam every OTHER + // package-bound registration reaches (a plugin declaring items through + // the registry directly), so it asks the registered holders only — the + // platform registers its own built-ins here, under its own package id. + // ⛔ A registration with no `packageId` is the bare slot — `sys_metadata` + // hydration, the metadata write-through — and is never judged here: an + // environment save over a package-held name is outside the ruling. + // + // A BUILT-IN name is held by the platform, and the registration of one + // here is the platform declaring its own name (`plugin-security`'s + // `registerBuiltinPositions`, in its `start()`) — the holder's own + // registration, never a second holder. Packages are refused built-in names + // at the package door, and a second package registering one here is still + // refused below (in either order: the other package's slot is a holder). + // What is NOT asked for a built-in name is the environment catalog: an + // environment item under a built-in name exists only because an + // environment save went over the platform's name — outside the ruling — and + // it is hydrated (`ObjectQLPlugin.start`, which every `start()` depending on + // the engine follows) BEFORE the platform declares, so asking it would + // refuse the platform's own declaration and with it the boot. The stored + // definition keeps answering first from the bare slot (ADR-0005); the + // declaration sits beside it. + if (packageId && isSecurityCatalogType(type)) { + const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, { + builtIns: false, + environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName), + }); + if (holders.length > 0) { + throw new SecurityCatalogNameConflictError( + holders.map((existingHolder) => ({ catalogType: type, name: baseName, incomingPackageId: packageId, existingHolder })), + ); + } + } + // ADR-0010 §3.7 — translate the author-facing `protection` block // into the private `_lock` envelope and stamp package provenance. // Centralised with the artifact loader path in metadata/plugin.ts @@ -3606,6 +3857,10 @@ export class SchemaRegistry { // same bare name (e.g. `page/home`) legitimately COEXIST under distinct // composite keys and each caller resolves to its own. What the original // guard flagged as a collision is now the supported marketplace case. + // ⚠️ Except for the three security catalog types, refused at the top of this + // method: an assignment names a position or a permission set with no + // package context, so there a second holder is an ambiguity no caller can + // resolve (maintainer ruling Q4 = A on #15196). // // Same-package re-registration still overwrites (idempotent reload), and a // runtime/DB overlay over a packaged item is the sanctioned ADR-0005 path, @@ -4316,6 +4571,14 @@ export class SchemaRegistry { // behind — the same disposition the namespace gate above has. this.refuseCoOwnedObjectNameCollision(manifest, selfId, coOwners); + // The security catalog's one-name-one-holder rule (ADR-0048 §3.4 narrowed + // for positions, permission sets and capabilities; maintainer ruling Q4 = A + // on #15196) — also ahead of every mutation, for the same reason. No + // co-owner exemption: two packages of one artifact sharing a catalog name + // are as ambiguous to a bare-name assignment as two strangers are, and no + // `collisionPolicy` downgrade (`security-catalog-namespace.ts`). + this.refuseSecurityCatalogNameConflicts(manifest, selfId); + const now = new Date().toISOString(); // [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move @@ -4390,6 +4653,9 @@ export class SchemaRegistry { this.debug(`[Registry] Overwriting package: ${manifest.id}`); } collection.set(manifest.id, pkg); + // The names this package now holds, recorded whether or not an item store + // entry will carry them (see `securityCatalogClaims`). + if (selfId !== undefined) this.recordSecurityCatalogClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; } @@ -4485,6 +4751,9 @@ export class SchemaRegistry { // process. Runs AFTER the object verb because that one can refuse // (ADR-0029 extenders): a refused uninstall must remove nothing at all. this.unregisterItemsByPackage(id); + // …and the security catalog names it held, so an uninstalled package's + // names are free for the next package to declare. + this.forgetSecurityCatalogClaims(id); // [#18877] The boot seed forgets the id together with the row. A package // that no longer exists has no lifecycle state to preserve, so the next diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts new file mode 100644 index 00000000000..e8ef6267b9d --- /dev/null +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one holder — the security catalog's namespace rule, and the + * vocabulary {@link SchemaRegistry}'s refusal of a second holder is written in. + * + * ## The rule + * + * The three security catalog types — positions, permission sets and + * capabilities — each hold ONE namespace per deployment. A package whose + * position, permission set or capability bears a name that an installed + * package, the environment catalog or a built-in already holds is refused at + * registration, and the refusal names both holders. (Maintainer ruling Q4 = A + * on #15196, record 6050490870.) + * + * Why these three and nothing else: an assignment carries a BARE name with no + * package context (ADR-0131 D4) — a user holds the position `sales_manager`, + * not "package X's `sales_manager`" — so for the security catalog a shared name + * is a real ambiguity: which definition grants would depend on registration + * order. Measured before this rule, on a booted kernel with two packages + * sharing one name per type: the by-name read resolved the permission set and + * the capability to the FIRST-registered package and the position to the + * LAST-registered one. For every other metadata type ADR-0048 §3.4's + * coexistence stands: a caller there carries its own package, and package-scoped + * resolution disambiguates. This module is that narrowing, and only it. + * + * ## The holders + * + * - **a package** — a name another installed package declares (its claim, + * recorded at install, or an item it registered under its own id, or the + * stored override it bound to itself); + * - **the environment catalog** — an item authored in this environment rather + * than shipped by a package: the registry's bare slot carrying no + * `_packageId`; + * - **a built-in** — {@link BUILT_IN_SECURITY_CATALOG_NAMES}. + * + * ## What it deliberately does not judge + * + * - ⛔ An environment-catalog save over a package-held name. The ruling covers + * installing or registering a PACKAGE, not a metadata author's save; that + * path keeps its own answers (a packaged permission set is already locked + * against an in-place edit, `403`). A bare-slot registration — what every + * `sys_metadata` hydration and write-through performs — carries no package + * and is never refused here. + * - ⛔ A downgrade. `OS_METADATA_COLLISION=warn` softens the ADR-0048 Phase 1 + * namespace gate only; no ruling extends it to this refusal. + * - The same package registering its own name again — an idempotent reload, a + * re-install, a hot reload — is one holder, not two. + */ + +import type { SecurityCatalogType } from '@objectstack/core'; +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec/identity'; +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; + +/** The three catalog types the rule covers, in the order a refusal lists them. */ +export const SECURITY_CATALOG_NAMESPACE_TYPES: readonly SecurityCatalogType[] = Object.freeze([ + 'position', + 'permission', + 'capability', +]); + +/** Is `type` one of the three security catalog types (as the registry keys it)? */ +export function isSecurityCatalogType(type: unknown): type is SecurityCatalogType { + return typeof type === 'string' && (SECURITY_CATALOG_NAMESPACE_TYPES as readonly string[]).includes(type); +} + +/** + * The stack collection each type is declared under — the keys the engine's + * manifest seam and the two in-memory registrars read (`positions`, + * `permissions`, `capabilities`). + */ +const COLLECTION_KEY: Readonly> = Object.freeze({ + position: 'positions', + permission: 'permissions', + capability: 'capabilities', +}); + +/** + * The names the platform itself holds, whichever package registers first. + * + * - `position` — the four built-in identity names (ADR-0068 D2) and the two + * audience anchors (ADR-0090 D5/D9). + * - `capability` — the curated platform capabilities (ADR-0066 D1), seeded by + * `plugin-security` and registered by no package. + * - `permission` — none here. The platform's permission sets are DECLARED by + * `plugin-security` on its own manifest (configurable through its + * `defaultPermissionSets` option), so they reach the registry as that + * package's items and are held by it like any other package's; a static + * list here would either refuse `plugin-security` its own sets or drift + * from what a deployment configured. + * + * Applied at the package door ({@link declaredSecurityCatalogNames}'s caller), + * never at the registry's own item seam: the platform declares these names to + * the registry itself, under its own package id, and that declaration is the + * built-in holder's own registration — never a second holder. So the item seam + * asks it only whether another PACKAGE holds the name. It does not ask the + * environment catalog: an environment item under a built-in name exists only + * where an environment save went over the platform's name, which is outside + * the ruling, and it is hydrated before the platform declares. + */ +export const BUILT_IN_SECURITY_CATALOG_NAMES: Readonly>> = Object.freeze({ + position: new Set([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]), + permission: new Set(), + capability: new Set(PLATFORM_CAPABILITY_NAMES), +}); + +/** Who holds a security catalog name (module doc, "The holders"). */ +export type SecurityCatalogHolder = + | { readonly kind: 'package'; readonly packageId: string } + | { readonly kind: 'environment' } + | { readonly kind: 'built-in' }; + +/** One `(type, name)` a package declares. */ +export interface DeclaredSecurityCatalogName { + readonly type: SecurityCatalogType; + readonly name: string; +} + +/** + * The name an item is registered under — the engine seam's own derivation for + * a non-view collection (`resolveMetadataItemName`: `name`, else `id`). An + * item with neither is skipped there, so it claims nothing here. + */ +function itemName(item: unknown): string | undefined { + if (!item || typeof item !== 'object') return undefined; + const { name, id } = item as { name?: unknown; id?: unknown }; + if (typeof name === 'string' && name !== '') return name; + if (typeof id === 'string' && id !== '') return id; + return undefined; +} + +/** + * Every `(type, name)` a manifest declares, read from the SAME sources the + * engine's registration seams read: the manifest's own collections and each + * nested `plugins[]` entry's (a nested plugin contributes under its parent + * package — `ObjectQL.registerPlugin`), one level deep, arrays only. + * + * `positions` is read like the other two collections. The engine's collection + * loop registers a package's positions under the package + * (`ObjectQL.registerApp`), and the in-memory registrars (`AppPlugin`'s + * security block, the artifact door) register them for the very same package; + * the package's claim covers them either way. + * + * ⚠️ A non-array `permissions` is skipped, not misread: at the manifest stage + * that key is the ADR-0025 capability GRANT (`{ services, hooks, … }`), not a + * list of permission sets. + */ +export function declaredSecurityCatalogNames(manifest: unknown): DeclaredSecurityCatalogName[] { + const out: DeclaredSecurityCatalogName[] = []; + const read = (source: unknown) => { + if (!source || typeof source !== 'object') return; + for (const type of SECURITY_CATALOG_NAMESPACE_TYPES) { + const items = (source as Record)[COLLECTION_KEY[type]]; + if (!Array.isArray(items)) continue; + for (const item of items) { + const name = itemName(item); + if (name !== undefined) out.push({ type, name }); + } + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + return out; +} + +/** How a refusal names a catalog type. */ +export function securityCatalogTypeLabel(type: SecurityCatalogType): string { + return type === 'permission' ? 'permission set' : type; +} + +/** How a refusal names a holder. */ +export function describeSecurityCatalogHolder(holder: SecurityCatalogHolder): string { + switch (holder.kind) { + case 'package': + return `package "${holder.packageId}"`; + case 'environment': + return 'the environment catalog (an item authored in this environment, not shipped by a package)'; + case 'built-in': + return 'the platform, as a built-in name no package can declare'; + } +} + +/** A stable identity for de-duplicating holders. */ +export function securityCatalogHolderKey(holder: SecurityCatalogHolder): string { + return holder.kind === 'package' ? `package:${holder.packageId}` : holder.kind; +} diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index 8b7e92975b1..5233702ec6e 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -75,7 +75,7 @@ import { defineStack, definePermissionSet } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; // ── the two objects under probe ──────────────────────────────────────────── @@ -205,10 +205,10 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller beforeAll(async () => { stack = await bootStack(probeApp, { - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, WidenerSet as any, PlainSet as any], - fallbackPermissionSet: 'member_default', - }), + // The two sets are the app's own (`permissions` above); ⛔ not ALSO handed + // to `defaultPermissionSets`, which would declare each under a second + // package and refuse the boot (one holder per permission-set name). + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // dev admin seed bobToken = await stack.signUp('wscope-bob@verify.test'); // holds the widener diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 5455303cbe6..a4290956779 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -57,7 +57,7 @@ import { defineStack, definePermissionSet } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; // ── the app under probe ──────────────────────────────────────────────────── @@ -165,13 +165,13 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => beforeAll(async () => { stack = await bootStack(probeApp, { - // The app's own set must be resolvable alongside the platform seeds; the - // fallback stays the platform `member_default` so nothing about this - // fixture's baseline differs from an ordinary deployment's. - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, ProbeWidenerSet as any], - fallbackPermissionSet: 'member_default', - }), + // The app's own set is registered by the app (`permissions` above), as in + // an ordinary deployment — ⛔ not ALSO handed to `defaultPermissionSets`, + // which would declare it under a second package and refuse the boot (one + // holder per permission-set name). The fallback stays the platform + // `member_default` so nothing about this fixture's baseline differs from an + // ordinary deployment's. + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // seed dev admin (platform admin) bobToken = await stack.signUp('probe-bob@verify.test'); // plain member diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index ab13cb62974..1f21fbcd8bc 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -52,7 +52,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; import { assertArmed, principalArmed } from './armed.js'; /** @@ -205,15 +205,10 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el // users to (ADR-0093 D1), which is the shape a real deployment boots in // and the shape QA run #7637 measured. orgContext: true, - security: new SecurityPlugin({ - defaultPermissionSets: [ - ...securityDefaultPermissionSets, - EditorSet as any, - ViewerSet as any, - ScopedSet as any, - ], - fallbackPermissionSet: 'member_default', - }), + // The three sets are the app's own (`permissions` above); ⛔ not ALSO + // handed to `defaultPermissionSets`, which would declare each under a + // second package and refuse the boot (one holder per permission-set name). + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // dev admin seed (first user) aliceToken = await stack.signUp('owdw-alice@verify.test'); diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 7bc12c69305..102e48b1ee8 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -39,14 +39,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { SecurityPlugin, securityDefaultPermissionSets, appDefaultPermissionSetName } from '@objectstack/plugin-security'; -import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; -// Mirror the CLI: pull the app-declared default profile (name + object) off the -// stack metadata via the same helper the CLI uses. +// Mirror the CLI: pull the app-declared default profile's name off the stack +// metadata via the same helper the CLI uses. const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ?? []) as Array<{ name?: string }>; const appDefault = appDefaultPermissionSetName(stackPerms); -const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown; const SYS = { isSystem: true } as const; @@ -56,16 +54,15 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = let ql: any; beforeAll(async () => { - // The full CLI boot loads stack permission sets into the metadata service, so - // `fallbackPermissionSet: ` resolves there. The lightweight harness does - // not seed permission metadata, so we hand the declared default to the plugin - // directly — then wire it by NAME exactly as the CLI's appDefaultPermissionSetName - // path does (constructor uses the explicit name, not its own isDefault scan). + // Wired by NAME exactly as the CLI's appDefaultPermissionSetName path does + // (constructor uses the explicit name, not its own isDefault scan). The set + // itself is the app's: the harness's AppPlugin registers the stack's + // `permissions`, as the CLI boot does. ⛔ Not ALSO handed to the plugin's + // `defaultPermissionSets` — that declares one permission set under two + // packages, and the boot is refused (`NAMESPACE_CONFLICT`, both holders + // named): a permission set holds one name per deployment. stack = await bootStack(showcaseStack, { - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, PermissionSetSchema.parse(declaredDefault) as PermissionSet], - fallbackPermissionSet: appDefault, - }), + security: new SecurityPlugin({ fallbackPermissionSet: appDefault }), }); await stack.signIn(); memberToken = await stack.signUp('d7-showcase-member@verify.test'); diff --git a/packages/qa/dogfood/test/showcase-security.ts b/packages/qa/dogfood/test/showcase-security.ts index 1505e52bd32..930cce0c14d 100644 --- a/packages/qa/dogfood/test/showcase-security.ts +++ b/packages/qa/dogfood/test/showcase-security.ts @@ -23,9 +23,17 @@ // `showcase-d7-default-profile.dogfood.test.ts` pins that wiring; this module // USES it, so fixtures are now more faithful to the running app than they were. // -// The lightweight verify harness does not seed permission metadata, so the -// declared set is handed to the plugin directly — the identical note that test -// carries. +// The app's set is registered by the app's own package, exactly as in the CLI +// boot: `bootStack`'s `AppPlugin` registers the stack's `permissions` in the +// engine registry and the metadata service, so the plain branch below wires the +// default by NAME only — the CLI's `appSecurityPluginOptions` wiring. ⛔ It must +// not ALSO hand the declared set to the plugin's `defaultPermissionSets`: that +// declares one permission set under two packages (the app's and +// `plugin-security`'s), and a permission set holds one name per deployment — +// the boot is refused with `NAMESPACE_CONFLICT`, naming both holders +// (`@objectstack/objectql`, `security-catalog-namespace.ts`). The scaffolding +// branch hands the plugin a set of a DIFFERENT name, which the app never +// declares, so it has one holder. import showcaseStack from '@objectstack/example-showcase'; import { SecurityPlugin, @@ -68,10 +76,8 @@ export function showcaseAppDefaultSecurity(extraObjectGrants?: ObjectGrants): Se } const appDefault = PermissionSetSchema.parse(declaredDefault) as PermissionSet; if (!extraObjectGrants) { - return new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, appDefault], - fallbackPermissionSet: appDefault.name, - }); + // The CLI wiring: the default named, the set itself the app's own. + return new SecurityPlugin({ fallbackPermissionSet: appDefault.name }); } const baseline = PermissionSetSchema.parse({ ...appDefault, diff --git a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts new file mode 100644 index 00000000000..9ae85bc8608 --- /dev/null +++ b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// One name, one holder for positions, permission sets and capabilities, at the +// two runtime boot paths a package's catalog items arrive through (maintainer +// ruling Q4 = A on #15196; the rule: `@objectstack/objectql`'s +// `security-catalog-namespace.ts`). +// +// ## Why the refusal is asserted at the boot, not inside the in-memory registrars +// +// Positions also reach the metadata service: `AppPlugin`'s security block +// (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door +// (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` +// registrar) write them there, in `start()`. But neither runs +// for a package the engine has not installed first: `AppPlugin.init()` registers +// every package of the bundle through the `manifest` service in Phase 1 — a +// multi-package artifact package by package — and the engine's package door +// refuses the second holder there, before any `start()`. So the boot is the +// door, and these cases boot the REAL compositions: the artifact boot +// (`createStandaloneStack`, door + `'artifact-door'` AppPlugin) and the +// door-less one (`new AppPlugin(stack)`, `'app-plugin'` registrar). +// +// Each refusal is asserted by its ADR-0112 envelope (`code` + `status`) and by +// the two holders it names; the control boots the same shapes with distinct +// names and finds each package's position registered by its door, stamped +// with its own package. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql'; +import { SecurityPlugin, SECURITY_PLUGIN_ID, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { Runtime } from './runtime.js'; +import { AppPlugin } from './app-plugin.js'; +import { createStandaloneStack } from './standalone-stack.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the +// boot reaches through a dynamic `import()` at MODULE LOAD, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 90_000; + +type Refusal = Error & { code?: string; status?: number; incomingPackageId?: string; existingHolder?: unknown }; + +type Names = { position: string; permission: string; capability: string }; + +/** One name of each catalog type, as a stack declares them. */ +const collections = (id: string, names: Names) => ({ + positions: [{ name: names.position, label: `${id} position` }], + permissions: [{ name: names.permission, label: `${id} set`, objects: {} }], + capabilities: [{ name: names.capability, label: `${id} capability` }], +}); + +const manifestOf = (id: string) => ({ id, name: id.split('.').pop(), type: 'app', version: '1.0.0' }); + +/** An assembled package body (ADR-0130 D4): the manifest's fields, its collections written over them. */ +const body = (id: string, names: Names) => ({ ...manifestOf(id), ...collections(id, names) }); + +/** A single-package stack: `manifest` plus its collections. */ +const stackOf = (id: string, names: Names) => ({ manifest: manifestOf(id), ...collections(id, names) }); + +describe('a package\'s security catalog name another holder holds refuses the boot', () => { + const dirs: string[] = []; + const kernels: any[] = []; + + afterEach(async () => { + for (const k of kernels.splice(0)) { + try { await k.shutdown(); } catch { /* noop */ } + } + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } + } + }); + + async function artifactStack(artifact: unknown) { + const dir = mkdtempSync(join(tmpdir(), 'os-catalog-one-holder-')); + dirs.push(dir); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + return createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + } + + /** Boots the plugins; answers the refusal, or `undefined` and the kernel. */ + async function boot(plugins: readonly unknown[]): Promise<{ refusal?: Refusal; kernel: any }> { + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + kernels.push(kernel); + for (const p of plugins) await kernel.use(p as any); + try { + await kernel.bootstrap(); + return { kernel }; + } catch (e) { + return { refusal: e as Refusal, kernel }; + } + } + + function expectRefusal(refusal: Refusal | undefined, incoming: string, holder: unknown) { + expect(refusal, 'the boot was refused').toBeDefined(); + expect(refusal!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal!.status).toBe(422); + expect(refusal!.incomingPackageId).toBe(incoming); + expect(refusal!.existingHolder).toEqual(holder); + } + + it('artifact boot: two packages of one artifact sharing a position, permission set and capability name', async () => { + const shared = { position: 'regional_manager', permission: 'regional_set', capability: 'regional.export' }; + const stack = await artifactStack({ + manifest: manifestOf('com.test.catalog-project'), + packages: [{ manifest: body('com.test.first', shared) }, { manifest: body('com.test.second', shared) }], + }); + expect((stack.plugins.find((p: any) => p?.type === 'app') as AppPlugin).securityMetadataRegistrar).toBe('artifact-door'); + + const { refusal } = await boot(stack.plugins); + expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); + // Every conflicting name in one refusal: the position, the permission set + // and the capability the first package holds. + expect((refusal as any).conflicts.map((c: any) => `${c.catalogType}/${c.name}`)).toEqual([ + 'position/regional_manager', + 'permission/regional_set', + 'capability/regional.export', + ]); + }, BOOT_TIMEOUT); + + it('artifact boot: a package declaring a built-in position', async () => { + const stack = await artifactStack( + stackOf('com.test.builtin', { position: 'everyone', permission: 'builtin_probe_set', capability: 'builtin_probe.export' }), + ); + const { refusal } = await boot(stack.plugins); + expectRefusal(refusal, 'com.test.builtin', { kind: 'built-in' }); + }, BOOT_TIMEOUT); + + it('door-less boot (`app-plugin` registrar): a second stack declaring a name the first holds', async () => { + const shared = { position: 'shared_position', permission: 'shared_set', capability: 'shared.export' }; + const stack = await artifactStack(stackOf('com.test.first', shared)); + const second = new AppPlugin(stackOf('com.test.second', shared)); + expect(second.securityMetadataRegistrar).toBe('app-plugin'); + + const { refusal } = await boot([...stack.plugins, second]); + expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); + }, BOOT_TIMEOUT); + + // The shape the dogfood fixtures used to compose: an app declaring a + // permission set AND the same set handed to `plugin-security`'s + // `defaultPermissionSets`, which declares every entry on that plugin's own + // manifest. One set, two packages: refused, whichever registers second, with + // both named. (`os serve` never composes this — it hands the plugin the + // default's NAME only, `appSecurityPluginOptions`.) + it('a permission set the app declares AND hands to plugin-security\'s defaultPermissionSets: two holders, refused', async () => { + const names = { position: 'dup_position', permission: 'dup_set', capability: 'dup.export' }; + const stack = await artifactStack(stackOf('com.test.dup', names)); + const security = new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, { name: 'dup_set', label: 'handed to the plugin', objects: {} } as never], + }); + const { refusal } = await boot([...stack.plugins, security]); + // The app's `AppPlugin` registers first here, so the plugin's is the second holder. + expectRefusal(refusal, SECURITY_PLUGIN_ID, { kind: 'package', packageId: 'com.test.dup' }); + }, BOOT_TIMEOUT); + + it('CONTROL: the same two-package artifact with distinct names boots, and the door registers each package\'s position under its own package', async () => { + const stack = await artifactStack({ + manifest: manifestOf('com.test.catalog-project'), + packages: [ + { manifest: body('com.test.first', { position: 'first_position', permission: 'first_set', capability: 'first.export' }) }, + { manifest: body('com.test.second', { position: 'second_position', permission: 'second_set', capability: 'second.export' }) }, + ], + }); + const { refusal, kernel } = await boot(stack.plugins); + expect(refusal).toBeUndefined(); + const metadata = kernel.getService('metadata'); + expect(await metadata.get('position', 'first_position')).toMatchObject({ _packageId: 'com.test.first' }); + expect(await metadata.get('position', 'second_position')).toMatchObject({ _packageId: 'com.test.second' }); + }, BOOT_TIMEOUT); +}); diff --git a/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json b/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json new file mode 100644 index 00000000000..6ab24bf953f --- /dev/null +++ b/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/objectql/src/security-catalog-namespace.ts", + "adrs": [ + "ADR-0048", + "ADR-0131" + ], + "invariant": "ADR-0048 §3.4 retired the per-item cross-package throw because a caller carries its package id; for positions, permission sets and capabilities it does not — ADR-0131 D4 makes every assignment a bare name — so the maintainer's ruling (Q4 = A on #15196) narrows §3.4 to leave these three types out: one name, one holder per deployment. A second holder (an installed package, the environment catalog, a built-in) is refused at registration, naming both holders. ⛔ Do not 'restore' §3.4's coexistence for these three types, do not let OS_METADATA_COLLISION=warn downgrade the refusal, and do not judge a registration with no package (an environment save is outside the ruling)." +}