diff --git a/.changeset/row-crud-cel-predicates-2614.md b/.changeset/row-crud-cel-predicates-2614.md new file mode 100644 index 0000000000..8584df3a6c --- /dev/null +++ b/.changeset/row-crud-cel-predicates-2614.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-hono-server': patch +--- + +feat(spec): userActions.edit/delete accept per-record CEL predicates (objectui#2614) + +`userActions.edit` / `userActions.delete` now accept, in addition to the +plain boolean, an object form `{ enabled?, visibleWhen?, disabledWhen? }` +(`RowCrudActionOverrideSchema`) so the built-in row Edit/Delete affordances +can be hidden or disabled **per record** via CEL predicates — the same +evaluation contract custom row actions already use. `visibleWhen` false → +button not rendered (fail-closed); `disabledWhen` true → rendered disabled +(fail-soft). Advisory UI gating only; server enforcement stays with +permissions/hooks. + +`resolveCrudAffordances()` keeps returning the resolved booleans (`enabled` +falls back to the `managedBy` bucket default) and now surfaces the +predicates as `editPredicates` / `deletePredicates`. Boolean-only inputs +produce byte-identical output — zero behavior change for existing schemas. + +`clampManagedObjectWrites` (ADR-0092 D2 hint clamp) treats the object form +by its explicit `enabled` flag only: per-record predicates are not a write +grant, so managed objects stay fail-closed unless `enabled === true`. diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 36cb6df918..2b531ec904 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -46,6 +46,20 @@ export const Invoice = ObjectSchema.create({ icon: 'receipt', description: 'A customer invoice entered together with its line items.', + // Per-record row-action gating (objectui#2614) — a PAID invoice is frozen: + // its fields already lock via `readonlyWhen: record.status == 'paid'` below, + // and here the built-in row buttons follow the same truth instead of + // advertising an edit the record will refuse. The two flags demonstrate the + // two semantics: Edit stays visible but DISABLED on paid rows (the user sees + // the affordance exists and is off), while Delete is HIDDEN outright (a paid + // invoice is an accounting record; offering delete would be noise). Draft + // and sent invoices keep the full untouched menu — the predicates evaluate + // per row against `record.*` on the same CEL engine as everything else. + userActions: { + edit: { disabledWhen: P`record.status == 'paid'` }, + delete: { visibleWhen: P`record.status != 'paid'` }, + }, + fields: { name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }), // Forward record-picker config (spec lookup* props). The renderer diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index df2126d5f6..56dc78e694 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -90,6 +90,27 @@ describe('clampManagedObjectWrites', () => { expect(objects.crm_lead.allowEdit).toBe(true); }); + it('treats the #2614 object form by its enabled flag only (predicates are UI gating, not a grant)', () => { + const schemas: Record = { + sys_user: { + managedBy: 'better-auth', + userActions: { edit: { enabled: true, disabledWhen: 'record.frozen == true' } as never }, + }, + sys_account: { + managedBy: 'better-auth', + // enabled omitted → NOT an explicit opt-in; the clamp stays fail-closed. + userActions: { edit: { disabledWhen: 'record.frozen == true' } as never }, + }, + }; + const objects: Record = { + sys_user: { allowEdit: true }, + sys_account: { allowEdit: true }, + }; + clampManagedObjectWrites(objects, (n) => schemas[n]); + expect(objects.sys_user.allowEdit).toBe(true); + expect(objects.sys_account.allowEdit).toBe(false); + }); + it('fold + clamp compose to permission ∩ guard for a platform admin', () => { // As produced for a platform admin (admin_full_access '*' modifyAll) who // also holds organization_admin (explicit managed denies). diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index b6ea84ca8e..15826df4f4 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -126,7 +126,19 @@ export function foldWildcardSuperUser(objects: Record): void { /** Minimal schema shape the managed-write clamp needs. */ export interface ManagedSchemaLike { managedBy?: string; - userActions?: { create?: boolean; edit?: boolean; delete?: boolean } | null; + userActions?: { + create?: boolean; + // edit/delete accept the #2614 object form ({ enabled, visibleWhen, + // disabledWhen }); only the object-level `enabled` matters here — the + // per-record predicates are UI gating, not a permission grant. + edit?: boolean | { enabled?: boolean }; + delete?: boolean | { enabled?: boolean }; + } | null; +} + +/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */ +function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { + return v === true || (typeof v === 'object' && v !== null && v.enabled === true); } /** @@ -157,9 +169,9 @@ export function clampManagedObjectWrites( const schema = schemaOf(obj); if (schema?.managedBy !== 'better-auth') continue; const ua = schema.userActions ?? {}; - if (ua.edit !== true) acc.allowEdit = false; + if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; if (ua.create !== true) acc.allowCreate = false; - if (ua.delete !== true) acc.allowDelete = false; + if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; } } diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 6142774bfe..f9351ecbd5 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -133,6 +133,20 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'system/settings-manifest.zod.ts:visible', ], }, + { + id: 'cel-row-crud-visible', + summary: 'built-in row Edit/Delete per-record visibility (userActions.{edit,delete}.visibleWhen, objectui#2614)', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: 'console (objectui) RowActionMenu BuiltinRowActionItem + data-table DataTableBuiltinRowActionItem → useRowPredicate → @objectstack/formula celEngine (interpret); FALSE/fault hides the row button (UI gating only — write enforcement stays with permissions/hooks)', + covers: ['data/object.zod.ts:visibleWhen'], + }, + { + id: 'cel-row-crud-disabled', + summary: 'built-in row Edit/Delete per-record disabling (userActions.{edit,delete}.disabledWhen, objectui#2614)', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', + enforcement: 'console (objectui) RowActionMenu BuiltinRowActionItem + data-table DataTableBuiltinRowActionItem → useRowPredicate → @objectstack/formula celEngine (interpret); TRUE renders the button disabled, a fault leaves it enabled (server hooks are the real boundary)', + covers: ['data/object.zod.ts:disabledWhen'], + }, { id: 'cel-flow', summary: 'flow / sync / loader branching + filter predicates', diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 1d38391fa1..063694811a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -441,6 +441,10 @@ "ReplicationConfigSchema (const)", "ResolveRecordDisplayNameOptions (interface)", "ResolvedHook (type)", + "RowCrudActionOverride (type)", + "RowCrudActionOverrideInput (type)", + "RowCrudActionOverrideSchema (const)", + "RowCrudPredicates (interface)", "SQLDialect (type)", "SQLDialectSchema (const)", "SQLDriverConfig (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index e782ec1ea5..aad0c068b9 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -838,6 +838,7 @@ "data/ReferenceResolution", "data/ReferenceResolutionError", "data/ReplicationConfig", + "data/RowCrudActionOverride", "data/SQLDialect", "data/SQLDriverConfig", "data/SSLConfig", diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index a5aa8d4fba..6d1f39806e 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, resolveCrudAffordances, type ServiceObject } from './object.zod'; describe('ObjectCapabilities', () => { it('should apply default values correctly', () => { @@ -1342,3 +1342,69 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () ).toThrow(/removed from @objectstack\/spec after v15\.0/); }); }); + +describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => { + it('accepts the plain boolean form unchanged (back-compat)', () => { + const obj = ObjectSchema.parse({ + name: 'invoice', + fields: { name: { type: 'text' } }, + userActions: { edit: false, delete: true }, + }); + const aff = resolveCrudAffordances(obj); + expect(aff.edit).toBe(false); + expect(aff.delete).toBe(true); + expect(aff.editPredicates).toBeUndefined(); + expect(aff.deletePredicates).toBeUndefined(); + }); + + it('accepts the object form with CEL predicate shorthand strings', () => { + const obj = ObjectSchema.parse({ + name: 'task_version_check_item', + fields: { name: { type: 'text' } }, + userActions: { + edit: { disabledWhen: 'record.frozen == true' }, + delete: { visibleWhen: 'record.frozen != true' }, + }, + }); + // String shorthand normalizes to the canonical CEL envelope. + expect((obj.userActions?.edit as any).disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' }); + expect((obj.userActions?.delete as any).visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' }); + }); + + it('resolveCrudAffordances carries predicates through and defaults enabled from the bucket', () => { + const aff = resolveCrudAffordances({ + managedBy: 'platform', + userActions: { + edit: { disabledWhen: { dialect: 'cel', source: 'record.frozen == true' } }, + delete: { enabled: false, visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } }, + }, + } as never); + // No `enabled` on edit → platform bucket default (true) applies. + expect(aff.edit).toBe(true); + expect(aff.editPredicates?.disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' }); + expect(aff.editPredicates?.visibleWhen).toBeUndefined(); + // Explicit enabled:false wins over the bucket default; predicates still surface. + expect(aff.delete).toBe(false); + expect(aff.deletePredicates?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' }); + }); + + it('object form without predicates behaves exactly like the boolean form', () => { + const aff = resolveCrudAffordances({ + managedBy: 'config', + userActions: { edit: { enabled: true }, delete: {} }, + } as never); + expect(aff.edit).toBe(true); + expect(aff.delete).toBe(true); // config bucket default + expect(aff.editPredicates).toBeUndefined(); + expect(aff.deletePredicates).toBeUndefined(); + }); + + it('rejects unknown keys in the object form', () => { + const result = ObjectSchema.safeParse({ + name: 'invoice', + fields: { name: { type: 'text' } }, + userActions: { edit: { hideWhen: 'record.frozen == true' } }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 28e06518f5..e68320ea2e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -9,7 +9,7 @@ import { ObjectListViewSchema } from '../ui/view.zod'; /** * API Operations Enum */ -import { TemplateExpressionInputSchema } from '../shared/expression.zod'; +import { ExpressionInputSchema, TemplateExpressionInputSchema, type Expression, type ExpressionInput } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { ProtectionSchema } from '../shared/protection.zod'; @@ -556,6 +556,42 @@ export const ObjectExternalBindingSchema = z.object({ export type ObjectExternalBinding = z.infer; +/** + * Object form of a `userActions.edit` / `userActions.delete` override — + * extends the plain boolean with **per-record** CEL predicates so the + * built-in row Edit/Delete affordances can be hidden or disabled for a + * subset of rows (objectstack-ai/objectui#2614). + * + * Semantics (mirrors custom row actions' `visible` / `disabled`): + * - `enabled` — object-level on/off, same meaning as the bare boolean. + * Omitted → the `managedBy` bucket default. + * - `visibleWhen` — CEL over `record.*`; evaluates **false** → the row's + * button is not rendered. Fail-closed (a faulting + * predicate hides, and warns once). + * - `disabledWhen` — CEL over `record.*`; evaluates **true** → the row's + * button renders greyed / non-clickable. Fail-soft (a + * faulting predicate leaves the button enabled). + * + * The predicates are advisory UI gating only — server-side enforcement + * stays with permissions / hooks (e.g. `beforeUpdate` rejecting frozen + * rows). Evaluation happens on the canonical CEL engine, per row, with the + * record bound as `record.*` (and bare fields) — the same machinery custom + * actions already use, so authoring is identical. + */ +export const RowCrudActionOverrideSchema = z.object({ + enabled: z.boolean().optional().describe( + 'Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default.', + ), + visibleWhen: ExpressionInputSchema.optional().describe( + 'Per-record CEL predicate; false → hide the row button for that record. Fail-closed.', + ), + disabledWhen: ExpressionInputSchema.optional().describe( + 'Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft.', + ), +}).strict().describe('Boolean-or-predicates override for a built-in row CRUD affordance.'); +export type RowCrudActionOverride = z.infer; +export type RowCrudActionOverrideInput = z.input; + const ObjectSchemaBase = z.object({ /** * Identity & Metadata @@ -642,8 +678,12 @@ const ObjectSchemaBase = z.object({ userActions: z.object({ create: z.boolean().optional().describe('Show generic "New" button.'), import: z.boolean().optional().describe('Show CSV import wizard entry.'), - edit: z.boolean().optional().describe('Allow inline / form edit of existing rows.'), - delete: z.boolean().optional().describe('Show row-level delete + bulk delete.'), + edit: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe( + 'Allow inline / form edit of existing rows. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.', + ), + delete: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe( + 'Show row-level delete + bulk delete. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.', + ), exportCsv: z.boolean().optional().describe('Show CSV export entry.'), }).optional().describe('Per-object override of the resolved CRUD affordance matrix.'), @@ -1298,6 +1338,24 @@ export interface CrudAffordances { delete: boolean; /** CSV / clipboard export. Allowed even on append-only audit tables by default. */ exportCsv: boolean; + /** + * Per-record CEL predicates for the built-in row Edit action, present only + * when `userActions.edit` used the object form (objectui#2614). Evaluate + * per row against `record.*`; see {@link RowCrudActionOverrideSchema}. + */ + editPredicates?: RowCrudPredicates; + /** Per-record CEL predicates for the built-in row Delete action. */ + deletePredicates?: RowCrudPredicates; +} + +/** + * Per-record gating predicates carried through {@link resolveCrudAffordances}. + * Kept as authored (`string` shorthand or `{ dialect, source }` envelope) — + * consumers hand them to the canonical CEL row-predicate evaluator untouched. + */ +export interface RowCrudPredicates { + visibleWhen?: Expression | ExpressionInput; + disabledWhen?: Expression | ExpressionInput; } /** @@ -1343,13 +1401,41 @@ export function resolveCrudAffordances( const bucket = (obj?.managedBy ?? 'platform') as keyof typeof CRUD_AFFORDANCE_DEFAULTS; const base = CRUD_AFFORDANCE_DEFAULTS[bucket] ?? CRUD_AFFORDANCE_DEFAULTS.platform; const overrides = obj?.userActions ?? {}; - return { + const edit = normalizeRowCrudOverride(overrides.edit, base.edit); + const del = normalizeRowCrudOverride(overrides.delete, base.delete); + const out: CrudAffordances = { create: overrides.create ?? base.create, import: overrides.import ?? base.import, - edit: overrides.edit ?? base.edit, - delete: overrides.delete ?? base.delete, + edit: edit.enabled, + delete: del.enabled, exportCsv: overrides.exportCsv ?? base.exportCsv, }; + if (edit.predicates) out.editPredicates = edit.predicates; + if (del.predicates) out.deletePredicates = del.predicates; + return out; +} + +/** + * Collapse a `userActions.edit` / `userActions.delete` override — bare + * boolean or `{ enabled, visibleWhen, disabledWhen }` object — onto the + * bucket default. The predicates pass through as authored; `predicates` is + * only set when at least one predicate is present, so the boolean-only path + * stays byte-identical to the pre-#2614 result. + */ +function normalizeRowCrudOverride( + override: boolean | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown } | null | undefined, + base: boolean, +): { enabled: boolean; predicates?: RowCrudPredicates } { + if (override == null) return { enabled: base }; + if (typeof override === 'boolean') return { enabled: override }; + const enabled = override.enabled ?? base; + const visibleWhen = override.visibleWhen as RowCrudPredicates['visibleWhen']; + const disabledWhen = override.disabledWhen as RowCrudPredicates['disabledWhen']; + if (visibleWhen == null && disabledWhen == null) return { enabled }; + const predicates: RowCrudPredicates = {}; + if (visibleWhen != null) predicates.visibleWhen = visibleWhen; + if (disabledWhen != null) predicates.disabledWhen = disabledWhen; + return { enabled, predicates }; } // =================================================================