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
5 changes: 5 additions & 0 deletions .changeset/b3-positions-predicate-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

Forward the authenticated user's `positions` into the client predicate scope (`current_user.positions`) in the console shell and the record form page. Position-gated select options (`'admin' in current_user.positions`, ADR-0058 / objectui#2284) now hide client-side like they do everywhere else, instead of failing open as visible and only being rejected by the server on submit — `positions` is the actor shape the framework rule-validator actually binds and enforces. Docs, the schema-catalog role-gated example, the skills guide, and inline examples switch the role-gating spelling from `current_user.roles` (never bound server-side, so never enforced) to `current_user.positions`.
2 changes: 1 addition & 1 deletion content/docs/fields/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ cascade-clear propagate down the chain.
{ "label": "Private (only me)", "value": "private" },
{ "label": "My team", "value": "team" },
{ "label": "Whole organization", "value": "org" },
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.roles" }
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" }
]}
```

Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0058-cascading-select-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ Rationale:
its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036).
Reusing both introduces no new category and no bespoke matrix format.
2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees
`current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades
`current_user` (positions/tenant) and `now()`, so the *same* knob covers cascades
(`record.country == 'cn'`) **and** role/context gating
(`'admin' in current_user.roles`). `validFor` can only express "varies with
(`'admin' in current_user.positions`). `validFor` can only express "varies with
another field's value" — it cannot gate by role. `current_user` is already
bound on every predicate surface (server formula, RLS, client UI gates), so a
role-referencing option predicate needs **zero new binding**.
Expand Down Expand Up @@ -113,7 +113,7 @@ expression parses and the field exists.
`@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`)
closes this at authoring/CI time with three conservative checks: `syntax` (via
`@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate
`current_user.roles` reference is never flagged), `unknown-field` (a `record.<name>`
`current_user.positions` reference is never flagged), `unknown-field` (a `record.<name>`
naming no declared sibling), and `option-literal-not-in-domain` (a literal
compared against an *enum* sibling that is outside its declared value set). It
only flags what it can statically prove — non-`record.` roots, open-domain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
{
"label": "Public — external",
"value": "public",
"visibleWhen": "'admin' in current_user.roles"
"visibleWhen": "'admin' in current_user.positions"
}
]
}
Expand Down
8 changes: 7 additions & 1 deletion packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// name/email/role were forwarded → isPlatformAdmin-gated actions were
// hidden even for platform admins.
isPlatformAdmin: (user as any).isPlatformAdmin ?? false,
// Positions are what the SERVER binds as `current_user` for per-option
// `visibleWhen` authorization gating (ADR-0058; framework EvalUser —
// objectui#2284). Forwarding them lets a position-gated option
// (`'admin' in current_user.positions`) hide client-side too, instead
// of failing open as visible and only being rejected on submit.
positions: (user as any).positions ?? [],
}
: { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false };
: { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false, positions: [] };

return (
<ExpressionProvider user={expressionUser} app={activeApp} data={{}} features={features}>
Expand Down
11 changes: 9 additions & 2 deletions packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,15 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
const expressionUser = useMemo(
() =>
user
? { name: user.name, email: user.email, role: user.role ?? 'user' }
: { name: 'Anonymous', email: '', role: 'guest' },
? {
name: user.name,
email: user.email,
role: user.role ?? 'user',
// Server-parity actor shape for per-option `visibleWhen` gating
// (ADR-0058): the rule-validator binds `current_user.positions`.
positions: (user as any).positions ?? [],
}
: { name: 'Anonymous', email: '', role: 'guest', positions: [] },
[user],
);

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/evaluator/__tests__/optionLint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('lintOptionPredicates — clean predicates', () => {
type: 'select',
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
],
},
]);
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/evaluator/__tests__/optionRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,18 @@ describe('resolveVisibleOptions — cascade', () => {
describe('resolveVisibleOptions — role / context gating via scope', () => {
const options: OptionLike[] = [
{ label: 'Standard', value: 'standard' },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
];

it('hides the admin option for a non-admin', () => {
const vals = resolveVisibleOptions(options, {}, { current_user: { roles: ['sales'] } }).map(
const vals = resolveVisibleOptions(options, {}, { current_user: { positions: ['sales'] } }).map(
(o) => o.value,
);
expect(vals).toEqual(['standard']);
});

it('offers the admin option to an admin', () => {
const vals = resolveVisibleOptions(options, {}, { current_user: { roles: ['admin'] } }).map(
const vals = resolveVisibleOptions(options, {}, { current_user: { positions: ['admin'] } }).map(
(o) => o.value,
);
expect(vals).toEqual(['standard', 'admin_only']);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/evaluator/optionLint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* 1. `syntax` — the predicate is valid CEL. Delegated to the canonical
* `@objectstack/formula` `validateExpression` (no schema
* hint → pure parse validation, so a legitimate
* `current_user.roles` reference is never flagged).
* `current_user.positions` reference is never flagged).
* 2. `unknown-field` — a `record.<name>` reference names a field the form
* doesn't declare (a sibling typo, `record.contry`).
* 3. `option-literal-not-in-domain` — a `record.<enum> == '<literal>'` (or
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/evaluator/optionRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* {@link evalFieldPredicate}). This expresses both:
* - cascades — `record.country == 'cn'` (the dependent list narrows as the
* controlling field changes), and
* - role/context gating — `'admin' in current_user.roles`.
* - role/context gating — `'admin' in current_user.positions`.
*
* A field declares which sibling fields its option list reacts to via
* `dependsOn` (aligns with `@objectstack/spec` Field.dependsOn — the same knob
Expand Down
6 changes: 3 additions & 3 deletions packages/fields/src/widgets/SelectField.cascade.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ describe('SelectField — role / context gating (#2284)', () => {
type: 'select',
options: [
{ label: 'Standard', value: 'standard' },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
],
} as any;

it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
const onChange = vi.fn();
render(
<PredicateScopeProvider scope={{ current_user: { roles: ['sales'] } }}>
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
<SelectField
value={'admin_only'}
onChange={onChange}
Expand All @@ -124,7 +124,7 @@ describe('SelectField — role / context gating (#2284)', () => {
it('keeps an admin-only value for an admin', () => {
const onChange = vi.fn();
render(
<PredicateScopeProvider scope={{ current_user: { roles: ['admin'] } }}>
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
<SelectField
value={'admin_only'}
onChange={onChange}
Expand Down
2 changes: 1 addition & 1 deletion packages/fields/src/widgets/SelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { FieldWidgetProps } from './types';
* Supports cascading / role-gated options (#2284): each option may carry a
* `visibleWhen` CEL predicate, evaluated against the live form record +
* `current_user`, so the offered set narrows as a controlling field changes
* (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
* (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
* field declares which sibling fields drive its list via `dependsOn`; while any
* of those is empty the control is gated with a "select the parent first" hint,
* mirroring the dependent-lookup UX.
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/field-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export interface SelectOptionMetadata {
* evaluated against the live record + `current_user` (same env as field-level
* `visibleWhen`). Omit = always available. Drives cascading/dependent options
* (`record.country == 'cn'`) and role/context gating
* (`'admin' in current_user.roles`). Aligns with @objectstack/spec
* (`'admin' in current_user.positions`). Aligns with @objectstack/spec
* SelectOption.visibleWhen. Client-side hiding is UX only — access-control
* gating must also be enforced server-side.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export interface SelectOption {
* evaluates TRUE against the live record + `current_user` (same engine/env as
* field-level {@link FormFieldConfig.visibleWhen}). Omit = always available.
* Expresses both cascading options (`record.country == 'cn'`) and role/context
* gating (`'admin' in current_user.roles`). Aligns with @objectstack/spec
* gating (`'admin' in current_user.positions`). Aligns with @objectstack/spec
* SelectOption.visibleWhen.
*
* Client-side hiding is UX, not authorization — access-control gating must also
Expand Down
2 changes: 1 addition & 1 deletion skills/objectui/guides/schema-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ humans and AI author it correctly by pattern-matching:
// role gating — same predicate, references current_user instead of a sibling:
{ "name": "tier", "type": "select", "options": [
{ "label": "Standard", "value": "standard" },
{ "label": "Admin only", "value": "admin_only", "visibleWhen": "'admin' in current_user.roles" }
{ "label": "Admin only", "value": "admin_only", "visibleWhen": "'admin' in current_user.positions" }
]}
]}
```
Expand Down
Loading