diff --git a/.changeset/7611-capability-registry.md b/.changeset/7611-capability-registry.md new file mode 100644 index 0000000000..a4beeaecf4 --- /dev/null +++ b/.changeset/7611-capability-registry.md @@ -0,0 +1,10 @@ +--- +'@object-ui/fields': minor +'@object-ui/app-shell': minor +--- + +The capability select lists the capability registry, and the permission-set editor's provenance badge reads the layered envelope (objectui#7611, objectstack ADR-0131 D3/D7). + +- **`CapabilityMultiSelectField` lists the registry.** Its options are the `capability` items of the console metadata store (`GET /api/v1/meta/capability`): the platform's curated capabilities beside every capability a package declares. They are read through `MetadataCtx` `ensureType`, the reader the sharing-rule recipient picker uses for positions. It no longer asks a data source for `sys_capability` rows. Nothing writes such a row any more, so on a fresh database the picker offered only the capabilities the set already granted, and none could be added. No second list is merged in. A granted name the registry does not serve is still shown, selected, under "Other". The props are unchanged; the `dataSource` prop is no longer read. Without a metadata store mounted, the picker offers only the set's own selections and loads nothing. On a framework from before objectstack#22669, the registry serves the package-declared capabilities only, so the platform's curated ones are offered only where the set already grants them. +- **The provenance badge.** In the permission-set editor, a set a code package ships now reads "Package" beside its read-only lock. It read "Custom" in the Setup catalog, because the registry serves no `managedBy` and that scope passes no `packageId`. The badge now also reads the layered envelope's verdict that the lock reads. A set authored in the environment, an organization-authored set and a published organization set still read "Custom". +- **The Capabilities page.** `…/metadata/capability?scope=environment` lists the capability registry read-only, with no create affordance and no active switch, because the type offers no runtime write channel and a capability has no activation. Nothing about that page changes here; `SETUP_CATALOG_TYPES` keeps `capability` out of the switch on purpose, and its documentation now says why. diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 66ccc92be4..470a69ea51 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -511,9 +511,11 @@ catalog row. A refusal from the door (`403` for a caller who may not switch, or for switching off the last administrator who can sign in; `404`; `503`) is shown in the server's own words. The audience anchors `everyone` and `guest`, which the door refuses to switch, show a disabled switch and the reason. -Capabilities are not in this scope yet: the Capabilities page and the -capability picker still read `sys_capability` rows, and a capability has no -switch. +Capabilities are listed by the same scope, `…/metadata/capability?scope=environment`: +the capability registry (the platform's curated capabilities beside every +package-declared one), read-only, because the type offers no runtime write +channel. That page has no switch, because a capability has no activation. The +capability picker in the permission-set editor lists the same registry. ### Package-less flows (`/studio/~org/automations`) diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx new file mode 100644 index 0000000000..86c426f752 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.provenanceBadge-7611.test.tsx @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7611 (F2) — the identity strip's provenance badge reads the layered + * envelope, so a set a code package SHIPS reads "Package", not "Custom". + * + * ## The defect + * + * The badge read only `draft.managedBy` and the `packageId` prop. The registry + * serves no `managedBy` on a permission set, and the Setup catalog (the + * environment scope) passes no `packageId`, so every packaged set opened there + * read "Custom" — beside the artifact lock that says a code package provides + * it. The editor already holds the fact that answers the question: the layered + * envelope's `code` layer and its ADR-0010 `provenance`, read by + * `isArtifactBackedLayer` into `codeIsArtifact` for the artifact gate. The badge + * now reads that same verdict, so the badge and the lock can never disagree. + * + * ## What discriminates + * + * The first case is the fix. The three after it are the predicate's own + * exclusions (`PermissionMatrixEditor.artifactTier.test.tsx` pins them for the + * lock): a set with no code layer, an organization-authored set read back with + * a code-looking `_packageId`, and the `sys_metadata` save-path sentinel. Each + * must still read "Custom", so the fix cannot be "always Package". + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +/** The layered envelope's `code` slot (see the artifact-tier suite's legend). */ +let codeLayer: Record | null = null; +/** The envelope's ADR-0010 `provenance`; `undefined` = the key absent. */ +let provenance: 'package' | 'org' | 'env-forced' | undefined; + +/** A set as the registry serves it: no `managedBy`. */ +const SET = { + name: 'showcase_contributor', + label: 'Contributor', + objects: { a_account: { allowRead: true } }, + fields: {}, +} as Record; + +function makeClient() { + return { + layered: async () => ({ + effective: SET, + code: codeLayer, + overlay: null, + overlayScope: null, + ...(provenance !== undefined ? { provenance } : {}), + }), + getDraft: async () => null, + list: async (type: string) => + type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [], + get: async (type: string) => + type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null, + save: async (_t: string, _n: string, payload: Record) => payload, + } as any; +} + +let clientImpl: any; + +// The stock-boot `permission` shape: no per-org overlay, runtime authoring open +// — the flags under which a packaged set is locked at the environment scope. +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => clientImpl, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: false, allowRuntimeCreate: true }], + }), +})); +// The environment scope mounts who-holds-it under the definition; its reads are +// not this suite's subject. +vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null })); + +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; + +afterEach(() => { + cleanup(); + codeLayer = null; + provenance = undefined; +}); + +/** Mounts the editor at the environment scope (no `packageId`). */ +async function renderMatrix() { + clientImpl = makeClient(); + render( + + + , + ); + await screen.findByText('Account'); +} + +const badgeText = () => screen.getByTestId('perm-provenance-badge').textContent; + +describe('the provenance badge reads the layered envelope (objectui#7611, F2)', () => { + it('a set a code package ships reads "Package" beside its lock, not "Custom"', async () => { + codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' }; + provenance = 'package'; + await renderMatrix(); + expect(badgeText()).toBe('Package'); + // The lock it sits beside: the artifact gate, read off the same envelope. + expect(screen.getByText(/^Read-only \(/)).toBeInTheDocument(); + }); + + it('a set with no code layer (authored in this environment) still reads "Custom"', async () => { + await renderMatrix(); + expect(badgeText()).toBe('Custom'); + }); + + it('an organization-authored set read back with a code-looking _packageId still reads "Custom"', async () => { + codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' }; + provenance = 'org'; + await renderMatrix(); + expect(badgeText()).toBe('Custom'); + }); + + it('the sys_metadata save-path sentinel still reads "Custom"', async () => { + codeLayer = { _packageId: 'sys_metadata', name: 'showcase_contributor' }; + await renderMatrix(); + expect(badgeText()).toBe('Custom'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 2f40feeca7..e607c3c6bf 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -290,9 +290,9 @@ export interface PermissionMatrixEditPageProps { export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd, onDirtyChange, readOnly = false, embedded = false }: PermissionMatrixEditPageProps) { const navigate = useNavigate(); const client = useMetadataClient(); - // Data adapter (records) — the capability picker reads the live sys_capability - // registry (ADR-0056 P2). The metadata `client` handles the draft; capability - // rows are data, fetched like AssignedUsersSection does. + // Data adapter (records) — read by "Clone to customize" below. The capability + // picker takes no data source: it lists the capability registry through the + // console metadata store (objectui#7611, `CapabilityMultiSelectField`). const adapter = useAdapter(); // objectui#5987 — the three seams "Clone to customize" dispatches through: // the console's shared action runner (`useAction()` under @@ -1332,11 +1332,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, so the summary carries the human label — no need to repeat it. */} {draft.label || draft.name} {/* [A4 framework#2920] Provenance tri-state — platform / package / - admin(custom) — mirrors the unified sys_* `managed_by` vocab. */} - + admin(custom) — mirrors the unified sys_* `managed_by` vocab. + objectui#7611 (F2): "Package" also reads the layered envelope + this editor already fetches (`codeIsArtifact`, the artifact + gate's own input). The registry serves no `managedBy`, and at + the environment scope there is no `packageId`, so a set a code + package ships read "Custom" beside its own lock. */} + {draft.managedBy === 'platform' ? t('perm.badge.platform') - : draft.managedBy === 'package' || packageId + : draft.managedBy === 'package' || packageId || codeIsArtifact ? t('perm.badge.package') : t('perm.badge.custom')} @@ -1449,8 +1454,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{t('perm.field.systemCapabilitiesHelp')}

- {/* objectui#3332 — the capability chip wall grows with the live - sys_capability registry (44+ nav capabilities → 20+ rows). It is + {/* objectui#3332 — the capability chip wall grows with the + capability registry (44+ nav capabilities → 20+ rows). It is a non-shrinking sibling of the flex-1 matrix, so unbounded it squeezes the object list to ~0px with no page scrollbar. Cap it to a viewport share and scroll internally; the matrix keeps the @@ -1465,7 +1470,6 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, setDraft((p) => ({ ...p, systemPermissions: parseCapabilityNames(v) })) } field={{ name: 'system_permissions' } as any} - dataSource={adapter as any} readonly={!writable} /> diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx index f703d4c1eb..aaaf6388d9 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.environmentScope-7611.test.tsx @@ -17,7 +17,10 @@ * - the active switch: per item, the activation ledger's state (no row is * active) with no catalog row read, written through the activation door by * name, the door's refusal shown in its own words, disabled for a caller - * who cannot author, and disabled with the reason on an audience anchor. + * who cannot author, and disabled with the reason on an audience anchor; + * - the Capabilities page (row 1b): the same scope lists the capability + * registry read-only, with no switch, no ledger read and no row read — the + * Setup target objectstack's `nav_capabilities` moves onto. */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react'; @@ -37,6 +40,13 @@ const POSITIONS: Record[] = [ { name: 'auditor', label: 'Auditor', _packageId: PROJECT_PKG, _provenance: 'package' }, ]; +/** The capability registry as a fresh showcase serves it: curated beside package-declared. */ +const CAPABILITIES: Record[] = [ + { name: 'manage_users', label: 'Manage Users', scope: 'platform', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, + { name: 'setup.access', label: 'Setup Access', scope: 'platform', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, + { name: 'showcase.export_data', label: 'Export Showcase Data', scope: 'org', _packageId: PROJECT_PKG, _provenance: 'package' }, +]; + const ITEMS: Record[] = [ { name: 'admin_full_access', label: 'Administrator', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' }, { name: 'showcase_auditor', label: 'Showcase Auditor', _packageId: PROJECT_PKG, _provenance: 'package' }, @@ -52,16 +62,20 @@ const CLIENT = { ? ITEMS : type === 'position' ? POSITIONS - : [], + : type === 'capability' + ? CAPABILITIES + : [], }; const ENTRY = { type: TYPE, label: 'Permission Set', allowOrgOverride: false, allowRuntimeCreate: true }; const POSITION_ENTRY = { type: 'position', label: 'Position', allowOrgOverride: false, allowRuntimeCreate: true }; +// The served `capability` entry: no runtime write channel at all (code-only, ADR-0126 §3). +const CAPABILITY_ENTRY = { type: 'capability', label: 'Capability', allowOrgOverride: false, allowRuntimeCreate: false }; vi.mock('./useMetadata', async (importOriginal) => ({ ...(await importOriginal>()), useMetadataClient: () => CLIENT, - useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY, POSITION_ENTRY] }), + useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY, POSITION_ENTRY, CAPABILITY_ENTRY] }), })); vi.mock('./i18n', async (importOriginal) => ({ @@ -247,3 +261,21 @@ describe('the active switch (the activation ledger, through its door)', () => { expect(requests.some((r) => r.url.includes('sys_metadata_activation'))).toBe(false); }); }); + +describe('the Capabilities catalog page: the registry, read-only, with no switch (objectui#7611, row 1b)', () => { + it('lists every registry capability with no create, no status filter and no switch, and reads no ledger and no sys_capability row', async () => { + const { container } = mount('?scope=environment', 'capability'); + await waitFor(() => expect(linkTo(container, 'manage_users')).not.toBeNull()); + for (const name of ['manage_users', 'setup.access', 'showcase.export_data']) { + expect(linkTo(container, name)?.getAttribute('href')).toMatch(/\?scope=environment$/); + } + // Read-only: the type offers no runtime write channel, whoever the caller is. + expect(screen.queryByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) })).toBeNull(); + // No switch: a capability has no activation (the door refuses the type). + expect(screen.queryByTestId('catalog-status-filter')).toBeNull(); + for (const name of ['manage_users', 'setup.access', 'showcase.export_data']) { + expect(screen.queryByTestId(`catalog-active-${name}`)).toBeNull(); + } + expect(requests.some((r) => /sys_metadata_activation|sys_capability/.test(r.url))).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/catalog-scope.ts b/packages/app-shell/src/views/metadata-admin/catalog-scope.ts index 4c62f3f2db..68e6a352f1 100644 --- a/packages/app-shell/src/views/metadata-admin/catalog-scope.ts +++ b/packages/app-shell/src/views/metadata-admin/catalog-scope.ts @@ -58,14 +58,18 @@ export function environmentScopeSuffix(search: QueryLike): string { } /** - * The catalog types Setup administers through this scope. Each name is also - * the activation ledger's `metadata_type` and the activation door's `:type` - * (see `catalog-activation.ts`). `capability` is absent: the Capabilities page - * and the capability picker still read `sys_capability` rows, and moving them - * onto the registry is objectui#7611's follow-up. (The registry serves the - * platform's own capabilities since objectstack#22669, #15204 stage 6b-1a; - * before it, it served the package-declared ones only.) A capability has no - * switch either: ADR-0126 §3 makes it code-only, and the activation door - * refuses the type with `400`. + * The catalog types Setup administers through this scope with an active + * switch. Each name is also the activation ledger's `metadata_type` and the + * activation door's `:type` (see `catalog-activation.ts`). + * + * `capability` is absent ON PURPOSE, and it is a catalog all the same. The + * Capabilities page is this scope's list of the type, + * `…/metadata/capability?scope=environment`: the capability registry (the + * platform's curated capabilities, served since objectstack#22669, beside + * every package-declared one), read-only because the served type entry offers + * no runtime write channel. The capability picker lists the same registry + * (objectui#7611). What a capability lacks is the switch: ADR-0126 §3 makes it + * code-only, and the activation door refuses the type with `400`, so naming it + * here would put a switch on that page that every click refuses. */ export const SETUP_CATALOG_TYPES = ['permission', 'position'] as const; diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx index f044527623..5ac077bddf 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx @@ -16,7 +16,7 @@ * The declaration was a seven-member list under a doc comment claiming it * mirrored `PLATFORM_CAPABILITIES`. The spec grew an eighth member * (`manage_sharing`) and the list did not follow, so that capability fell - * through to `byName.get(name)?.label` — the English label the `sys_capability` + * through to `byName.get(name)?.label` — the English label the capability * registry serves — and rendered untranslated in all ten packs, beside seven * siblings that localize. Every gate stayed green, and the reason is exact: * `scripts/check-i18n-call-site-keys.mjs` reads this declaration as the @@ -70,6 +70,7 @@ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { render, screen } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; import '@testing-library/jest-dom'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { builtInLocales } from '@object-ui/i18n/locales'; @@ -176,8 +177,9 @@ const enLabels = (): Record => { }; /** - * One `sys_capability` row per declared platform capability, each carrying a - * label that is NOT the localized one. Any assertion that reads `REGISTRY …` + * One capability registry item per declared platform capability (objectui#7611: + * the picker lists the registry), each carrying a label that is NOT the + * localized one. Any assertion that reads `REGISTRY …` * back has caught the widget falling through to the registry. */ const registryRows = () => @@ -186,20 +188,28 @@ const registryRows = () => label: `REGISTRY ${c.label}`, description: c.description, scope: c.scope, - active: true, })); -const mockDataSource = (rows: unknown[]) => - ({ find: vi.fn().mockResolvedValue({ data: rows }) }) as any; +/** The console metadata store, serving `items` as the `capability` registry. */ +const metadataStore = (items: unknown[]) => + ({ + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn(async (type: string) => (type === 'capability' ? items : [])), + getItem: async () => null, + getItemsByType: () => [], + }) as unknown as MetadataContextValue; -const renderPicker = (rows: unknown[]) => +const renderPicker = (items: unknown[]) => render( - , + + + , ); describe('the curated capability set is PLATFORM_CAPABILITIES (objectui#6285)', () => { diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.test.tsx index e01512d7c1..b7e06f71a2 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.test.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.test.tsx @@ -14,11 +14,16 @@ * 2. every edit emits a JSON-**string** array of names — byte-equivalent to * the `sys_permission_set.system_permissions` textarea storage, so the * round-trip through the picker never changes the on-disk shape. + * + * And, since objectui#7611, the property ADR-0131 D3/D7 adds: the options are + * the capability REGISTRY (the console metadata store's `capability` items), + * never `sys_capability` rows, and never a merge of the two. */ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField'; describe('parseCapabilityNames', () => { @@ -55,18 +60,35 @@ const CAPS = [ { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true }, ]; -function mockDataSource(rows = CAPS) { +/** A console metadata store whose `capability` registry serves `items`. */ +function metadataStore(items: unknown[] = CAPS): MetadataContextValue & { ensureType: ReturnType } { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn().mockResolvedValue(items), + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +/** Renders `ui` inside `store` (the registry reader), or bare when `null`. */ +function renderInStore(ui: React.ReactElement, store: MetadataContextValue | null = metadataStore()) { + return render(store ? {ui} : ui); +} + +/** A data source answering `sys_capability` rows — which the picker must never ask. */ +function rowDataSource(rows: unknown[]) { return { find: vi.fn().mockResolvedValue({ data: rows }) } as any; } describe('CapabilityMultiSelectField', () => { it('renders capabilities grouped by scope with human labels', async () => { - render( + renderInStore( , ); // group headers @@ -79,12 +101,11 @@ describe('CapabilityMultiSelectField', () => { it('emits a JSON-string array when a capability is toggled on', async () => { const onChange = vi.fn(); - render( + renderInStore( , ); fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' })); @@ -98,12 +119,11 @@ describe('CapabilityMultiSelectField', () => { it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => { const onChange = vi.fn(); - render( + renderInStore( , ); // Chip labels now resolve through the i18n bundle (objectui#2600 B5), so an @@ -117,12 +137,11 @@ describe('CapabilityMultiSelectField', () => { }); it('preserves an unknown/legacy selected name not in the registry', async () => { - render( + renderInStore( , ); // The unknown name still renders as a (selected) chip so it is not dropped. @@ -131,12 +150,11 @@ describe('CapabilityMultiSelectField', () => { }); it('renders selected labels as read-only badges when readonly', async () => { - render( + renderInStore( , ); @@ -148,21 +166,93 @@ describe('CapabilityMultiSelectField', () => { // objectui#2600 B5 — curated platform caps get a localized label; package/ // admin-authored caps keep whatever label the registry served. it('localizes curated capability labels but preserves registry labels for others', async () => { - render( + renderInStore( , + metadataStore([ + // Registry sends a shorter label; the curated client map wins. + { name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org' }, + // Not a curated platform capability — its registry label is kept. + { name: 'export_data', label: 'Export Data', description: 'Export', scope: 'org' }, + ]), ); expect(await screen.findByRole('button', { name: 'Manage Organization Users' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Manage Org Users' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Export Data' })).toBeInTheDocument(); }); }); + +/** + * objectui#7611 (ADR-0131 D3/D7) — the options are the capability REGISTRY: the + * console metadata store's `capability` items, which hold the platform's + * curated capabilities beside every package-declared one. Nothing writes a + * `sys_capability` row any more, so a picker that read rows offered only the + * set's own selections. + */ +describe('CapabilityMultiSelectField — the options are the capability registry (objectui#7611)', () => { + const REGISTRY = [ + { name: 'manage_users', label: 'Manage Users', scope: 'platform', _packageId: 'com.objectstack.plugin-security' }, + { name: 'setup.access', label: 'Setup Access', scope: 'platform', _packageId: 'com.objectstack.plugin-security' }, + // Package-declared: no curated label exists for it, so its own label shows. + { name: 'showcase.export', label: 'Export Showcase Data', scope: 'org', _packageId: 'com.example.showcase' }, + ]; + + it('lists the registry items, curated and package-declared, and never asks for sys_capability rows', async () => { + const store = metadataStore(REGISTRY); + const ds = rowDataSource([{ name: 'row_only_capability', label: 'Row Only Capability', scope: 'org', active: true }]); + renderInStore( + , + store, + ); + + await waitFor(() => expect(store.ensureType).toHaveBeenCalledWith('capability')); + expect(await screen.findByRole('button', { name: 'Export Showcase Data' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Manage Users' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Setup Access' })).toBeInTheDocument(); + // One source: a row the registry does not hold is not offered, and the + // data source is never asked, even when one is handed in. + expect(screen.queryByRole('button', { name: 'Row Only Capability' })).not.toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('adds a package-declared capability by its name', async () => { + const onChange = vi.fn(); + renderInStore( + , + metadataStore(REGISTRY), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Export Showcase Data' })); + expect(JSON.parse(onChange.mock.calls[0][0])).toEqual(['setup.access', 'showcase.export']); + }); + + it('offers only the set\'s own selections, and loads nothing, when no metadata store is mounted', async () => { + const ds = rowDataSource([{ name: 'row_only_capability', label: 'Row Only Capability', scope: 'org', active: true }]); + renderInStore( + , + null, + ); + // No registry reader: the granted name stays visible and selected, nothing + // else is offered, and the picker does not sit on a loading line. + expect(await screen.findByRole('button', { name: 'legacy.custom' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByRole('button')).toHaveLength(1); + expect(screen.queryByText('Loading capabilities…')).not.toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index eb5dc93380..d970f96f65 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Badge, EmptyValue, cn } from '@object-ui/components'; -import { SchemaRendererContext } from '@object-ui/react'; -import type { DataSource, QueryParams } from '@object-ui/types'; +import { MetadataCtx } from '@object-ui/react'; import { FieldWidgetComponentProps } from './types.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -14,9 +13,27 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * (e.g. `["setup.access","studio.access"]`; `security-plugin.ts` writes * `JSON.stringify(...)` and reads `parseJson(...)`). Editing that as raw JSON is * the anti-pattern ADR-0056 removes. This widget renders the same value as a - * multi-select over the live `sys_capability` registry — grouped by scope, - * labelled, with the capability description on hover — while round-tripping the - * stored value **byte-for-byte** as a JSON string of names. + * multi-select over the capability registry — grouped by scope, labelled, with + * the capability description on hover — while round-tripping the stored value + * **byte-for-byte** as a JSON string of names. + * + * ## The options are the registry, and only the registry (objectui#7611) + * + * ADR-0131 D3 gives capabilities one home, the environment registry, and D7 + * says a picker LISTS that registry. The registry is `GET /api/v1/meta/capability`: + * the platform's curated capabilities (objectstack registers + * `PLATFORM_CAPABILITIES` as `capability` items at boot) beside every + * package-declared one. It is read through the console's metadata store + * (`MetadataCtx` `ensureType`), the reader `RecipientPickerField` uses for the + * `position` registry, so the list is fetched once per session and shared with + * every other registry consumer. + * + * The picker read `sys_capability` rows until then. Nothing writes such a row + * any more, so on a fresh database it offered only the set's own selections. + * ⛔ There is no second source merged in: no `sys_capability` read, and no + * data-source fallback when the store is absent. The one union kept is with + * the set's own selections (see `byName`), so a granted name the registry + * does not serve is still shown and never dropped. * * ## Not a registry field widget (objectui#3308) * @@ -32,12 +49,18 @@ import { useFieldTranslation } from './useFieldTranslation.js'; * which is the defined behavior for an unregistered widget. */ +/** + * The metadata type whose items are the options. A capability declaration + * carries `name`, `label`, `description` and `scope` (`@objectstack/spec`'s + * `CapabilityDeclarationSchema`), which is all this picker reads. + */ +const CAPABILITY_REGISTRY_TYPE = 'capability'; + interface Capability { name: string; label?: string; description?: string; scope?: string; - active?: boolean; } /** @@ -67,7 +90,7 @@ export function parseCapabilityNames(value: unknown): string[] { /** * Scope → group header order (platform powers first). The visible header is * localized via `capability.group.` (objectui#2600 B5) — the capability - * *labels themselves* still come from the sys_capability registry, whose + * *labels themselves* still come from the capability registry, whose * per-locale localization is tracked separately (framework). */ const SCOPE_ORDER = ['platform', 'org', 'other'] as const; @@ -75,7 +98,7 @@ const SCOPE_ORDER = ['platform', 'org', 'other'] as const; /** * objectui#2600 B5 — the curated platform capabilities whose labels this picker * localizes client-side via `capability.label.`; package- and - * admin-authored capabilities keep their authored `sys_capability` label. + * admin-authored capabilities keep the label their registry declaration carries. * * ## This IS `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`, and the * ## equality is CHECKED rather than claimed (objectui#6285) @@ -83,7 +106,7 @@ const SCOPE_ORDER = ['platform', 'org', 'other'] as const; * The names below are the spec's, with the dot spellings written as underscores * (see the transform note under the declaration). It used to say that in prose * and nothing held it: the spec grew `manage_sharing`, this list did not follow, - * and that capability fell through to the English label the `sys_capability` + * and that capability fell through to the English label the capability * registry serves — untranslated in all ten packs, beside seven siblings that * localize, with every gate green. * @@ -153,44 +176,55 @@ export function CapabilityMultiSelectField({ ...props }: FieldWidgetComponentProps) { const { t } = useFieldTranslation(); - const ctx = React.useContext(SchemaRendererContext); - // The context leg needs no cast since objectui#7912 typed the seam; the - // `props` leg still does, because `FieldWidgetProps.dataSource` is declared - // `unknown` in this package. - const dataSource: DataSource | null = - (props.dataSource as DataSource | null | undefined) ?? ctx?.dataSource ?? null; const disabled = props.disabled; const [caps, setCaps] = React.useState(null); const selected = React.useMemo(() => parseCapabilityNames(value), [value]); - // Load the live capability registry (active only). Mirrors LookupField's - // context-dataSource access: no registration path forwards a `dataSource` - // prop to a field widget, so the SchemaRenderer context is the reliable - // source. (The absent forwarder named here used to be the docs-demo - // `createFieldRenderer`, removed in objectui#3910 — the conclusion is - // unchanged and now holds for the only remaining path.) + // The registry read (objectui#7611): the `capability` items of the console's + // metadata store. Read raw rather than through `useMetadata()`, whose + // no-provider fallback answers every type with `[]` — the same reading + // `RecipientPickerField` takes. Without a store there is no registry reader, + // so the options are the set's own selections and nothing loads. + // + // Keyed on whether a store is mounted, never on the store: its context value + // is rebuilt whenever any type it caches settles, and AGENTS.md #10 forbids + // an effect that keys on that identity, so the store is read through a ref. + const metadataStore = React.useContext(MetadataCtx); + const metadataStoreRef = React.useRef(metadataStore); + metadataStoreRef.current = metadataStore; + const hasMetadataStore = metadataStore !== null; React.useEffect(() => { - if (!dataSource || typeof (dataSource as any).find !== 'function') return; + const store = metadataStoreRef.current; + if (!store) { + setCaps([]); + return; + } let cancelled = false; - (async () => { - try { - const res = await dataSource.find('sys_capability', { - $filter: { active: true }, - $top: 500, - } as QueryParams); - const rows: Capability[] = - (res as any)?.data ?? (res as any)?.records ?? (Array.isArray(res) ? res : []); - if (!cancelled) setCaps(Array.isArray(rows) ? rows : []); - } catch { + store + .ensureType(CAPABILITY_REGISTRY_TYPE) + .then((items) => { + if (cancelled) return; + const list: Capability[] = []; + for (const it of Array.isArray(items) ? items : []) { + if (typeof it?.name !== 'string' || !it.name) continue; + list.push({ + name: it.name, + label: typeof it.label === 'string' ? it.label : undefined, + description: typeof it.description === 'string' ? it.description : undefined, + scope: typeof it.scope === 'string' ? it.scope : undefined, + }); + } + setCaps(list); + }) + .catch(() => { if (!cancelled) setCaps([]); - } - })(); + }); return () => { cancelled = true; }; - }, [dataSource]); + }, [hasMetadataStore]); // Emit as a JSON string so the value round-trips byte-equivalent to the // `system_permissions` textarea's `JSON.stringify(string[])` storage. @@ -199,9 +233,9 @@ export function CapabilityMultiSelectField({ [onChange], ); - // Union of the fetched registry and any *selected* names not present in it - // (unknown / legacy / package-owned-inactive), so no current grant is hidden - // or dropped. Unknown names land in the "other" group. + // Union of the registry and any *selected* names it does not serve (unknown, + // legacy, or declared by a package no longer installed), so no current grant + // is hidden or dropped. Unknown names land in the "other" group. const byName = React.useMemo(() => { const map = new Map(); for (const c of caps ?? []) if (c?.name) map.set(c.name, c); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 6ae3fa6aa9..a3dcff7a95 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -13,7 +13,7 @@ const ar = { notRestored: 'قبل الخادم الطلب لكنه لم يستعد جلسة المسؤول — ما زلت تنتحل هوية {{user}}. سجّل الخروج ثم الدخول مرة أخرى لإنهائها.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "إدارة المستخدمين", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5b867452d6..beeb9b1647 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -13,7 +13,7 @@ const de = { notRestored: 'Der Server hat die Anfrage angenommen, aber Ihre Administratorsitzung nicht wiederhergestellt — Sie handeln weiterhin als {{user}}. Melden Sie sich ab und erneut an, um dies zu beenden.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "Benutzer verwalten", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4370eb5bb0..3582b0981b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -17,7 +17,7 @@ const en = { }, }, // objectui#2600 B5 — capability picker scope group headers (labels come from - // the sys_capability registry; only these group titles are UI strings). + // the capability registry; only these group titles are UI strings). capability: { group: { platform: 'Platform', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 0953746d83..179337bcc2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -13,7 +13,7 @@ const es = { notRestored: 'El servidor aceptó la solicitud pero no restauró su sesión de administrador: sigue suplantando a {{user}}. Cierre sesión y vuelva a iniciarla para terminar.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "Gestionar usuarios", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3b0242723e..492e852394 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -13,7 +13,7 @@ const fr = { notRestored: 'Le serveur a accepté la demande mais n’a pas restauré votre session administrateur — vous incarnez toujours {{user}}. Déconnectez-vous puis reconnectez-vous pour y mettre fin.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "Gérer les utilisateurs", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f6c1ab4cf2..1054a5ee8e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -13,7 +13,7 @@ const ja = { notRestored: 'サーバーはリクエストを受け付けましたが、管理者セッションを復元しませんでした。まだ {{user}} として操作しています。サインアウトして再度サインインして終了してください。', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "ユーザー管理", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 790ec5fb3c..4b04f65277 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -13,7 +13,7 @@ const ko = { notRestored: '서버가 요청을 받았지만 관리자 세션을 복원하지 않았습니다. 여전히 {{user}} 계정을 대행하고 있습니다. 로그아웃 후 다시 로그인하여 종료하세요.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "사용자 관리", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 1ae02ffd3c..1515208fff 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -13,7 +13,7 @@ const pt = { notRestored: 'O servidor aceitou a solicitação, mas não restaurou sua sessão de administrador — você ainda está personificando {{user}}. Saia e entre novamente para encerrar.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "Gerenciar usuários", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index bb5491c5f3..3cd70a0f86 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -13,7 +13,7 @@ const ru = { notRestored: 'Сервер принял запрос, но не восстановил сеанс администратора — вы всё ещё работаете от имени {{user}}. Выйдите и войдите снова, чтобы завершить.', }, }, - // objectui#2600 B5 — capability picker scope group headers (labels come from the sys_capability registry). + // objectui#2600 B5 — capability picker scope group headers (labels come from the capability registry). capability: { label: { manage_users: "Управление пользователями", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 65d9a78aca..81266bc350 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -17,7 +17,7 @@ const zh = { }, }, // objectui#2600 B5 — 能力选择器的作用域分组标题(能力标签本身来自 - // sys_capability 注册表,这里只本地化分组标题这类 UI 字符串)。 + // capability 注册表,这里只本地化分组标题这类 UI 字符串)。 capability: { group: { platform: '平台',