Skip to content
Draft
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
52 changes: 52 additions & 0 deletions .changeset/useractions-create-import-predicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-hono-server": patch
---

feat(spec): `userActions.create` / `.import` accept the same CEL-predicate object form as `edit` / `delete` (#7692)

#3076 (objectui#2614) gave `userActions.edit` and `userActions.delete` a
boolean-or-predicates union so the built-in row affordances could be gated on
record state. `create` and `import` were left as bare booleans, and there was no
other lever for them — which means a child object's related-list `[+ New]` button
could not be gated on the parent record's state at all, while the row `Edit` /
`Delete` beside it could. On a frozen parent the row actions correctly grey out
and `[+ New]` still renders; the server-side guard rejects the insert, so this is
an affordance leak rather than a data-integrity hole, but it is one an app has no
way to close.

Both keys now take the union `edit` / `delete` already carry — the **same**
`RowCrudActionOverrideSchema`, not a new dialect:

```ts
userActions: {
create: { visibleWhen: 'record.version_status == "draft"' },
import: { enabled: true, disabledWhen: 'record.frozen == true' },
}
```

`enabled` keeps the bare boolean's meaning (omitted → the `managedBy` bucket
default), `visibleWhen` is fail-closed and `disabledWhen` fail-soft, exactly as
for the row pair. `resolveCrudAffordances` carries the predicates through as
`createPredicates` / `importPredicates`, alongside the existing
`editPredicates` / `deletePredicates`.

**What `record.*` binds to differs between the two positions, and the schema says
so rather than implying symmetry it does not have.** `edit` / `delete` evaluate
per row against that row's own record. `create` / `import` gate a record that does
not exist yet, so they evaluate once per toolbar against the record in scope where
the toolbar renders — the host (parent) record on a record page's related list,
and nothing at all on a standalone object list, where a predicate reading
`record.*` therefore hides the button under the fail-closed rule.

Back-compatible: the boolean forms parse and resolve exactly as before, and the
boolean-only path still produces no predicate keys. Unknown keys inside the object
form are rejected, same as for `edit` / `delete`.

`@objectstack/plugin-hono-server` tracks the widened producer: the `/me/permissions`
managed-write clamp tested `create` with a bare `!== true`, which would have clamped
away a legitimate `create: { enabled: true, visibleWhen: … }` opt-in; it now reads
`create` through the same opt-in helper as `edit` / `delete`.

The renderer half — the related-list toolbar honouring `create.visibleWhen` — is
objectui's downstream card and is not part of this change.
8 changes: 4 additions & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const result = ApiMethod.parse(data);
| **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) |
| **managedBy** | `Enum<'platform' \| 'config' \| 'system-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. |
| **ownership** | `Enum<'user' \| 'business_unit' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id plus owning_business_unit_id) \| business_unit (unit-owned: owning_business_unit_id only, no owner_id) \| org \| none (no per-record owner, neither anchor). Distinct from the package own/extend contribution kind. |
| **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| object; delete?: boolean \| object; … }` | optional | Per-object override of the resolved CRUD affordance matrix. |
| **userActions** | `{ create?: boolean \| object; import?: boolean \| object; edit?: boolean \| object; delete?: boolean \| object; … }` | optional | Per-object override of the resolved CRUD affordance matrix. |
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
Expand Down Expand Up @@ -293,15 +293,15 @@ Type: `string[]`

## RowCrudActionOverride

Boolean-or-predicates override for a built-in row CRUD affordance.
Boolean-or-predicates override for a built-in CRUD affordance.

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | optional | Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default. |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-record CEL predicate; false → hide the row button for that record. Fail-closed. |
| **disabledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft. |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); false → hide the button. Fail-closed. |
| **disabledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); true → render the button disabled. Fail-soft. |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,11 @@ interface ResolvedPermissionSetLike {
export interface ManagedSchemaLike {
managedBy?: string;
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.
// create/edit/delete all accept the object form
// ({ enabled, visibleWhen, disabledWhen }) — #2614 gave it to the row
// pair, #7692 to `create`. Only the object-level `enabled` matters
// here: the predicates are UI gating, not a permission grant.
create?: boolean | { enabled?: boolean };
edit?: boolean | { enabled?: boolean };
delete?: boolean | { enabled?: boolean };
} | null;
Expand Down Expand Up @@ -441,7 +442,10 @@ export function clampManagedObjectWrites(
if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue;
const ua = schema.userActions ?? {};
if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false;
if (ua.create !== true) acc.allowCreate = false;
// `create` reads through the same opt-in helper as edit/delete since
// #7692 widened it to the object form — a bare `ua.create !== true`
// would clamp away a legitimate `{ enabled: true, visibleWhen: … }`.
if (!isWriteOptedIn(ua.create)) acc.allowCreate = false;
if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,44 @@ describe('clampManagedObjectWrites', () => {
expect(objects.sys_account.allowEdit).toBe(false);
});

/**
* #7692 widened `userActions.create` to the same boolean-or-object union, so
* this clamp had to stop testing it with a bare `create !== true`. Without
* that change an object opting the create affordance IN through the object
* form would be clamped OFF here — a silent permission-hint tightening that
* `edit`/`delete` beside it do not suffer. Read `create` the same way, with
* the same fail-closed rule when `enabled` is omitted.
*/
it('treats the #7692 create object form by its enabled flag only, like edit/delete', () => {
const schemas: Record<string, ManagedSchemaLike> = {
sys_user: {
managedBy: 'better-auth',
userActions: { create: { enabled: true, visibleWhen: 'record.status == "draft"' } as never },
},
sys_account: {
managedBy: 'better-auth',
// enabled omitted → NOT an explicit opt-in; the clamp stays fail-closed.
userActions: { create: { visibleWhen: 'record.status == "draft"' } as never },
},
};
const objects: Record<string, any> = {
sys_user: { allowCreate: true },
sys_account: { allowCreate: true },
};
clampManagedObjectWrites(objects, (n) => schemas[n]);
expect(objects.sys_user.allowCreate).toBe(true);
expect(objects.sys_account.allowCreate).toBe(false);
// The bare boolean form is untouched by the widening.
const boolObjects: Record<string, any> = { sys_user: { allowCreate: true }, sys_member: { allowCreate: true } };
clampManagedObjectWrites(boolObjects, (n) => (
n === 'sys_user'
? { managedBy: 'better-auth', userActions: { create: true } }
: { managedBy: 'better-auth' }
));
expect(boolObjects.sys_user.allowCreate).toBe(true);
expect(boolObjects.sys_member.allowCreate).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
110 changes: 110 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,116 @@ describe('userActions row predicates + resolveCrudAffordances (objectui#2614)',
});
});

// #7692 completes the symmetry #3076/objectui#2614 left half-done: `create`
// and `import` were boolean-only, so a related-list `[+ New]` could not be
// gated on the host record's state while the row Edit/Delete beside it could.
// Same union, same schema piece — only the binding differs (per toolbar rather
// than per row), which the schema's docblock states rather than inventing a
// second dialect.
describe('userActions.create / .import toolbar predicates (#7692)', () => {
it('keeps parsing the plain boolean form for create and import (back-compat)', () => {
const obj = ObjectSchema.parse({
name: 'invoice',
fields: { name: { type: 'text' } },
userActions: { create: false, import: true },
});
expect(obj.userActions?.create).toBe(false);
expect(obj.userActions?.import).toBe(true);
const aff = resolveCrudAffordances(obj);
expect(aff.create).toBe(false);
expect(aff.import).toBe(true);
// Boolean-only path stays byte-identical to the pre-#7692 result: no
// predicate keys appear at all.
expect(aff.createPredicates).toBeUndefined();
expect(aff.importPredicates).toBeUndefined();
});

it('accepts the object form with a visibleWhen CEL shorthand on create', () => {
const obj = ObjectSchema.parse({
name: 'task_version_check_item',
fields: { name: { type: 'text' }, version_status: { type: 'text' } },
userActions: { create: { visibleWhen: 'record.version_status == "draft"' } },
});
// String shorthand normalizes to the canonical CEL envelope, exactly as it
// does for edit/delete — no new dialect.
expect((obj.userActions?.create as any).visibleWhen)
.toEqual({ dialect: 'cel', source: 'record.version_status == "draft"' });
});

it('accepts the object form on import too, with the same keys', () => {
const obj = ObjectSchema.parse({
name: 'task_position',
fields: { name: { type: 'text' } },
userActions: { import: { enabled: true, disabledWhen: 'record.frozen == true' } },
});
expect((obj.userActions?.import as any).enabled).toBe(true);
expect((obj.userActions?.import as any).disabledWhen)
.toEqual({ dialect: 'cel', source: 'record.frozen == true' });
});

it('resolveCrudAffordances carries the predicates through and defaults enabled from the bucket', () => {
const aff = resolveCrudAffordances({
managedBy: 'platform',
userActions: {
create: { visibleWhen: { dialect: 'cel', source: 'record.version_status == "draft"' } },
import: { enabled: false, disabledWhen: { dialect: 'cel', source: 'record.frozen == true' } },
},
} as never);
// No `enabled` on create → platform bucket default (true) applies.
expect(aff.create).toBe(true);
expect(aff.createPredicates?.visibleWhen)
.toEqual({ dialect: 'cel', source: 'record.version_status == "draft"' });
expect(aff.createPredicates?.disabledWhen).toBeUndefined();
// Explicit enabled:false wins over the bucket default; predicates still surface.
expect(aff.import).toBe(false);
expect(aff.importPredicates?.disabledWhen)
.toEqual({ dialect: 'cel', source: 'record.frozen == true' });
});

it('object form without predicates behaves exactly like the boolean form', () => {
const aff = resolveCrudAffordances({
managedBy: 'config',
userActions: { create: { enabled: false }, import: {} },
} as never);
expect(aff.create).toBe(false);
expect(aff.import).toBe(false); // config bucket default: import is opt-in
expect(aff.createPredicates).toBeUndefined();
expect(aff.importPredicates).toBeUndefined();
});

it('the object form still respects the import opt-in on a non-platform bucket', () => {
// #4671: `import` is opt-IN outside `platform`. Widening the key must not
// turn the object form into a back door around that — `{}` inherits the
// bucket default (false above), and only an explicit enabled:true grants it.
const aff = resolveCrudAffordances({
managedBy: 'system-data',
userActions: { import: { enabled: true, visibleWhen: 'record.frozen != true' } },
} as never);
expect(aff.import).toBe(true);
});

it('rejects unknown keys inside the create/import object form', () => {
for (const key of ['create', 'import'] as const) {
const result = ObjectSchema.safeParse({
name: 'invoice',
fields: { name: { type: 'text' } },
userActions: { [key]: { hideWhen: 'record.frozen == true' } },
});
expect(result.success).toBe(false);
expect(JSON.stringify(result.error?.issues)).toContain('hideWhen');
}
});

it('rejects a non-predicate value where the union accepts neither arm', () => {
const result = ObjectSchema.safeParse({
name: 'invoice',
fields: { name: { type: 'text' } },
userActions: { create: 'record.frozen != true' },
});
expect(result.success).toBe(false);
});
});

// ADR-0100: a `password` field on a generic (non-better-auth) object is masked
// on read but plaintext at rest — not hashed. create() warns (non-fatally) to
// steer authors toward `secret` or the auth subsystem. The warning is deduped
Expand Down
Loading
Loading