Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/row-crud-cel-predicates-2614.md
Original file line number Diff line number Diff line change
@@ -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`.
14 changes: 14 additions & 0 deletions examples/app-showcase/src/data/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ManagedSchemaLike> = {
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<string, any> = {
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).
Expand Down
18 changes: 15 additions & 3 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,19 @@ export function foldWildcardSuperUser(objects: Record<string, any>): 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);
}

/**
Expand Down Expand Up @@ -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;
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,10 @@
"ReplicationConfigSchema (const)",
"ResolveRecordDisplayNameOptions (interface)",
"ResolvedHook (type)",
"RowCrudActionOverride (type)",
"RowCrudActionOverrideInput (type)",
"RowCrudActionOverrideSchema (const)",
"RowCrudPredicates (interface)",
"SQLDialect (type)",
"SQLDialectSchema (const)",
"SQLDriverConfig (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@
"data/ReferenceResolution",
"data/ReferenceResolutionError",
"data/ReplicationConfig",
"data/RowCrudActionOverride",
"data/SQLDialect",
"data/SQLDriverConfig",
"data/SSLConfig",
Expand Down
68 changes: 67 additions & 1 deletion packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
98 changes: 92 additions & 6 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -556,6 +556,42 @@ export const ObjectExternalBindingSchema = z.object({

export type ObjectExternalBinding = z.infer<typeof ObjectExternalBindingSchema>;

/**
* 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<typeof RowCrudActionOverrideSchema>;
export type RowCrudActionOverrideInput = z.input<typeof RowCrudActionOverrideSchema>;

const ObjectSchemaBase = z.object({
/**
* Identity & Metadata
Expand Down Expand Up @@ -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.'),

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 };
}

// =================================================================
Expand Down