diff --git a/.changeset/10058-quick-actions-capability-gate.md b/.changeset/10058-quick-actions-capability-gate.md index 23c93ad6b0..7835d50aa3 100644 --- a/.changeset/10058-quick-actions-capability-gate.md +++ b/.changeset/10058-quick-actions-capability-gate.md @@ -42,3 +42,16 @@ REPORTED empty array is a real answer and gates strictly. `perms.can()` itself is untouched: no other caller moves, and the full before/after truth table of both stock providers is pinned as unchanged. + +**Correction, 2026-10-01 (objectui#10224).** The phrase "hides the whole bar" +above is wrong about what the reader sees. When a declared capability is unheld or +unrecognised, no action is drawn and an insufficient-permissions notice +(`role="status"`) renders where the bar would be. That is what the contract's +shared record-block `requiredPermissions` describe says: "this block does not +render its content; wherever it would otherwise render, an +insufficient-permissions notice takes its place". The `record:quick_actions` +registration now publishes that describe verbatim, in place of "Hide the whole +bar unless the user holds these permissions", and the contract wording quoted +in the first paragraph is retired upstream (objectstack#18159). Everything +else above stands: the capabilities the gate asks for, its fail-closed +verdict, and the fail-open when capabilities are unreported. diff --git a/.changeset/10224-quick-actions-permissions-text.md b/.changeset/10224-quick-actions-permissions-text.md new file mode 100644 index 0000000000..9efee30e3c --- /dev/null +++ b/.changeset/10224-quick-actions-permissions-text.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(plugin-detail): `record:quick_actions.requiredPermissions` publishes what the gate does — the contract's shared record-block describe, verbatim + +The `record:quick_actions` registration described `requiredPermissions` as "Hide +the whole bar unless the user holds these permissions". The renderer does not +hide the bar. It reads the ADR-0066 capability set, and when a declared +capability is missing it draws an insufficient-permissions notice where the bar +would be. When the client cannot resolve capabilities (no permission provider, +or a backend that does not report `systemPermissions`) it renders the bar. + +`@objectstack/spec` 17.5.0 gives this key one describe, shared with +`record:details`, `record:highlights` and `record:related_list`, and those three +already publish it. The quick-actions input now publishes the same text. It is +carried by `sdui.manifest.json`, and at runtime by +`ComponentRegistry.getConfig('record:quick_actions').inputs`. No input name, +type or shape changes, and no rendering or gating behaviour changes. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index a100d6f341..92b641473d 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2439,11 +2439,14 @@ const MULTI_KIND_MEMBER_CONTRACTS: Record = { // (the shape this card's own dispatch names, over an arbitrary four). Neither // key needed `NO_READ_SITE_TO_PIN`: `actionNames` drives a real // `useMetadataItem` lookup against the object's own `actions`, and -// `requiredPermissions` is a block-level gate (`required.every((p) => -// perms.can(objectName, p))`) that hides the whole bar before anything is -// drawn — a DIFFERENT mechanism from an `ActionDef`'s own per-action field of -// the same name, which a pre-existing fixture already exercised without -// touching this key at all. Measured over this file's own ledger, before and +// `requiredPermissions` is a block-level gate that puts an +// insufficient-permissions notice in place of the whole bar before any action +// is drawn. It reads the capability set (`perms.hasCapabilities(required)`) +// since objectui#10058; slice 4 found it reading `perms.can(objectName, p)` +// (sentence corrected by objectui#10224). It is a DIFFERENT mechanism from an +// `ActionDef`'s own per-action field of the same name, which a pre-existing +// fixture already exercised without touching this key at all. Measured over +// this file's own ledger, before and // after: 90 array/object-armed inputs, 40 pinned, 50 exempt -> 90, 42 pinned, // 48 exempt, and `MEMBER_PIN_EXEMPTION_CEILING` follows 50 -> 48 in the same // commit. One of the two pins PROMOTES a pre-existing file @@ -3036,7 +3039,7 @@ const MEMBER_PINS: Record = { }, 'record:quick_actions.requiredPermissions': { file: 'packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx', - pins: 'The BLOCK-LEVEL gate (`required.every((p) => perms.can(objectName, p))`) that hides the whole bar before any action is drawn — distinct from an `ActionDef`\'s own per-action `requiredPermissions`, which `record-quick-actions.declared-action-ids-7182.test.tsx`\'s `gated` fixture already covers. No key at all: the bar renders on the (empty) grant set, so the gate is provably driven by the key\'s PRESENCE. A single held permission gates as expected, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when read as `.every` over the WHOLE array rather than its first element — with the all-granted case as that row\'s own positive control. New file: no existing test drove `schema.requiredPermissions` itself, only the unrelated per-action field of the same name (objectui#8071).', + pins: 'The BLOCK-LEVEL ADR-0066 capability gate, read through `perms.hasCapabilities(required)` (objectui#10058) — distinct from an `ActionDef`\'s own per-action `requiredPermissions`, which `record-quick-actions.declared-action-ids-7182.test.tsx`\'s `gated` fixture already covers. Members are CAPABILITY names and the bar needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider`: an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the whole bar with no action drawn, a held one renders the bar, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array rather than its first element — with the all-granted case as that row\'s own positive control. Discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate, a held capability opens it with `allowRead: false`, the enum member `manage` is gated rather than resolved to the read bit, and `read` / `update` are gated despite `allowRead` / `allowEdit`), a detector pins that the object-permission path is never asked about a member, and the gate holds with no `objectName` in the record context. A client that cannot resolve capabilities fails open: the role-based `PermissionProvider`, no provider at all, and a stock provider whose backend omits `systemPermissions`. The same file pins the registration\'s description as the installed spec\'s shared record-block describe, verbatim, and as the text the three sibling record blocks publish (objectui#10224). New file at objectui#8071 slice 4: no existing test drove `schema.requiredPermissions` itself, only the unrelated per-action field of the same name.', }, 'record:related_list.actions': { file: 'packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.authoredActions-11163.test.tsx', diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 1c8caf2c6b..0bda81d748 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -876,7 +876,19 @@ ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, { // Implementing the fallback would be a behaviour expansion and needs its own // card; pinned by `recordQuickActionsInputs.actionNamesFallback.test.tsx`. { name: 'actionNames', type: 'array', of: 'string', description: 'Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder' }, - { name: 'requiredPermissions', type: 'array', of: 'string', description: 'Hide the whole bar unless the user holds these permissions' }, + // The contract's shared record-block describe, verbatim (objectstack#18159): + // `record:quick_actions` carries the one text `record:details`, + // `record:highlights` and `record:related_list` share, and objectui#8649 + // already publishes it on those three. This input used to read + // "Hide the whole bar unless the user holds these permissions", which is not + // what the renderer does: the gate reads the CAPABILITY set + // (`perms.hasCapabilities`, objectui#10058), puts a `role="status"` + // insufficient-permissions notice where the bar would be, and fails open when + // capabilities are unreported (objectui#10224). Each clause is pinned on this + // block by `record-quick-actions.requiredPermissions-gate.test.tsx`, which + // also re-reads the installed describe every run, so a spec that rewords it + // turns that file red rather than leaving this text to drift. + { name: 'requiredPermissions', type: 'array', of: 'string', description: '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.' }, // Derived from the spec's own vocabulary rather than restated — #3019. { name: 'location', type: 'enum', enum: [...ACTION_LOCATIONS], description: 'Which declared action location this bar renders' }, { name: 'align', type: 'enum', enum: ['start', 'center', 'end'] }, diff --git a/packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx index 5c38e6876e..e32925e4e9 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx @@ -16,9 +16,11 @@ * The key is an **ADR-0066 system capability set** — the one meaning the word * carries on `action`, `app`, `field` and `bulkAction` — read through the * permission context's capability path and gating **fail-closed**: an unheld - * or unrecognised capability hides the block. objectui#10058 settled that for - * `record:quick_actions`; these three carried the identical call and were - * untouched by it. + * or unrecognised capability withholds the block's content, and an + * insufficient-permissions notice (`role="status"`) renders in its place — the + * block is not hidden (wording corrected by objectui#10224). objectui#10058 + * settled the capability read for `record:quick_actions`; these three carried + * the identical call and were untouched by it. * * All three used to read `perms.can(objectName, name)`, whose second argument * is the closed object-action enum. Under the stock `/me/permissions` provider @@ -175,7 +177,7 @@ describe.each(BLOCKS)('$key — the `requiredPermissions` capability gate on MeP const noBody = () => expect(screen.queryByTestId(block.shown)).not.toBeInTheDocument(); const noRefusal = () => expect(screen.queryByText(block.refusal)).not.toBeInTheDocument(); - it('hides the WHOLE block when the declared capability is not held (REPORTED-empty capability set)', async () => { + it('puts the notice in place of the WHOLE block when the declared capability is not held (REPORTED-empty capability set)', async () => { render({bound(block, { requiredPermissions: ['crm.manage'] })}); expect(await refused()).toBeInTheDocument(); noBody(); @@ -187,7 +189,7 @@ describe.each(BLOCKS)('$key — the `requiredPermissions` capability gate on MeP noRefusal(); }); - it('an UNKNOWN capability name hides the block — unrecognised is refused, not waved through', async () => { + it('an UNKNOWN capability name gets the notice, not the block — unrecognised is refused, not waved through', async () => { render({bound(block, { requiredPermissions: ['not.a.real.capability'] })}); expect(await refused()).toBeInTheDocument(); noBody(); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx index 9bcb4f519d..185dbcdf0c 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.requiredPermissions-gate.test.tsx @@ -7,9 +7,17 @@ */ /** - * `record:quick_actions.requiredPermissions` — the BLOCK-LEVEL gate the - * published contract describes as "Hide the whole bar unless the current user - * holds every named permission on this object" (objectui#8071 slice 4). + * `record:quick_actions.requiredPermissions` — the BLOCK-LEVEL gate + * (objectui#8071 slice 4). Since objectstack#18159 the contract describes it + * with the one record-block describe this key shares with `record:details`, + * `record:highlights` and `record:related_list`: ALL the named capabilities + * must be held, and when one is missing "an insufficient-permissions notice + * takes its place". The registration publishes that describe verbatim, and the + * last block below re-reads it off the installed spec (objectui#10224). Before + * objectstack#18159 the contract said "Hide the whole bar unless the current + * user holds every named permission on this object", and the registration said + * "Hide the whole bar unless the user holds these permissions"; neither named + * the notice the renderer draws. * * ## What this file pins, and why it was rewritten (objectui#10058) * @@ -17,7 +25,8 @@ * carries on `action`, `app`, `field` and `bulkAction` (ruling batch #192 item * 5 letter B). The renderer reads it through the permission context's * capability path and gates fail-closed: an unheld or unrecognised capability - * hides the whole bar. + * puts a `role="status"` insufficient-permissions notice in place of the whole + * bar, so no action is drawn. * * It used to read `perms.can(objectName, name)`, whose second argument is the * closed object-action enum. Under the stock `/me/permissions` provider a name @@ -27,9 +36,11 @@ * the previous ones mocked `usePermissions` down to a `can` stub and therefore * pinned the defect: they passed on a gate that does not gate. * - * ⭐ Every pin below mounts a REAL stock provider and reads its real verdicts. - * A mocked `usePermissions` cannot discriminate the two reading paths — it IS - * whichever path the mock chooses to implement. + * ⭐ Every gate pin below mounts a REAL stock provider and reads its real + * verdicts. A mocked `usePermissions` cannot discriminate the two reading + * paths — it IS whichever path the mock chooses to implement. The one gate pin + * that mounts no provider does so on purpose: no provider at all is the case + * it pins. * * Not to be confused with an `ActionDef`'s OWN `requiredPermissions` — a * per-action field the `gated` fixture in @@ -41,10 +52,15 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; import { MetadataCtx, RecordContextProvider } from '@object-ui/react'; import type { MetadataContextValue } from '@object-ui/react'; import { MePermissionsProvider, PermissionProvider, usePermissions, type MePermissionsResponse } from '@object-ui/permissions'; +import { RecordQuickActionsProps } from '@objectstack/spec/ui'; import { RecordQuickActionsRenderer } from '../record-quick-actions'; +// Registers `record:quick_actions` and its three sibling record blocks, whose +// published `inputs` the last describe block reads. +import '../../index'; /** * Records what the OBJECT-PERMISSION path was asked, without replacing it. @@ -157,7 +173,7 @@ beforeEach(() => { }); describe('record:quick_actions.requiredPermissions — capability gate on MePermissionsProvider (objectui#10058)', () => { - it('hides the WHOLE bar when the declared capability is not held (REPORTED-empty capability set)', async () => { + it('puts the notice in place of the WHOLE bar when the declared capability is not held (REPORTED-empty capability set)', async () => { render({bar({ ...BY_NAME, requiredPermissions: ['crm.manage'] })}); expect(await refused()).toBeInTheDocument(); noButton(); @@ -169,7 +185,7 @@ describe('record:quick_actions.requiredPermissions — capability gate on MePerm noRefusal(); }); - it('an UNKNOWN capability name hides the bar — unrecognised is refused, not waved through', async () => { + it('an UNKNOWN capability name gets the notice, not the bar — unrecognised is refused, not waved through', async () => { render({bar({ ...BY_NAME, requiredPermissions: ['not.a.real.capability'] })}); expect(await refused()).toBeInTheDocument(); noButton(); @@ -237,6 +253,23 @@ describe('the reading path is the capability set, NOT `perms.can()` (objectui#10 noButton(); }); + // DISCRIMINATOR ④ (objectui#10224): the published describe says in as many + // words that `read` and `update` here are ordinary capability names, not the + // object's read or edit permission. They are the stock provider's MAPPED + // verbs, a different leg from ③'s unmapped `manage`: the object-action read + // answered each off its own bit, TRUE in its row, and would have drawn the bar. + it('DISCRIMINATOR ④a: `read` is a capability name — `allowRead` on the object does not open the gate', async () => { + render({bar({ ...BY_NAME, requiredPermissions: ['read'] })}); + expect(await refused()).toBeInTheDocument(); + noButton(); + }); + + it('DISCRIMINATOR ④b: `update` is a capability name — `allowEdit` on the object does not open the gate', async () => { + render({bar({ ...BY_NAME, requiredPermissions: ['update'] })}); + expect(await refused()).toBeInTheDocument(); + noButton(); + }); + it('DETECTOR: the renderer never asks the OBJECT-permission path about a capability name', async () => { render({bar({ ...BY_NAME, requiredPermissions: ['crm.manage', 'manage'] })}); expect(await refused()).toBeInTheDocument(); @@ -278,6 +311,63 @@ describe('role-based PermissionProvider — capabilities are UNREPORTED here (ob }); }); +describe('a client that cannot resolve capabilities fails OPEN — the describe\'s last clause (objectui#10224)', () => { + /** + * The published describe names two such clients: "no permission provider, + * or one that does not report `systemPermissions`". The role-based provider + * above is one of the second kind; these two rows are the other two shapes + * a real page meets. The lit control is the first block's opening row: the + * SAME stock provider reporting an EMPTY set gates the same declaration, so + * the open verdict here is driven by "unreported", not by a gate that never + * closes. + */ + it('renders with NO permission provider mounted at all', async () => { + render(bar({ ...BY_NAME, requiredPermissions: ['crm.manage'] })); + expect(await shown()).toBeInTheDocument(); + noRefusal(); + }); + + it('renders under the stock provider when the backend does not report `systemPermissions`', async () => { + render({bar({ ...BY_NAME, requiredPermissions: ['crm.manage'] })}); + expect(await shown()).toBeInTheDocument(); + noRefusal(); + }); +}); + +describe('the published description is the contract\'s shared record-block describe, verbatim (objectui#10224)', () => { + /** + * The registration's `inputs` are the published authoring surface: + * `gen-manifest.ts` serializes them into `sdui.manifest.json`, descriptions + * included. The generated JSX authoring types take only each input's name + * and value type (`generateDts` reads no `description`), so the manifest and + * the runtime registry are where this text is published. The describe is + * the text of record, read off the + * INSTALLED spec every run rather than restated, so a spec that rewords it + * turns this row red instead of leaving the manifest to drift. Every clause + * of that text is a row above: ALL capabilities required (the partial-grant + * pair), capability names rather than object actions (④), the notice in + * place of the bar, and fail-open when capabilities are unreported. + */ + const shapeOf = () => + (RecordQuickActionsProps.shape as Record) + .requiredPermissions; + const contractText = () => shapeOf()?.description ?? shapeOf()?.def?.innerType?.description; + const publishedText = (type: string) => + ComponentRegistry.getConfig(type)?.inputs?.find((i) => i.name === 'requiredPermissions')?.description; + + it('`record:quick_actions` publishes the installed describe text, verbatim', () => { + // Non-vacuity: an undefined describe would compare equal to a missing one. + expect(contractText() ?? '', 'the contract carries no describe to publish').not.toBe(''); + expect(publishedText('record:quick_actions')).toBe(contractText()); + }); + + it('the text is the one the three sibling record blocks publish — one describe, four blocks', () => { + for (const type of ['record:details', 'record:highlights', 'record:related_list']) { + expect(publishedText(type), type).toBe(publishedText('record:quick_actions')); + } + }); +}); + describe('CONTROL — `can()` answers exactly what it answered before this card (objectui#10058)', () => { /** * The ruling moves this ONE call site and no other caller: `can()`'s own diff --git a/packages/plugin-detail/src/renderers/record-quick-actions.tsx b/packages/plugin-detail/src/renderers/record-quick-actions.tsx index d5a44899ab..eff51b7b6c 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -237,8 +237,14 @@ export const RecordQuickActionsRenderer: React.FC