From e8e3b3f9d167fed17701c678ab7904012427061c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:56:48 +0000 Subject: [PATCH 1/5] feat(plugin-security): declare the curated platform capabilities as capability metadata (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_014DUFKvmT2Vyzx42PyAAXV3 Co-authored-by: Claude --- .../src/builtin-capabilities.ts | 145 ++++++++++++++++++ .../src/declared-capability-context.ts | 8 +- .../plugin-security/src/security-plugin.ts | 21 ++- 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-security/src/builtin-capabilities.ts diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.ts b/packages/plugins/plugin-security/src/builtin-capabilities.ts new file mode 100644 index 00000000000..9eae036ed08 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-capabilities.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The platform's curated capabilities (ADR-0066 D1) — declared as `capability` + * metadata this plugin owns, so the security catalog's one home (ADR-0131 D3) + * holds them. + * + * ## One list, not a copy + * + * The curated list lives in `@objectstack/spec` ({@link PLATFORM_CAPABILITIES}), + * where the authoring lint and the one-holder rule (`@objectstack/objectql`, + * `security-catalog-namespace.ts`) already read it. This module declares that + * same list to the engine registry and keeps no list of its own: + * + * 1. `SecurityPlugin.start` registers each curated capability as a + * `capability` item under this plugin's package id + * ({@link registerBuiltinCapabilities}), so the security catalog read + * (`createSecurityCatalogReader`, `@objectstack/core`) and the metadata door + * (`GET /api/v1/meta/capability`) list them beside every package-declared + * capability, and `GET /api/v1/meta/capability/:name` answers each one's + * definition. + * 2. `bootstrapSystemCapabilities` keeps seeding their `sys_capability` rows + * from the same spec list, unchanged. Nothing here writes a row. + * + * ## Why the engine registry's item seam, and not the manifest + * + * The engine's stack-collection loop does decompose a manifest `capabilities` + * collection, but the PACKAGE door in front of it (`SchemaRegistry`'s + * install-time refusal) refuses a package declaring a built-in name, and the + * curated names are exactly the built-in capability names. The item seam + * (`registerItem` with a package id) is where the platform declares its own + * built-in names: it asks only whether another package holds the name. The + * built-in positions (`builtin-positions.ts`) take the same seam for the same + * reason. Existing `sys_capability` rows are rows, not registry holders, so a + * database the seeder already populated registers the same as a fresh one. + * + * ## The one adapter, and why it lives at the call site + * + * `bootstrapDeclaredCapabilities` and `readDeclaredCapabilityContext` + * (`declared-capability-context.ts`) read the registry's capabilities as PACKAGE declarations. Handed the curated + * declarations too, the seeder would refuse each one as a package hijacking a + * curated name (one false `capability_platform_name_refused` line per name, + * every boot), and both would stop falling back to the metadata service on a + * registry that holds no package declaration. The seeders are retired, not + * edited (ADR-0131 cutover), so the plugin hands that seeder an engine view + * without this plugin's own curated declarations + * ({@link withoutPlatformCapabilityDeclarations}), and the context read drops + * the same items ({@link withoutPlatformCapabilityItems}); both readers then + * see exactly what they saw before. The view goes with the seeder. + * + * ## What a declaration here carries + * + * `name`, `label`, `description` and `scope` — the curated fields, each of + * which `CapabilityDeclarationSchema` declares. Activation stays on the + * catalog row. + */ + +import { PLATFORM_CAPABILITIES, PLATFORM_CAPABILITY_NAMES, type PlatformCapability } from '@objectstack/spec/security'; +import { SECURITY_PLUGIN_ID } from './manifest.js'; + +/** + * The engine registry's registration seam, as this module uses it — ObjectQL's + * `SchemaRegistry` satisfies it as it stands. + */ +export interface BuiltinCapabilityRegistry { + registerItem(type: string, item: Record, keyField: string, packageId?: string): void; +} + +/** One curated capability declaration — the spec's own entry (module doc). */ +export type BuiltinCapabilityDeclaration = PlatformCapability; + +/** The curated capabilities this plugin declares: the spec list itself. */ +export const securityBuiltinCapabilities: readonly BuiltinCapabilityDeclaration[] = PLATFORM_CAPABILITIES; + +/** + * Declare the curated capabilities to the engine registry, owned by + * `packageId` (the plugin passes its own `SECURITY_PLUGIN_ID`). Each + * registration gets a fresh copy of the four curated fields: the registry + * stamps provenance onto the object it is handed, and the spec list is shared. + * + * @returns how many were registered — 0 when `registry` cannot register + * anything, which the caller reports (absence is loud). + */ +export function registerBuiltinCapabilities(registry: unknown, packageId: string): number { + const target = registry as Partial | null | undefined; + if (!target || typeof target.registerItem !== 'function') return 0; + for (const { name, label, description, scope } of securityBuiltinCapabilities) { + target.registerItem('capability', { name, label, description, scope }, 'name', packageId); + } + return securityBuiltinCapabilities.length; +} + +/** + * Is `item` this plugin's own declaration of a curated capability — a curated + * name stamped with this plugin's package id? Exact match on both. Another + * package's item under a curated name is not, so it still reaches the readers + * (and their refusal) as before. + */ +export function isPlatformCapabilityDeclaration(item: unknown): boolean { + if (!item || typeof item !== 'object') return false; + const { name, _packageId } = item as { name?: unknown; _packageId?: unknown }; + return _packageId === SECURITY_PLUGIN_ID && typeof name === 'string' && PLATFORM_CAPABILITY_NAMES.has(name); +} + +/** Read `prop` off `target`, binding a method to `target` itself. */ +function forward(target: object, prop: PropertyKey): unknown { + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; +} + +/** + * The engine as the declared-capability seeder must see it: every member is + * the engine's own, except that its registry lists no capability this plugin + * declared from the curated list ({@link isPlatformCapabilityDeclaration}). + * Every other type, and every other capability, is listed as the registry + * lists it. An engine without a listing registry is returned as it is. + */ +export function withoutPlatformCapabilityDeclarations(engine: T): T { + const registry = (engine as { registry?: unknown } | null | undefined)?.registry; + if (!engine || typeof engine !== 'object' || !registry || typeof registry !== 'object' + || typeof (registry as { listItems?: unknown }).listItems !== 'function') { + return engine; + } + const registryView = new Proxy(registry, { + get(target, prop) { + if (prop !== 'listItems') return forward(target, prop); + return (type: string, ...rest: unknown[]) => { + const items = (target as { listItems(type: string, ...rest: unknown[]): unknown }).listItems(type, ...rest); + return type === 'capability' && Array.isArray(items) + ? items.filter((item) => !isPlatformCapabilityDeclaration(item)) + : items; + }; + }, + }); + return new Proxy(engine as object, { + get(target, prop) { + return prop === 'registry' ? registryView : forward(target, prop); + }, + }) as T; +} + +/** Drop this plugin's own curated declarations from a list of capability declarations. */ +export function withoutPlatformCapabilityItems(items: readonly T[]): T[] { + return items.filter((item) => !isPlatformCapabilityDeclaration(item)); +} diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts index 62bc826c151..56e5713fcd3 100644 --- a/packages/plugins/plugin-security/src/declared-capability-context.ts +++ b/packages/plugins/plugin-security/src/declared-capability-context.ts @@ -48,6 +48,7 @@ import type { AnchorBindingContext } from '@objectstack/spec/security'; import { readDeclared } from './bootstrap-declared-permissions.js'; +import { withoutPlatformCapabilityItems } from './builtin-capabilities.js'; /** * Read this stack's declared authorization capabilities as an @@ -65,7 +66,12 @@ export async function readDeclaredCapabilityContext( ql: any, metadataService?: any, ): Promise { - let caps: any[] = readDeclared(ql, 'capability'); + // [ADR-0131 D3] The registry also holds the platform's curated capabilities, + // which this plugin declares itself (`builtin-capabilities.ts`). They are not + // a package's declaration, and the platform floor would discard them anyway; + // dropping them keeps the metadata-service fallback below answering on a + // registry that holds no package declaration, as it did before. + let caps: any[] = withoutPlatformCapabilityItems(readDeclared(ql, 'capability')); if (caps.length === 0) { try { const listed = metadataService?.list?.('capability'); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1b8a4366515..2c22025483d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -179,6 +179,7 @@ import { SECURITY_PLUGIN_ID, } from './manifest.js'; import { registerBuiltinPositions } from './builtin-positions.js'; +import { registerBuiltinCapabilities, withoutPlatformCapabilityDeclarations } from './builtin-capabilities.js'; /** * [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the @@ -1795,6 +1796,20 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0131 D3, ADR-0066 D1] The curated capabilities are declared metadata + // of THIS plugin too, by the same item seam and for the same reason: the + // manifest's package door refuses a built-in name, and the curated names + // are the built-in capability names (`builtin-capabilities.ts`). Sourced + // from `PLATFORM_CAPABILITIES`, the list the curated seeding pass reads; + // nothing here writes a `sys_capability` row. + if (registerBuiltinCapabilities((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID) === 0) { + ctx.logger.warn( + '[security] the curated platform capabilities were NOT declared as capability metadata: the ObjectQL ' + + 'engine exposes no registry that can register them. Their sys_capability rows still seed and no grant ' + + 'changes, but the security catalog read and GET /api/v1/meta/capability will not list them.', + ); + } + // [#18682] Answer the engine's create/update gate question for `validate()`. // // `validate()` runs no middleware for its target object, so without this the @@ -4984,7 +4999,11 @@ export class SecurityPlugin implements Plugin { // it affects (#4967 Part 3). let materializedCapabilityNames: string[] = []; try { - const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, { + // [ADR-0131 D3] Handed the engine WITHOUT this plugin's own curated + // declarations (`builtin-capabilities.ts`): they are not a package's, + // the curated pass below seeds their rows, and read here each would be + // refused as a package claiming a curated name. + const capOutcome = await bootstrapDeclaredCapabilities(withoutPlatformCapabilityDeclarations(ql), this.metadata, { logger: ctx.logger, permissionSets: this.bootstrapPermissionSets, }); From f2665347ee6714ca4032617f1edf00927fc59d3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:59:46 +0000 Subject: [PATCH 2/5] test(plugin-security): pin the curated capability declarations on a real boot Claude-Session: https://claude.ai/code/session_014DUFKvmT2Vyzx42PyAAXV3 Co-authored-by: Claude --- .../src/builtin-capabilities.boot.test.ts | 264 ++++++++++++++++++ .../src/builtin-capabilities.test.ts | 127 +++++++++ 2 files changed, 391 insertions(+) create mode 100644 packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts create mode 100644 packages/plugins/plugin-security/src/builtin-capabilities.test.ts diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts new file mode 100644 index 00000000000..2f7b61c2a94 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, ADR-0066 D1] The curated platform capabilities as declared + * `capability` metadata, read off a REAL boot of this plugin: what the security + * catalog read lists, what the boot writes into `sys_capability`, and what the + * capability seeders report. + * + * The boot is the plugin's own — `init`, `start`, then its `kernel:ready` + * handlers in registration order (both capability seeding passes included) — + * over a real `ObjectQL` engine on a SQLite file, so a second boot can open the + * database the first one seeded. + * + * ## The scenarios + * + * Two postures (`single`, walled), each booted on a fresh database and on a + * database the seeders already populated, and each with the stack's own + * capability declared one of two ways: + * + * - `in the registry` — a package manifest's `capabilities`, the way a stack + * reaches the engine registry; + * - `in the metadata service only` — what the declared-capability seeder + * reads when the registry holds no package declaration. + * + * ## The expected values + * + * Read off the producers, never off a registry: the curated entries from + * `PLATFORM_CAPABILITIES`, the stack's from its declaration. The census is the + * one the boot wrote before the curated capabilities were declared — the + * curated pass's rows plus the stack's package row — which is why an + * unchanged census is the pin that the declarations changed no row. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createSecurityCatalogReader, resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { CAPABILITY_PLATFORM_NAME_REFUSED } from './seed-refusal-diagnostics.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const STACK_PACKAGE = 'com.example.field'; + +/** The stack's own capability. `packageId` lets the metadata-service copy name its owner. */ +const STACK_CAPABILITY = { + name: 'field.export', + label: 'Export Field Data', + description: 'Export the territory records.', + scope: 'org', + packageId: STACK_PACKAGE, +} as const; + +type Posture = 'single' | 'isolated'; +type Declared = 'in the registry' | 'in the metadata service only'; + +interface Booted { + engine: ObjectQL; + metadata: { get(type: string, name: string): unknown; list(type: string): unknown }; + /** Every line the boot logged at `warn`, through the logger or the console. */ + warnings: string[]; + /** Every `sys_capability` insert and update the boot made, by row name. */ + writes: string[]; +} + +async function boot(filename: string, posture: Posture, declared: Declared): Promise { + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + if (posture === 'isolated') { + process.env[POSTURE_ENV] = 'isolated'; + process.env[OWNER_ENV] = 'admin@cap.example'; + } + resetPlatformAdminEmailMemo(); + + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.builtin-capability-boot', + name: 'Built-in capability boot', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + } as any); + if (declared === 'in the registry') { + engine.registerApp({ + id: STACK_PACKAGE, + name: 'Field', + version: '1.0.0', + type: 'app', + capabilities: [{ ...STACK_CAPABILITY }], + } as any); + } + await engine.syncSchemas(); + + const writes: string[] = []; + const idToName = new Map(); + const insert = engine.insert.bind(engine); + const update = engine.update.bind(engine); + (engine as any).insert = async (object: string, data: any, opts?: any) => { + if (object === 'sys_capability') { + for (const row of Array.isArray(data) ? data : [data]) { + if (row?.id) idToName.set(String(row.id), String(row.name)); + writes.push(`insert ${row?.name}`); + } + } + return insert(object, data, opts); + }; + (engine as any).update = async (object: string, data: any, opts?: any) => { + if (object === 'sys_capability') writes.push(`update ${idToName.get(String(data?.id)) ?? `id:${String(data?.id)}`}`); + return update(object, data, opts); + }; + + const metadata = { + get: async (type: string, name: string) => + type === 'capability' && declared === 'in the metadata service only' && name === STACK_CAPABILITY.name + ? { ...STACK_CAPABILITY } + : null, + list: async (type: string) => { + if (type === 'capability') return declared === 'in the metadata service only' ? [{ ...STACK_CAPABILITY }] : []; + if (type === 'permission') return [...defaultPermissionSets]; + return []; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + ...(posture === 'isolated' + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } + : {}), + }; + const warnings: string[] = []; + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + const hooks = new Map unknown>>(); + const ctx: any = { + logger: { + info: vi.fn(), + warn: vi.fn((message: unknown) => warnings.push(String(message))), + error: vi.fn(), + debug: vi.fn(), + }, + hook: (name: string, handler: (...args: unknown[]) => unknown) => { + hooks.set(name, [...(hooks.get(name) ?? []), handler]); + }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + try { + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + const exists = await engine.find('sys_user', { where: { id: 'usr_admin' }, limit: 1, context: SYS } as any); + if (!Array.isArray(exists) || exists.length === 0) { + await engine.insert( + 'sys_user', + { id: 'usr_admin', email: 'admin@cap.example', name: 'admin', created_at: '2025-01-01T00:00:00.000Z', email_verified: true }, + { context: SYS } as any, + ); + } + for (const handler of hooks.get('kernel:ready') ?? []) await handler(); + } finally { + consoleWarn.mockRestore(); + } + return { engine, metadata, warnings, writes }; +} + +/** The `sys_capability` rows, one string per row. */ +async function census(engine: ObjectQL): Promise { + const rows = (await engine.find('sys_capability', { where: {}, limit: 5000, context: SYS } as any)) as any[]; + return rows + .map((r) => [r.name, r.managed_by, r.package_id ?? '-', r.label, r.description ?? '-', r.scope, r.active].join(' | ')) + .sort(); +} + +/** The census the boot writes, read off the producers (module doc). */ +const EXPECTED_CENSUS = [ + ...PLATFORM_CAPABILITIES.map((c) => [c.name, 'platform', '-', c.label, c.description, c.scope, true].join(' | ')), + [STACK_CAPABILITY.name, 'package', STACK_PACKAGE, STACK_CAPABILITY.label, STACK_CAPABILITY.description, STACK_CAPABILITY.scope, true].join(' | '), +].sort(); + +const SCENARIOS: ReadonlyArray<{ posture: Posture; declared: Declared }> = [ + { posture: 'single', declared: 'in the registry' }, + { posture: 'single', declared: 'in the metadata service only' }, + { posture: 'isolated', declared: 'in the registry' }, + { posture: 'isolated', declared: 'in the metadata service only' }, +]; + +describe.each(SCENARIOS)('a $posture boot, the stack capability $declared', ({ posture, declared }) => { + let dir: string; + let fresh: Booted; + let reseeded: Booted; + const envBefore = { posture: process.env[POSTURE_ENV], owner: process.env[OWNER_ENV] }; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-builtin-capabilities-')); + const filename = join(dir, 'boot.sqlite'); + fresh = await boot(filename, posture, declared); + // The same database, opened by a second boot after the first one seeded it. + reseeded = await boot(filename, posture, declared); + }, 120_000); + + afterAll(async () => { + for (const b of [fresh, reseeded]) { + try { await b?.engine.destroy(); } catch { /* noop */ } + } + rmSync(dir, { recursive: true, force: true }); + if (envBefore.posture === undefined) delete process.env[POSTURE_ENV]; + else process.env[POSTURE_ENV] = envBefore.posture; + if (envBefore.owner === undefined) delete process.env[OWNER_ENV]; + else process.env[OWNER_ENV] = envBefore.owner; + }); + + it('the catalog read lists every curated capability, from the registry, owned by this plugin, with its curated fields', async () => { + for (const booted of [fresh, reseeded]) { + const reader = createSecurityCatalogReader({ registry: (booted.engine as any).registry, metadata: booted.metadata as any }); + const listed = await reader.list('capability'); + expect(listed.map((e) => e.name).sort()).toEqual([...PLATFORM_CAPABILITIES.map((c) => c.name), STACK_CAPABILITY.name].sort()); + for (const curated of PLATFORM_CAPABILITIES) { + const entry = await reader.resolve('capability', curated.name); + expect(entry?.source, curated.name).toBe('registry'); + expect(entry?.packageId, curated.name).toBe(SECURITY_PLUGIN_ID); + const definition = entry?.definition as Record | undefined; + expect( + { name: definition?.name, label: definition?.label, description: definition?.description, scope: definition?.scope }, + curated.name, + ).toEqual({ ...curated }); + } + } + }); + + it('the boot writes the census it wrote before the declarations, on a fresh and on a seeded database', async () => { + expect(await census(fresh.engine)).toEqual(EXPECTED_CENSUS); + expect(await census(reseeded.engine)).toEqual(EXPECTED_CENSUS); + // The seeded database needs no write at all: every row already reads as declared. + expect(reseeded.writes).toEqual([]); + }); + + it('no capability seeder refuses or reports the platform’s own declarations', () => { + for (const booted of [fresh, reseeded]) { + expect(booted.warnings.filter((w) => w.includes(CAPABILITY_PLATFORM_NAME_REFUSED))).toEqual([]); + expect(booted.warnings.filter((w) => w.includes('capabilit'))).toEqual([]); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts new file mode 100644 index 00000000000..66d9ab76836 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3, ADR-0066 D1] The curated platform capabilities, declared from + * the spec list. The expected values come from `PLATFORM_CAPABILITIES`, never + * from the module under test. + */ + +import { describe, it, expect } from 'vitest'; +import { CapabilityDeclarationSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { + isPlatformCapabilityDeclaration, + registerBuiltinCapabilities, + securityBuiltinCapabilities, + withoutPlatformCapabilityDeclarations, + withoutPlatformCapabilityItems, +} from './builtin-capabilities.js'; +import { SECURITY_PLUGIN_ID } from './manifest.js'; + +describe('securityBuiltinCapabilities', () => { + it('is the spec’s curated list itself, not a copy', () => { + expect(securityBuiltinCapabilities).toBe(PLATFORM_CAPABILITIES); + }); + + it('is a valid capability declaration, every curated entry', () => { + for (const declaration of securityBuiltinCapabilities) { + expect(CapabilityDeclarationSchema.safeParse(declaration).success, declaration.name).toBe(true); + } + }); +}); + +describe('registerBuiltinCapabilities', () => { + it('registers each curated entry as a `capability`, keyed by name, under the owning package, with its four fields', () => { + const calls: Array<[string, Record, string, string | undefined]> = []; + const registry = { + registerItem(type: string, item: Record, keyField: string, packageId?: string) { + // The real registry stamps provenance onto the object it is handed. + item._packageId = packageId; + calls.push([type, item, keyField, packageId]); + }, + }; + expect(registerBuiltinCapabilities(registry, 'com.example.owner')).toBe(PLATFORM_CAPABILITIES.length); + expect(calls.map(([type, , keyField, packageId]) => [type, keyField, packageId])).toEqual( + PLATFORM_CAPABILITIES.map(() => ['capability', 'name', 'com.example.owner']), + ); + expect(calls.map(([, item]) => item)).toEqual( + PLATFORM_CAPABILITIES.map((c) => ({ ...c, _packageId: 'com.example.owner' })), + ); + // Each registration got its own copy: the spec list carries no stamp. + for (const declaration of PLATFORM_CAPABILITIES) expect(declaration).not.toHaveProperty('_packageId'); + }); + + it('registers nothing, and says so with 0, when there is no registration seam', () => { + expect(registerBuiltinCapabilities(undefined, 'com.example.owner')).toBe(0); + expect(registerBuiltinCapabilities({}, 'com.example.owner')).toBe(0); + }); +}); + +const curated = PLATFORM_CAPABILITIES[0]!.name; +const OWN = { name: curated, _packageId: SECURITY_PLUGIN_ID }; +const FOREIGN_CURATED = { name: curated, _packageId: 'com.example.other' }; +const PACKAGE_DECLARED = { name: 'field.export', _packageId: 'com.example.other' }; +const OWN_NON_CURATED = { name: 'field.export', _packageId: SECURITY_PLUGIN_ID }; + +describe('isPlatformCapabilityDeclaration', () => { + it('is this plugin’s declaration of a curated name, and nothing else', () => { + expect(isPlatformCapabilityDeclaration(OWN)).toBe(true); + for (const item of [FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED, { name: curated }, null, undefined, curated]) { + expect(isPlatformCapabilityDeclaration(item)).toBe(false); + } + }); + + it('drops only those from a list', () => { + expect(withoutPlatformCapabilityItems([OWN, FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED])).toEqual( + [FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED], + ); + }); +}); + +describe('withoutPlatformCapabilityDeclarations', () => { + class FakeRegistry { + readonly items: Record = { + capability: [OWN, FOREIGN_CURATED, PACKAGE_DECLARED], + permission: [OWN], + }; + listItems(type: string): unknown[] { + return this.items[type] ?? []; + } + getItem(type: string, name: string): unknown { + return (this.items[type] ?? []).find((i) => (i as { name?: unknown }).name === name); + } + } + class FakeEngine { + registry = new FakeRegistry(); + #inserted: unknown[] = []; + async insert(object: string, row: unknown): Promise { + this.#inserted.push([object, row]); + return row; + } + inserted(): unknown[] { + return this.#inserted; + } + } + + it('lists every capability but this plugin’s curated declarations, and every other type as it is', () => { + const engine = new FakeEngine(); + const view = withoutPlatformCapabilityDeclarations(engine); + expect(view.registry.listItems('capability')).toEqual([FOREIGN_CURATED, PACKAGE_DECLARED]); + expect(view.registry.listItems('permission')).toEqual([OWN]); + expect(view.registry.getItem('capability', curated)).toBe(OWN); + // The engine itself is untouched. + expect(engine.registry.listItems('capability')).toEqual([OWN, FOREIGN_CURATED, PACKAGE_DECLARED]); + }); + + it('forwards every other member to the engine itself, private state included', async () => { + const engine = new FakeEngine(); + const view = withoutPlatformCapabilityDeclarations(engine); + await view.insert('sys_capability', { name: 'x' }); + expect(engine.inserted()).toEqual([['sys_capability', { name: 'x' }]]); + }); + + it('returns an engine with no listing registry as it is', () => { + const bare = { insert: async () => undefined }; + expect(withoutPlatformCapabilityDeclarations(bare)).toBe(bare); + expect(withoutPlatformCapabilityDeclarations(undefined)).toBeUndefined(); + }); +}); From 464a4edbe6fa054b88a927decc9aba048c16de55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:00:28 +0000 Subject: [PATCH 3/5] test(plugin-security): pin the declared-capability context's fallback beside the curated declarations Claude-Session: https://claude.ai/code/session_014DUFKvmT2Vyzx42PyAAXV3 Co-authored-by: Claude --- .../src/builtin-capabilities.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts index 66d9ab76836..deca0428bef 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts @@ -16,6 +16,7 @@ import { withoutPlatformCapabilityItems, } from './builtin-capabilities.js'; import { SECURITY_PLUGIN_ID } from './manifest.js'; +import { readDeclaredCapabilityContext } from './declared-capability-context.js'; describe('securityBuiltinCapabilities', () => { it('is the spec’s curated list itself, not a copy', () => { @@ -125,3 +126,18 @@ describe('withoutPlatformCapabilityDeclarations', () => { expect(withoutPlatformCapabilityDeclarations(undefined)).toBeUndefined(); }); }); + +describe('readDeclaredCapabilityContext, beside the curated declarations', () => { + const APP = { name: 'field.export', _packageId: 'com.example.field' }; + const engineWith = (capabilities: unknown[]) => ({ registry: { listItems: (type: string) => (type === 'capability' ? capabilities : []) } }); + + it('still falls back to the metadata service when the registry holds only the platform’s curated declarations', async () => { + const metadata = { list: async (type: string) => (type === 'capability' ? [APP] : []) }; + expect(await readDeclaredCapabilityContext(engineWith([OWN]), metadata)).toEqual({ declaredCapabilities: [APP] }); + expect(await readDeclaredCapabilityContext(engineWith([OWN]), { list: async () => [] })).toBeUndefined(); + }); + + it('reads the registry’s package declarations without the curated ones', async () => { + expect(await readDeclaredCapabilityContext(engineWith([OWN, APP]), undefined)).toEqual({ declaredCapabilities: [APP] }); + }); +}); From 22b7689f4230bfe2413bc38b6840ce72495fe892 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:02:46 +0000 Subject: [PATCH 4/5] test(dogfood): the showcase catalog pin expects the curated capabilities beside the stack's; changeset Claude-Session: https://claude.ai/code/session_014DUFKvmT2Vyzx42PyAAXV3 Co-authored-by: Claude --- .changeset/15204-s6b1a-capability-declarations.md | 11 +++++++++++ .../test/security-catalog-showcase.dogfood.test.ts | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .changeset/15204-s6b1a-capability-declarations.md diff --git a/.changeset/15204-s6b1a-capability-declarations.md b/.changeset/15204-s6b1a-capability-declarations.md new file mode 100644 index 00000000000..fb4894241d0 --- /dev/null +++ b/.changeset/15204-s6b1a-capability-declarations.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security): the curated platform capabilities are declared `capability` metadata + +Clause-②: no + +`SecurityPlugin` now declares the nine curated platform capabilities (`PLATFORM_CAPABILITIES` in `@objectstack/spec`: `manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`) to the engine registry under its own package id, from that one spec list. The security catalog read (`createSecurityCatalogReader`) and `GET /api/v1/meta/capability` now list them beside the capabilities a stack declares, and `GET /api/v1/meta/capability/:name` answers each one's definition (`name`, `label`, `description`, `scope`) where it answered `404`. A write to one of them at the metadata door is still refused, as before: `capability` is code-only. + +Nothing else changes: the `sys_capability` rows the boot seeds are identical, on a fresh database and on one already seeded, and no capability seeder reports the platform's own declarations. diff --git a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts index 387e4dfda4c..0611bba25aa 100644 --- a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts @@ -7,8 +7,8 @@ // What it pins: per catalog type, the names the read lists are EXACTLY the // names the declarations declare — the showcase stack's `positions`, // `permissions` and `capabilities`, plus the platform's own bootstrap -// permission sets and the six built-in positions that `plugin-security` -// ships (ADR-0131 D2) — and exactly the names the +// permission sets, the six built-in positions and the curated platform +// capabilities that `plugin-security` ships (ADR-0131 D2/D3) — and exactly the names the // metadata door lists (`GET /api/v1/meta/:type`). Every listed name resolves, // by name, to the entry the list gave for it. // @@ -37,6 +37,7 @@ import showcaseStack from '@objectstack/example-showcase'; import { securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { createSecurityCatalogReader, type SecurityCatalogType } from '@objectstack/core'; import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; type Named = { name?: unknown }; const namesOf = (items: unknown): string[] => @@ -56,7 +57,8 @@ const DECLARED: Record = { // [ADR-0131 D2] plus the six built-in positions `plugin-security` declares. position: [...new Set([...namesOf(stack.positions), ...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS])].sort(), permission: [...new Set([...namesOf(stack.permissions), ...namesOf(securityDefaultPermissionSets)])].sort(), - capability: namesOf(stack.capabilities), + // [ADR-0131 D3] plus the curated platform capabilities `plugin-security` declares. + capability: [...new Set([...namesOf(stack.capabilities), ...PLATFORM_CAPABILITY_NAMES])].sort(), }; const TYPES: SecurityCatalogType[] = ['position', 'permission', 'capability']; From cca8fe464825eafb07e30b70f01c31fac83485e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:32:54 +0000 Subject: [PATCH 5/5] test(plugin-security): type the boot test's find options Claude-Session: https://claude.ai/code/session_014DUFKvmT2Vyzx42PyAAXV3 Co-authored-by: Claude --- .../plugin-security/src/builtin-capabilities.boot.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts index 2f7b61c2a94..3d8ab46cdd7 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -169,7 +169,7 @@ async function boot(filename: string, posture: Posture, declared: Declared): Pro await plugin.init(ctx); await plugin.start(ctx); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - const exists = await engine.find('sys_user', { where: { id: 'usr_admin' }, limit: 1, context: SYS } as any); + const exists = await engine.find('sys_user', { where: { id: 'usr_admin' }, limit: 1, context: SYS }); if (!Array.isArray(exists) || exists.length === 0) { await engine.insert( 'sys_user', @@ -186,7 +186,7 @@ async function boot(filename: string, posture: Posture, declared: Declared): Pro /** The `sys_capability` rows, one string per row. */ async function census(engine: ObjectQL): Promise { - const rows = (await engine.find('sys_capability', { where: {}, limit: 5000, context: SYS } as any)) as any[]; + const rows = (await engine.find('sys_capability', { where: {}, limit: 5000, context: SYS })) as any[]; return rows .map((r) => [r.name, r.managed_by, r.package_id ?? '-', r.label, r.description ?? '-', r.scope, r.active].join(' | ')) .sort();