From f32478a97ffd59105e8180fdca5a03a94dd18bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:54:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec,plugin-security):=20A5=20?= =?UTF-8?q?=E2=80=94=20package-level=20capability=20declaration=20API=20(#?= =?UTF-8?q?2920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give packages a formal, EXPLICIT entry point to DEFINE their own authorization capabilities, so package-owned capabilities flow into the sys_capability registry with managed_by:'package' + package_id provenance instead of relying on the implicit "derive an untitled capability from a permission set's systemPermissions[]" back-door (ADR-0066 D1; aligns with ADR-0094 D5). - spec: new defineCapability / CapabilityDeclarationSchema ({ name, label?, description?, scope, packageId? }); new `capabilities` field on the stack definition (+ compose concat). - plugin-security: new bootstrapDeclaredCapabilities seeds declared capabilities with package provenance (new package_id field + index on sys_capability). Idempotent, upgrade-aware; refuses to hijack curated platform capabilities or a foreign package's rows, never clobbers admin rows, and CLAIMS a pre-existing derived placeholder. bootstrapSystemCapabilities gains declaredCapabilityNames so its back-compat derivation skips (never clobbers) declared capabilities. - runtime: stack-declared `capabilities` registered into the metadata registry (type `capability`) for the boot seeder to read. - lint: validateCapabilityReferences treats stack.capabilities as a known source. - docs: authorization.mdx + ADR-0066 D1 note; app-showcase example (define + grant the showcase.export_data capability). Note: corrects the tracking issue's premise — capability was already a single source of truth (no app→framework duplication); this task adds the missing explicit package DECLARATION path + provenance, not a de-dup. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- .changeset/authz-a5-capability-declaration.md | 33 ++++ content/docs/permissions/authorization.mdx | 55 +++++- docs/adr/0066-unified-authorization-model.md | 2 + examples/app-showcase/objectstack.config.ts | 4 + .../app-showcase/src/security/capabilities.ts | 38 ++++ examples/app-showcase/src/security/index.ts | 5 + .../src/security/permission-sets.ts | 5 +- .../validate-capability-references.test.ts | 8 + .../src/validate-capability-references.ts | 20 +- .../bootstrap-declared-capabilities.test.ts | 143 ++++++++++++++ .../src/bootstrap-declared-capabilities.ts | 185 ++++++++++++++++++ .../src/bootstrap-system-capabilities.test.ts | 11 ++ .../src/bootstrap-system-capabilities.ts | 15 +- .../src/objects/sys-capability.object.ts | 16 ++ .../plugin-security/src/security-plugin.ts | 22 ++- packages/runtime/src/app-plugin.ts | 4 + packages/spec/src/index.ts | 1 + .../spec/src/security/capabilities.test.ts | 56 ++++++ packages/spec/src/security/capabilities.ts | 78 ++++++++ packages/spec/src/stack.zod.ts | 18 ++ 20 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 .changeset/authz-a5-capability-declaration.md create mode 100644 examples/app-showcase/src/security/capabilities.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts create mode 100644 packages/spec/src/security/capabilities.test.ts diff --git a/.changeset/authz-a5-capability-declaration.md b/.changeset/authz-a5-capability-declaration.md new file mode 100644 index 0000000000..52c450653b --- /dev/null +++ b/.changeset/authz-a5-capability-declaration.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +"@objectstack/runtime": minor +"@objectstack/lint": minor +--- + +feat(spec,plugin-security): package-level capability declaration API (ADR-0066 D1) + +Packages can now DEFINE their own authorization capabilities explicitly via the +new `defineCapability` factory and a stack's `capabilities` array, instead of +relying on the implicit "derive an untitled capability from whatever a permission +set references in `systemPermissions[]`" back-door. + +- `@objectstack/spec`: new `defineCapability` / `CapabilityDeclarationSchema` + (`{ name, label?, description?, scope, packageId? }`) and a `capabilities` + field on the stack definition. +- `@objectstack/plugin-security`: new `bootstrapDeclaredCapabilities` seeds + declared capabilities into `sys_capability` with `managed_by:'package'` + + `package_id` provenance (new `package_id` field on the object). Idempotent, + upgrade-aware; refuses to hijack curated platform capabilities or another + package's rows, never clobbers admin-authored rows, and CLAIMS a pre-existing + derived placeholder (upgrading it to package provenance). The implicit + derive-from-`systemPermissions` path still runs for back-compat but now skips + any explicitly-declared name so it can't clobber authored metadata. +- `@objectstack/runtime`: stack-declared `capabilities` are registered into the + metadata registry (type `capability`) so the boot seeder can read them. +- `@objectstack/lint`: `validateCapabilityReferences` treats + `stack.capabilities` names as a known capability source. + +A capability is not a contract: DEFINE it (`defineCapability`), GRANT it +(`systemPermissions`), REQUIRE it (`requiredPermissions`) — no `inputs`. +Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing back-doors). diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 2cdeb01b61..62cf10f0e1 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -27,7 +27,10 @@ primitive fails the build. Authorization splits into three concerns that stay decoupled: 1. **Capability** — *what can be done* (`manage_users`, `export_data`). - Defined by the platform or a package; extended by admins. + Defined by the platform (curated `PLATFORM_CAPABILITIES`) or by a package + via [`defineCapability`](#package-capability-declaration-adr-0066-d1); extended + by admins in Setup. A capability is **not** a contract and has no `inputs` — a + resource merely *references* one by name (see Requirement, below). 2. **Assignment** — *who holds it* — permission sets / positions / user bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`, `sys_position_permission_set`, `sys_user_position`). Runtime records, @@ -114,6 +117,51 @@ package (metadata); subject bindings and env-specific values stay as config.** (`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded array is exactly the inert-metadata smell ADR-0078 prohibits. +### Package capability declaration (ADR-0066 D1) + +A package DEFINES its own authorization capabilities with **`defineCapability`**, +collected on the stack's `capabilities` array — the declaration-side counterpart +of the platform's curated `PLATFORM_CAPABILITIES`: + +```ts +import { defineCapability, defineStack } from '@objectstack/spec'; + +export const ExportDataCapability = defineCapability({ + name: 'export_data', + label: 'Export Data', + description: 'Bulk-export records to CSV/XLSX.', + scope: 'org', // 'platform' (global) | 'org' (scoped to the caller's org) +}); + +export default defineStack({ + manifest: { namespace: 'billing' }, + capabilities: [ExportDataCapability], // ← DEFINE + permissions: [{ name: 'billing_admin', systemPermissions: ['export_data'] }], // ← GRANT + // a resource REQUIRES it: requiredPermissions: ['export_data'] +}); +``` + +At boot `bootstrapDeclaredCapabilities` +(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds +each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`** +provenance — idempotent, re-seeded on upgrade, and it **never clobbers** +admin-authored rows, refuses to hijack a curated platform capability, and refuses +to write into another package's capability. + +This **replaces the implicit back-door** where a capability existed only as an +untitled placeholder derived from whatever a permission set happened to reference +in `systemPermissions[]`. That derivation still runs for back-compat (a +reference with no declaration keeps resolving as a `managed_by:'platform'` +placeholder), but an explicit `defineCapability` takes precedence and — for a +pre-existing derived placeholder — **claims** it, upgrading the row to package +provenance with the authored label/description/scope. This is the ADR-0094 D5 +direction: retire the implicit `managed_by`-guessing back-doors in favour of +explicit, attributable declarations. + +Remember the three-way separation: a capability is **not** a contract. You +**DEFINE** it here, **GRANT** it via a permission set's `systemPermissions`, and +**REQUIRE** it on a resource via `requiredPermissions`. There is no `inputs`. + ### Two doors, one metadata (ADR-0086 D6/D7) The `managedBy` provenance axis is not just descriptive — the platform @@ -280,6 +328,11 @@ The complete, prioritized gap map lives in issue **#2561** (the production as first-class `sys_capability` records from the canonical list in `@objectstack/spec` (`security/capabilities.ts` → `PLATFORM_CAPABILITIES`), seeded by `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`. + A **package** now DEFINES its own capabilities explicitly via + [`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1), + seeded with `managed_by:'package'` + `package_id` provenance by + `bootstrap-declared-capabilities.ts` — replacing the implicit + derive-from-`systemPermissions` back-door (which stays for back-compat). The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences` (`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a `requiredPermissions` names a capability registered nowhere — no built-in, no diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 9093a1e7e4..5ec8ed7308 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -42,6 +42,8 @@ The design rule that resolves the recurring confusion: **a resource declares the ### D1 — Capability registry [new] Promote capabilities from bare strings to **first-class records** (`sys_permission` / capability definition) with `name`, `label`, `description`, `scope` (platform | org), and a `managedBy` (platform | package | admin). `systemPermissions[]` on permission sets and `requiredPermissions[]` on resources become **references** to these records. Packages declare their capabilities; admins add new ones in Setup. Back-compat: existing string capabilities are seeded as records with the same `name`, so all current references keep resolving. +**Landed (2026-07):** the curated platform set is `@objectstack/spec` `PLATFORM_CAPABILITIES` (`security/capabilities.ts`), seeded `managed_by:'platform'` by `bootstrap-system-capabilities.ts`. "**Packages declare their capabilities**" is now an EXPLICIT API — `defineCapability` + a stack's `capabilities` array (`{ name, label?, description?, scope, packageId? }`) — materialized into `sys_capability` with `managed_by:'package'` + `package_id` provenance by `bootstrap-declared-capabilities.ts` (idempotent, upgrade-aware; refuses to hijack curated/foreign capabilities, never clobbers admin rows, and *claims* a pre-existing derived placeholder). This retires the implicit back-door where a capability existed only as an untitled placeholder derived from a permission set's `systemPermissions[]`; that derivation still runs for back-compat but skips any explicitly-declared name. A capability is **not** a contract and carries no `inputs`: DEFINE (`defineCapability`) / GRANT (`systemPermissions`) / REQUIRE (`requiredPermissions`) stay decoupled. Aligns with ADR-0094 D5 (retire implicit `managed_by`-guessing). + ### D2 — Secure-by-default object/field posture [new] (data-model posture, NOT a permission) Add an object (and field) flag that opts it **out of blanket wildcard grants** — e.g. `access: { default: 'private' }` (vs the implicit `'public'`). A `private` object is **not** covered by `'*': {allowRead:true}`; access requires an **explicit** permission-set grant. Mirrors Salesforce "new object = no access until granted." This is a posture like `tenancy`, declared on the object — it is **not** an assignment and names no principal. `admin_full_access` (the superuser `'*'` grant) still covers private objects unless it too is excluded (rare). diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index d7c23afbf4..fb12a06c8b 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -33,6 +33,7 @@ import { allApis } from './src/system/apis/index.js'; import { allPositions, allPermissionSets, + allCapabilities, allSharingRules, } from './src/security/index.js'; import { allThemes } from './src/ui/themes/index.js'; @@ -194,6 +195,9 @@ export default defineStack({ // Security positions: allPositions, permissions: allPermissionSets, + // [ADR-0066 D1] Package-declared authorization capabilities — seeded into + // sys_capability with package provenance (managed_by:'package'). + capabilities: allCapabilities, sharingRules: allSharingRules, // Seed data diff --git a/examples/app-showcase/src/security/capabilities.ts b/examples/app-showcase/src/security/capabilities.ts new file mode 100644 index 0000000000..d7646b626c --- /dev/null +++ b/examples/app-showcase/src/security/capabilities.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Package-level capability declarations (ADR-0066 D1). + * + * `defineCapability` is the FORMAL, EXPLICIT way for a package to DEFINE an + * authorization capability of its own — the counterpart, on the declaration + * side, of the platform's curated capabilities (`manage_users`, `setup.access`, + * …). Collected on the stack's `capabilities` array, each declaration is seeded + * into `sys_capability` at boot with `managed_by:'package'` + `package_id` + * provenance, so the registry can attribute and uninstall it — instead of the + * old back-door where a capability only existed IMPLICITLY as an untitled + * placeholder derived from whatever a permission set referenced. + * + * The three-way separation (ADR-0066): a capability is NOT a contract. + * • DEFINE it here (`defineCapability`). + * • GRANT it on a permission set (`systemPermissions`) — see + * `OpsPermissionSet` which carries `showcase.export_data`. + * • REQUIRE it on a resource (`requiredPermissions`) — a resource that lists + * the capability is denied unless the caller's granted sets carry it. + */ + +import { defineCapability } from '@objectstack/spec'; + +/** + * Bulk data export. Org-scoped: a power that operates within the caller's + * organization. Granted to Operations (see permission-sets.ts); a future export + * endpoint/action would gate itself with `requiredPermissions: + * ['showcase.export_data']`. + */ +export const ExportDataCapability = defineCapability({ + name: 'showcase.export_data', + label: 'Export Showcase Data', + description: 'Bulk-export showcase records (accounts, invoices) to CSV/XLSX.', + scope: 'org', +}); + +export const allCapabilities = [ExportDataCapability]; diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index cf36192905..d524252b2a 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -45,6 +45,11 @@ export { allPermissionSets, } from './permission-sets.js'; +export { + ExportDataCapability, + allCapabilities, +} from './capabilities.js'; + export { RedProjectSharingRule, HighValueRedProjectRule, diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 2aa50a78b1..bfcc2f4706 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -187,7 +187,10 @@ export const OpsPermissionSet = definePermissionSet({ showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' }, showcase_invoice: { allowRead: true }, }, - systemPermissions: ['setup.access'], + // `setup.access` is a platform capability; `showcase.export_data` is a + // PACKAGE capability this app DECLARES via defineCapability (see + // capabilities.ts) — the grant side of the ADR-0066 three-way separation. + systemPermissions: ['setup.access', 'showcase.export_data'], }); // ── The `everyone` baseline suggestion (ADR-0090 D5) ─────────────────────── diff --git a/packages/lint/src/validate-capability-references.test.ts b/packages/lint/src/validate-capability-references.test.ts index 274f718854..bf0e0bcaa1 100644 --- a/packages/lint/src/validate-capability-references.test.ts +++ b/packages/lint/src/validate-capability-references.test.ts @@ -14,6 +14,14 @@ describe('validateCapabilityReferences (ADR-0066 ⑨)', () => { expect(findings).toEqual([]); }); + it('passes a reference to a capability the stack DECLARES via defineCapability', () => { + const findings = validateCapabilityReferences({ + capabilities: [{ name: 'export_data', label: 'Export Data', scope: 'org' }], + objects: [{ name: 'inv_invoice', requiredPermissions: ['export_data'] }], + }); + expect(findings).toEqual([]); + }); + it('passes a reference to a capability the stack grants via systemPermissions', () => { const findings = validateCapabilityReferences({ permissions: [{ name: 'billing_admin', systemPermissions: ['manage_billing'] }], diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index b7e0a90cf4..6316c2bc9e 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -14,10 +14,13 @@ * * The author-time "known" set is: * 1. the built-in platform capabilities (`PLATFORM_CAPABILITY_NAMES`), - * 2. every capability a permission set in this stack GRANTS via - * `systemPermissions` (granting a capability is what declares it — mirrors + * 2. every capability the stack DECLARES via `defineCapability` + * (`stack.capabilities`) — the explicit, package-provenanced declaration + * (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`, + * 3. every capability a permission set in this stack GRANTS via + * `systemPermissions` (granting a capability also declares it — mirrors * the runtime `bootstrapSystemCapabilities` derived-defaults rule), and - * 3. any `sys_capability` row shipped as seed data. + * 4. any `sys_capability` row shipped as seed data. * * WARNING, not error: a single package's lint cannot see capabilities declared * by OTHER installed packages, and the reference fails closed at runtime anyway, @@ -91,6 +94,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin // ── Build the author-time "known capability" set ── const known = new Set(PLATFORM_CAPABILITY_NAMES); + // [ADR-0066 D1] Capabilities the stack explicitly DECLARES via defineCapability. + for (const cap of asArray(stack.capabilities)) { + if (typeof cap.name === 'string' && cap.name.length > 0) known.add(cap.name); + } for (const ps of asArray(stack.permissions)) { for (const cap of asCapArray(ps.systemPermissions)) known.add(cap); } @@ -103,9 +110,10 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin } const hint = - 'Fix the capability name, declare it on a permission set’s systemPermissions, ' + - 'ship a sys_capability seed row, or ignore this if the capability is provided by ' + - 'another installed package (references fail closed at runtime).'; + 'Fix the capability name, define it with defineCapability (stack.capabilities), ' + + 'declare it on a permission set’s systemPermissions, ship a sys_capability seed row, ' + + 'or ignore this if the capability is provided by another installed package ' + + '(references fail closed at runtime).'; const flag = (cap: string, where: string, path: string) => { if (known.has(cap)) return; diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts new file mode 100644 index 0000000000..53fdea3bc2 --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; +import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; + +/** Minimal in-memory ql for sys_capability seeding with a registry stub. */ +function makeQl(declared: any[] = []) { + const rows: any[] = []; + return { + rows, + // readDeclared() reads engine._registry.listItems(type); stub it so + // capabilities are surfaced without a metadata service. + _registry: { + listItems(type: string) { + return type === 'capability' ? declared.map((c) => ({ content: c })) : []; + }, + }, + async find(object: string, q: any) { + if (object !== 'sys_capability') return []; + const where = q?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + }, + async insert(object: string, data: any) { + if (object !== 'sys_capability') return null; + rows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + if (object !== 'sys_capability') return; + const r = rows.find((x) => x.id === data.id); + if (r) Object.assign(r, data); + }, + }; +} + +describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () => { + it('seeds an explicit package declaration with package provenance', async () => { + const ql = makeQl([ + { name: 'export_data', label: 'Export Data', description: 'Bulk export.', scope: 'org', _packageId: 'com.acme.reports' }, + ]); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.seeded).toBe(1); + expect(out.declaredNames).toEqual(['export_data']); + const row = ql.rows.find((r) => r.name === 'export_data'); + expect(row).toMatchObject({ + name: 'export_data', + label: 'Export Data', + description: 'Bulk export.', + scope: 'org', + managed_by: 'package', + package_id: 'com.acme.reports', + active: true, + }); + }); + + it('is idempotent + upgrade-aware for its own rows (re-seed, no dup)', async () => { + const ql = makeQl([{ name: 'billing.refund', label: 'Refund', scope: 'platform', _packageId: 'com.acme.billing' }]); + await bootstrapDeclaredCapabilities(ql, null); + // Ship a new label on the next boot. + (ql as any)._registry.listItems = (t: string) => + t === 'capability' ? [{ content: { name: 'billing.refund', label: 'Issue Refund', _packageId: 'com.acme.billing' } }] : []; + const out2 = await bootstrapDeclaredCapabilities(ql, null); + expect(out2.seeded).toBe(0); + expect(out2.updated).toBe(1); + expect(ql.rows.filter((r) => r.name === 'billing.refund')).toHaveLength(1); + expect(ql.rows.find((r) => r.name === 'billing.refund')?.label).toBe('Issue Refund'); + }); + + it('defaults label/description/scope when omitted', async () => { + const ql = makeQl([{ name: 'approve_invoice', _packageId: 'com.acme.inv' }]); + await bootstrapDeclaredCapabilities(ql, null); + const row = ql.rows.find((r) => r.name === 'approve_invoice'); + expect(row.label).toBe('Approve Invoice'); + expect(row.description).toBe('Capability approve_invoice.'); + expect(row.scope).toBe('platform'); + }); + + it('refuses to hijack a curated platform capability', async () => { + const ql = makeQl([{ name: 'manage_users', label: 'Evil', _packageId: 'com.acme.evil' }]); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.skippedPlatform).toBe(1); + expect(out.seeded).toBe(0); + expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined(); + }); + + it('skips a declaration with no owning package', async () => { + const ql = makeQl([{ name: 'orphan_cap', label: 'Orphan' }]); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.seeded).toBe(0); + expect(ql.rows.find((r) => r.name === 'orphan_cap')).toBeUndefined(); + }); + + it('refuses to write into a capability owned by a different package', async () => { + const ql = makeQl([{ name: 'shared_cap', _packageId: 'com.b' }]); + ql.rows.push({ id: 'cap_x', name: 'shared_cap', managed_by: 'package', package_id: 'com.a' }); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.skippedForeign).toBe(1); + expect(ql.rows.find((r) => r.name === 'shared_cap')?.package_id).toBe('com.a'); + }); + + it('never clobbers an admin-authored row', async () => { + const ql = makeQl([{ name: 'admin_cap', label: 'Ship', _packageId: 'com.a' }]); + ql.rows.push({ id: 'cap_a', name: 'admin_cap', label: 'Admin Made', managed_by: 'admin' }); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.skippedAdmin).toBe(1); + expect(ql.rows.find((r) => r.name === 'admin_cap')?.label).toBe('Admin Made'); + }); + + it('CLAIMS a derived-from-systemPermissions platform placeholder', async () => { + // Simulate the back-compat path: bootstrapSystemCapabilities derived a + // placeholder from a permission set's systemPermissions. + const ql = makeQl([{ name: 'export_data', label: 'Export Data', description: 'Nice.', scope: 'org', _packageId: 'com.acme.reports' }]); + await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }]); + const derived = ql.rows.find((r) => r.name === 'export_data'); + expect(derived.managed_by).toBe('platform'); + // Now the explicit declaration claims it. + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out.claimed).toBe(1); + const claimed = ql.rows.find((r) => r.name === 'export_data'); + expect(claimed).toMatchObject({ managed_by: 'package', package_id: 'com.acme.reports', label: 'Export Data', scope: 'org' }); + expect(ql.rows.filter((r) => r.name === 'export_data')).toHaveLength(1); + }); + + it('declared name suppresses the implicit derived placeholder (no clobber)', async () => { + // Full boot order: declared first, then system with declaredNames. + const ql = makeQl([{ name: 'export_data', label: 'Export Data', scope: 'org', _packageId: 'com.acme.reports' }]); + const cap = await bootstrapDeclaredCapabilities(ql, null); + await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }], { + declaredCapabilityNames: cap.declaredNames, + }); + const row = ql.rows.find((r) => r.name === 'export_data'); + // The package row is untouched — no humanized placeholder overwrote it. + expect(row).toMatchObject({ managed_by: 'package', label: 'Export Data', scope: 'org' }); + expect(ql.rows.filter((r) => r.name === 'export_data')).toHaveLength(1); + }); + + it('returns an empty outcome when nothing is declared', async () => { + const ql = makeQl([]); + const out = await bootstrapDeclaredCapabilities(ql, null); + expect(out).toMatchObject({ seeded: 0, updated: 0, claimed: 0, declaredNames: [] }); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts new file mode 100644 index 0000000000..b76e1afd9f --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredCapabilities — seed stack-declared `capabilities` into + * `sys_capability` with PACKAGE provenance (ADR-0066 D1; the exact sibling of + * `bootstrapDeclaredPermissions`). + * + * A package's authorization capabilities used to reach the registry only + * IMPLICITLY: `bootstrapSystemCapabilities` derived an untitled placeholder for + * any capability string a permission set happened to reference in + * `systemPermissions[]`, seeded `managed_by:'platform'` with a humanized label. + * That back-door gave the registry no way to attribute a capability to the + * package that owns it, nor to carry a real label/description. + * + * `defineCapability` + `stack.capabilities` is the EXPLICIT declaration entry + * point (ADR-0066 D1: "packages DEFINE capabilities"). This seeder materializes + * those declarations: + * + * - each declared capability is upserted by `name` with `managed_by:'package'` + * and `package_id` = the registering package (`_packageId` stamped by the + * SchemaRegistry / ADR-0010, with the spec-level `packageId` (ADR-0086 D3) + * as the author-declared fallback); + * - a name that collides with a CURATED platform capability + * (`PLATFORM_CAPABILITY_NAMES`) is refused loudly — those are platform-owned + * and a package must not hijack them; + * - a pre-existing `managed_by:'platform'` row for a NON-curated name is a + * derived-from-systemPermissions placeholder — the explicit declaration + * CLAIMS it (upgrades it to package provenance with the authored metadata), + * which is the whole point: retire the implicit back-door; + * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns (`managed_by:'package'`, + * same `package_id`) is re-seeded on every boot so the record always + * reflects the shipped declaration. Rows owned by a DIFFERENT package are + * skipped loudly; + * - admin-authored rows (`managed_by:'admin'`) are NEVER clobbered. + * + * Runs on `kernel:ready` in `@objectstack/plugin-security` alongside the other + * declared-metadata seeders. The set of declared names is returned so the + * caller can tell `bootstrapSystemCapabilities` to SKIP re-deriving (and thus + * clobbering) an explicitly-declared capability. + */ + +import { + genId, + tryFind, + tryInsert, + tryUpdate, + type ProjectionLogger, +} from './permission-set-projection.js'; +import { readDeclared } from './bootstrap-declared-permissions.js'; +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; + +interface SeedOptions { + logger?: ProjectionLogger; +} + +/** Aggregated outcome of a declared-capability seeding pass. */ +export interface CapabilitySeedOutcome { + seeded: number; + updated: number; + claimed: number; + skippedAdmin: number; + skippedForeign: number; + skippedPlatform: number; + /** Names of every capability EXPLICITLY declared (regardless of seed outcome). */ + declaredNames: string[]; +} + +function humanize(name: string): string { + return name.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Normalize a declaration body into the `sys_capability` platform-owned fields. */ +function capabilityRowFields(cap: any): { label: string; description: string; scope: 'platform' | 'org' } { + return { + label: typeof cap.label === 'string' && cap.label ? cap.label : humanize(cap.name), + description: typeof cap.description === 'string' && cap.description ? cap.description : `Capability ${cap.name}.`, + scope: cap.scope === 'org' ? 'org' : 'platform', + }; +} + +/** + * Upsert ONE declared capability into `sys_capability` under the owning + * `packageId`, applying the ADR-0066 D1 provenance rules (own-row re-seed, + * derived-platform-row claim, curated/foreign/admin refuse-or-skip). + */ +async function upsertPackageCapability( + ql: any, + cap: any, + packageId: string | null | undefined, + out: CapabilitySeedOutcome, + logger?: ProjectionLogger, +): Promise { + if (!cap?.name) return; + + // Curated platform capabilities are platform-owned — a package must never + // claim one (that would let it silently redefine `manage_users`, `setup.access`, …). + if (PLATFORM_CAPABILITY_NAMES.has(cap.name)) { + out.skippedPlatform += 1; + logger?.warn?.('[security] capability name is a curated platform capability — not materialized as package', { name: cap.name }); + return; + } + + // A `managed_by:'package'` row without a `package_id` makes uninstall + // undefined (the ambiguity ADR-0086 D3 removes) — skip an unowned declaration. + if (!packageId) { + logger?.warn?.('[security] capability has no owning package — not materialized', { name: cap.name }); + return; + } + + const fields = capabilityRowFields(cap); + const existing = (await tryFind(ql, 'sys_capability', { name: cap.name }, 1))[0]; + + if (!existing?.id) { + const created = await tryInsert(ql, 'sys_capability', { + id: genId('cap'), + name: cap.name, + ...fields, + managed_by: 'package', + package_id: packageId, + active: true, + }); + if (created) out.seeded += 1; + return; + } + + if (existing.managed_by === 'package') { + if (existing.package_id === packageId) { + // Our own row — re-seed so it always reflects the shipped declaration. + if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields })) out.updated += 1; + } else { + out.skippedForeign += 1; + logger?.warn?.('[security] capability name owned by another package — skipped', { + name: cap.name, declaredBy: packageId, ownedBy: existing.package_id, + }); + } + return; + } + + if (existing.managed_by === 'platform') { + // Non-curated (curated excluded above) platform row = a derived-from- + // systemPermissions placeholder. The explicit declaration CLAIMS it: + // upgrade to package provenance with the authored label/description/scope. + if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId })) { + out.claimed += 1; + } + return; + } + + // `admin` (or any other) — environment/admin-authored. Never clobbered. + out.skippedAdmin += 1; +} + +export async function bootstrapDeclaredCapabilities( + ql: any, + metadataService: any, + options: SeedOptions = {}, +): Promise { + const out: CapabilitySeedOutcome = { + seeded: 0, updated: 0, claimed: 0, skippedAdmin: 0, skippedForeign: 0, skippedPlatform: 0, declaredNames: [], + }; + if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; + + let caps: any[] = readDeclared(ql, 'capability'); + if (caps.length === 0) { + try { + const listed = metadataService?.list?.('capability'); + caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { caps = []; } + } + if (!Array.isArray(caps) || caps.length === 0) return out; + + for (const cap of caps) { + if (!cap?.name) continue; + out.declaredNames.push(cap.name); + // Registry provenance first (ADR-0010 `_packageId`), author-declared + // spec `packageId` (ADR-0086 D3) as fallback. + const packageId: string | undefined = cap._packageId ?? cap.packageId ?? undefined; + await upsertPackageCapability(ql, cap, packageId, out, options.logger); + } + + options.logger?.info?.('[security] declared capabilities seeded into sys_capability (ADR-0066 D1)', { + ...out, total: caps.length, + }); + return out; +} diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts index 3a3c7be363..a1e8c1e43c 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts @@ -54,6 +54,17 @@ describe('bootstrapSystemCapabilities (ADR-0066 D1 back-compat seed)', () => { expect(ql.rows.find((x) => x.name === 'manage_org_users')?.scope).toBe('org'); }); + it('does NOT derive a placeholder for an explicitly-declared capability', async () => { + const ql = makeQl(); + await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data', 'approve_invoice'] }], { + declaredCapabilityNames: ['export_data'], + }); + // `export_data` is owned by the declared seeder — no platform placeholder. + expect(ql.rows.find((x: any) => x.name === 'export_data')).toBeUndefined(); + // `approve_invoice` is still derived as before. + expect(ql.rows.find((x: any) => x.name === 'approve_invoice')).toBeDefined(); + }); + it('marks manage_org_users as org-scoped and the rest platform', () => { const org = KNOWN_CAPABILITIES.find((c) => c.name === 'manage_org_users'); expect(org?.scope).toBe('org'); diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts index bb42934ffc..f584f390e2 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts @@ -61,6 +61,14 @@ function humanize(name: string): string { interface SeedOptions { logger?: { info?: (m: string, meta?: Record) => void; warn?: (m: string, meta?: Record) => void }; + /** + * [ADR-0066 D1] Capability names that a package has EXPLICITLY declared via + * `defineCapability` (materialized by `bootstrapDeclaredCapabilities`). The + * implicit derived-defaults path SKIPS these so it never overwrites an + * authored capability's label/description/scope (or its package provenance) + * with a humanized placeholder. Curated platform capabilities are unaffected. + */ + declaredCapabilityNames?: Iterable; } export async function bootstrapSystemCapabilities( @@ -72,13 +80,16 @@ export async function bootstrapSystemCapabilities( return { seeded: 0, updated: 0, total: 0 }; } + const declared = new Set(options.declaredCapabilityNames ?? []); + // Build the full definition set: curated first, then any extra capability - // string referenced by the seeded permission sets (derived defaults). + // string referenced by the seeded permission sets (derived defaults) — EXCEPT + // ones a package explicitly declared, which the declared seeder owns. const byName = new Map(); for (const c of KNOWN_CAPABILITIES) byName.set(c.name, c); for (const ps of permissionSets) { for (const cap of ps?.systemPermissions ?? []) { - if (typeof cap === 'string' && cap && !byName.has(cap)) { + if (typeof cap === 'string' && cap && !byName.has(cap) && !declared.has(cap)) { byName.set(cap, { name: cap, label: humanize(cap), description: `Capability ${cap}.`, scope: 'platform' }); } } diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts index 764a2166de..58d978b4ab 100644 --- a/packages/plugins/plugin-security/src/objects/sys-capability.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-capability.object.ts @@ -158,6 +158,20 @@ export const SysCapability = ObjectSchema.create({ group: 'Classification', }), + // [ADR-0066 D1 / ADR-0086 D3] Owning package for a capability DECLARED by a + // package via `defineCapability` (absent = platform-curated or admin-created). + // Populated by bootstrapDeclaredCapabilities; makes package uninstall/upgrade + // of a capability well-defined and gives the derived-from-systemPermissions + // back-door an explicit, attributable replacement. + package_id: Field.text({ + label: 'Owning Package', + required: false, + readonly: true, + maxLength: 255, + description: 'Package that ships this capability (absent = platform-curated or admin-created).', + group: 'Classification', + }), + // ── Status ─────────────────────────────────────────────────── active: Field.boolean({ label: 'Active', @@ -192,6 +206,8 @@ export const SysCapability = ObjectSchema.create({ { fields: ['name'], unique: true }, { fields: ['scope'] }, { fields: ['active'] }, + // [ADR-0086 D3] uninstall/upgrade query: "this package's own capabilities". + { fields: ['package_id'] }, ], enable: { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f4728fa254..da891d0dfa 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -31,6 +31,7 @@ import { import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; +import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { matchesFilterCondition } from '@objectstack/formula'; import { FieldMasker } from './field-masker.js'; @@ -1254,10 +1255,25 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } - // [ADR-0066 D1] Back-compat seed the capability registry (sys_capability) - // from the curated platform set + the default grants' systemPermissions. + // [ADR-0066 D1] Seed the capability registry (sys_capability) in two + // passes. FIRST the EXPLICIT package declarations (`defineCapability` / + // `stack.capabilities`) land with `managed_by:'package'` + package_id + // provenance — the formal replacement for the implicit derive-from- + // systemPermissions back-door. THEN the platform curated set + the + // back-compat derived defaults, SKIPPING any name a package already + // declared (so the placeholder never clobbers the authored capability). + let declaredCapabilityNames: string[] = []; try { - await bootstrapSystemCapabilities(ql, this.bootstrapPermissionSets, { logger: ctx.logger }); + const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, { logger: ctx.logger }); + declaredCapabilityNames = capOutcome.declaredNames; + } catch (e) { + ctx.logger.warn('[security] declared-capability seeding failed', { error: (e as Error).message }); + } + try { + await bootstrapSystemCapabilities(ql, this.bootstrapPermissionSets, { + logger: ctx.logger, + declaredCapabilityNames, + }); } catch (e) { ctx.logger.warn('[security] capability seeding failed', { error: (e as Error).message }); } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 55018cd4c1..791cf62735 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -418,6 +418,10 @@ export class AppPlugin implements Plugin { const SECURITY_FIELDS: Array<[string, string]> = [ ['positions', 'position'], ['permissions', 'permission'], + // [ADR-0066 D1] Package-declared authorization capabilities — + // read back by bootstrapDeclaredCapabilities to seed + // sys_capability with package provenance. + ['capabilities', 'capability'], ['sharingRules', 'sharing_rule'], ['policies', 'policy'], ]; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 860021f103..5ae349f2d5 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -90,6 +90,7 @@ export { defineConnector } from './integration/connector.zod'; export { defineSharingRule } from './security/sharing.zod'; export { definePosition, EVERYONE_POSITION, GUEST_POSITION, AUDIENCE_ANCHOR_POSITIONS } from './identity/position.zod'; export { definePermissionSet } from './security/permission.zod'; +export { defineCapability } from './security/capabilities'; export { defineEmailTemplateDefinition } from './system/email-template.zod'; export { defineReport } from './ui/report.zod'; export { defineWebhook } from './automation/webhook.zod'; diff --git a/packages/spec/src/security/capabilities.test.ts b/packages/spec/src/security/capabilities.test.ts new file mode 100644 index 0000000000..43f39b26d1 --- /dev/null +++ b/packages/spec/src/security/capabilities.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + defineCapability, + CapabilityDeclarationSchema, + PLATFORM_CAPABILITIES, + PLATFORM_CAPABILITY_NAMES, +} from './capabilities'; + +describe('defineCapability (ADR-0066 D1 package declaration)', () => { + it('validates and returns a capability declaration with defaults applied', () => { + const cap = defineCapability({ + name: 'export_data', + label: 'Export Data', + description: 'Bulk export to CSV.', + scope: 'org', + }); + expect(cap).toEqual({ + name: 'export_data', + label: 'Export Data', + description: 'Bulk export to CSV.', + scope: 'org', + }); + }); + + it('defaults scope to platform when omitted', () => { + const cap = defineCapability({ name: 'billing.refund', label: 'Refund' }); + expect(cap.scope).toBe('platform'); + }); + + it('allows dotted and underscored lowercase names', () => { + expect(() => defineCapability({ name: 'billing.refund' })).not.toThrow(); + expect(() => defineCapability({ name: 'export_data' })).not.toThrow(); + }); + + it('rejects invalid (uppercase / spaced) names', () => { + expect(() => defineCapability({ name: 'ExportData' })).toThrow(); + expect(() => defineCapability({ name: 'export data' })).toThrow(); + expect(() => defineCapability({ name: '' })).toThrow(); + }); + + it('carries an optional author-declared packageId', () => { + const cap = defineCapability({ name: 'x_cap', packageId: 'com.acme.x' }); + expect(cap.packageId).toBe('com.acme.x'); + }); + + it('is the declaration counterpart of the curated platform set', () => { + // Every curated platform capability is a valid declaration shape. + for (const c of PLATFORM_CAPABILITIES) { + const parsed = CapabilityDeclarationSchema.parse(c); + expect(parsed.name).toBe(c.name); + expect(PLATFORM_CAPABILITY_NAMES.has(parsed.name)).toBe(true); + } + }); +}); diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index 7df63869e1..8a81eedb5d 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -1,5 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + /** * [ADR-0066 D1] Canonical platform capability registry. * @@ -50,3 +53,78 @@ export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ export const PLATFORM_CAPABILITY_NAMES: ReadonlySet = new Set( PLATFORM_CAPABILITIES.map((c) => c.name), ); + +/** + * [ADR-0066 D1] PACKAGE-LEVEL capability DECLARATION. + * + * The formal entry point for a package/app to DEFINE an authorization + * capability of its own — the counterpart, on the declaration side, of the + * curated {@link PLATFORM_CAPABILITIES}. Authored via {@link defineCapability} + * and collected on a stack's `capabilities` array, these declarations flow + * EXPLICITLY into the `sys_capability` registry at boot with `managed_by: + * 'package'` + `package_id` provenance (seeded by + * `@objectstack/plugin-security`'s `bootstrapDeclaredCapabilities`). + * + * This replaces the old implicit "derive an untitled capability from whatever a + * permission set happens to reference in `systemPermissions[]`" back-door: a + * package now says what capabilities it owns, with a real label/description, + * and the registry can attribute + uninstall them. The implicit derivation + * still runs for back-compat (references with no declaration keep resolving), + * but an EXPLICIT declaration takes precedence and carries package provenance. + * + * NOTE — a capability is NOT a contract: resources REFERENCE a capability by + * name via `requiredPermissions` (the requirement side); packages DEFINE one + * here (the declaration side); permission sets GRANT one via `systemPermissions` + * (the assignment side). There is no `inputs` shape — see ADR-0066's three-way + * separation (capability / assignment / requirement). + */ +export const CapabilityDeclarationSchema = lazySchema(() => z.object({ + /** + * Stable capability key referenced by `systemPermissions` / `requiredPermissions`. + * Lowercase, dot/underscore separable (e.g. `export_data`, `billing.refund`). + */ + name: z.string() + .min(1) + .regex(/^[a-z][a-z0-9_.]*$/, 'Capability name must be lowercase and may contain digits, "_" and "." (e.g. export_data, billing.refund)') + .describe('Stable capability key referenced by systemPermissions / requiredPermissions'), + /** Human label shown in Setup. Defaults to a humanized `name` when omitted. */ + label: z.string().min(1).optional().describe('Human label shown in Setup'), + /** What holding the capability permits. */ + description: z.string().optional().describe('What holding this capability permits'), + /** `platform` = global; `org` = scoped to the caller's organization. Defaults to `platform`. */ + scope: z.enum(['platform', 'org']).default('platform') + .describe('platform = a platform-wide power; org = scoped to an organization'), + /** + * [ADR-0086 D3] Owning package id — the author-declared fallback provenance. + * The registry stamps `_packageId` at load; this is used only when that is + * absent. A capability with no resolvable owner is not materialized as a + * package row. + */ + packageId: z.string().optional() + .describe('[ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped)'), +})); + +/** A validated package-level capability declaration (output of {@link defineCapability}). */ +export type CapabilityDeclaration = z.infer; +/** Authoring input for {@link CapabilityDeclaration} — defaulted fields are optional. */ +export type CapabilityDeclarationInput = z.input; + +/** + * Type-safe factory for a package-level capability declaration (ADR-0066 D1). + * Validates at authoring time via `.parse()` and accepts input-shape config + * (optional `label`/`description`, defaulted `scope`) — preferred over a bare + * object literal. + * + * @example + * ```ts + * export const ExportDataCapability = defineCapability({ + * name: 'export_data', + * label: 'Export Data', + * description: 'Bulk-export records to CSV/XLSX.', + * scope: 'org', + * }); + * ``` + */ +export function defineCapability(config: CapabilityDeclarationInput): CapabilityDeclaration { + return CapabilityDeclarationSchema.parse(config); +} diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 40b49edeaf..2c85bd9d00 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -32,6 +32,7 @@ import { JobSchema } from './system/job.zod'; // Security Protocol import { PositionSchema } from './identity/position.zod'; import { PermissionSetSchema } from './security/permission.zod'; +import { CapabilityDeclarationSchema } from './security/capabilities'; import { SharingRuleSchema } from './security/sharing.zod'; import { ApiEndpointSchema } from './api/endpoint.zod'; @@ -234,6 +235,22 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ */ positions: z.array(PositionSchema).optional().describe('Positions — flat capability-distribution groups (ADR-0090 D3)'), permissions: z.array(PermissionSetSchema).optional().describe('Permission Sets'), + /** + * [ADR-0066 D1] Authorization capabilities this package DEFINES. + * + * The formal, EXPLICIT declaration entry point (`defineCapability`) — the + * package-side counterpart of the curated platform capabilities. Each entry + * is seeded into `sys_capability` at boot with `managed_by:'package'` + + * `package_id` provenance, instead of being implicitly derived (untitled) + * from whatever a permission set references in `systemPermissions[]`. + * + * NOT to be confused with `requires` (platform SERVICE capabilities like + * `ai`/`automation`) nor the runtime `ObjectStackCapabilities` descriptor — + * these are authorization capabilities in the ADR-0066 sense (referenced by + * `requiredPermissions` / granted by `systemPermissions`). + */ + capabilities: z.array(CapabilityDeclarationSchema).optional() + .describe('[ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance)'), sharingRules: z.array(SharingRuleSchema).optional().describe('Record Sharing Rules'), /** @@ -1047,6 +1064,7 @@ const CONCAT_ARRAY_FIELDS = [ 'flows', 'positions', 'permissions', + 'capabilities', 'sharingRules', 'apis', 'webhooks', From 60d1d4011ffb88f52ac213f82a15a40d29c6f512 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 16:15:20 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface snapshot for defineCapability exports The A5 branch added defineCapability + CapabilityDeclaration* exports but did not commit the regenerated api-surface snapshot; CI check:api-surface flagged 6 unregistered exports. Regenerated against a fresh dts build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- packages/spec/api-surface-signatures.json | 1 + packages/spec/api-surface.json | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json index d4aff2c55c..3986f39a30 100644 --- a/packages/spec/api-surface-signatures.json +++ b/packages/spec/api-surface-signatures.json @@ -3,6 +3,7 @@ "defineAgent": "sha256:130533285b3deea4", "defineApp": "sha256:bef0d76d5076259a", "defineBook": "sha256:07a6e25c6be3f3bd", + "defineCapability": "sha256:b080cc5480782d6c", "defineConnector": "sha256:827b4a4bb56a83b5", "defineCube": "sha256:635855b04c960946", "defineDatasource": "sha256:e0ec3b5f9db26aca", diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 4210b695c4..7024b14b16 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -108,6 +108,7 @@ "defineAgent (function)", "defineApp (function)", "defineBook (function)", + "defineCapability (function)", "defineConnector (function)", "defineCube (function)", "defineDatasource (function)", @@ -3913,6 +3914,9 @@ "AdminScope (type)", "AdminScopeInput (type)", "AdminScopeSchema (const)", + "CapabilityDeclaration (type)", + "CapabilityDeclarationInput (type)", + "CapabilityDeclarationSchema (const)", "CriteriaSharingRule (type)", "CriteriaSharingRuleSchema (const)", "ExplainDecision (type)", @@ -3958,6 +3962,7 @@ "TerritoryModelSchema (const)", "TerritorySchema (const)", "TerritoryType (const)", + "defineCapability (function)", "definePermissionSet (function)", "defineSharingRule (function)", "describeAnchorForbiddenBits (function)",