Skip to content

Commit 092bd85

Browse files
os-zhuangclaude
andauthored
fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) (#2573)
* test(e2e-live): B3 cascading-options live e2e — client re-filter + server rejection (#2559 / #1583) Adds e2e/live/cascading-options.spec.ts, the live-backend counterpart to the no-backend docs e2e (e2e/cascading-options.spec.ts). Against the real console + backend it drives the showcase `showcase_cascade` create form and asserts the dual-side B3 contract: - CLIENT: the child `province`'s offered option set re-filters as the parent `country` changes, and a now-invalid child value clears (cascade-clear). - SERVER: POST /api/v1/data/showcase_cascade rejects an out-of-set option value (400 VALIDATION_FAILED / invalid_option) and accepts the in-set one — client hiding is UX, the server is the boundary. Excluded from the default/CI run (testIgnore '**/live/**'); opt-in via `pnpm test:e2e:live` with the stack up. Requires the framework showcase_cascade fixture (framework #2559). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg * fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) The console forwarded only id/name/email/role/roles/isPlatformAdmin into the expression scope, so a position-gated option predicate (`'admin' in current_user.positions` — the shape the framework rule-validator binds and enforces, EvalUser) faulted client-side and FAILED OPEN: the gated option stayed visible to everyone and was only rejected by the server on submit. Forward `positions` (auth payload already carries it) in AppContent and RecordFormPage so client hiding and the server verdict agree. Also switch every role-gating example from `current_user.roles` — a key the server never binds, so a predicate written that way is never enforced — to `current_user.positions`: schema-catalog role-gated-options.json, fields/select docs, the skills guide, ADR-0058 example spellings, TSDoc on SelectOption.visibleWhen, evaluator header comments, and the three unit tests' mock scopes. Matches @objectstack/spec's own SelectOption.visibleWhen example. Verified: optionRules/optionLint/SelectField.cascade/useExpression 51/51, schema-catalog 836/836 (incl. the option-predicate guard), eslint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eeef906 commit 092bd85

15 files changed

Lines changed: 39 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
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`.

content/docs/fields/select.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ cascade-clear propagate down the chain.
7070
{ "label": "Private (only me)", "value": "private" },
7171
{ "label": "My team", "value": "team" },
7272
{ "label": "Whole organization", "value": "org" },
73-
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.roles" }
73+
{ "label": "Public — external", "value": "public", "visibleWhen": "'admin' in current_user.positions" }
7474
]}
7575
```
7676

docs/adr/0058-cascading-select-options.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ Rationale:
3737
its cascade; `visibleWhen` is already the field-level CEL predicate (ADR-0036).
3838
Reusing both introduces no new category and no bespoke matrix format.
3939
2. **`visibleWhen` is strictly more expressive than a value cascade.** It sees
40-
`current_user` (roles/tenant) and `now()`, so the *same* knob covers cascades
40+
`current_user` (positions/tenant) and `now()`, so the *same* knob covers cascades
4141
(`record.country == 'cn'`) **and** role/context gating
42-
(`'admin' in current_user.roles`). `validFor` can only express "varies with
42+
(`'admin' in current_user.positions`). `validFor` can only express "varies with
4343
another field's value" — it cannot gate by role. `current_user` is already
4444
bound on every predicate surface (server formula, RLS, client UI gates), so a
4545
role-referencing option predicate needs **zero new binding**.
@@ -113,7 +113,7 @@ expression parses and the field exists.
113113
`@object-ui/core`'s `lintOptionPredicates(fields)` (`evaluator/optionLint.ts`)
114114
closes this at authoring/CI time with three conservative checks: `syntax` (via
115115
`@objectstack/formula`'s `validateExpression`, no schema hint so a legitimate
116-
`current_user.roles` reference is never flagged), `unknown-field` (a `record.<name>`
116+
`current_user.positions` reference is never flagged), `unknown-field` (a `record.<name>`
117117
naming no declared sibling), and `option-literal-not-in-domain` (a literal
118118
compared against an *enum* sibling that is outside its declared value set). It
119119
only flags what it can statically prove — non-`record.` roots, open-domain

examples/schema-catalog/src/schemas/fields-select/role-gated-options.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
{
2222
"label": "Public — external",
2323
"value": "public",
24-
"visibleWhen": "'admin' in current_user.roles"
24+
"visibleWhen": "'admin' in current_user.positions"
2525
}
2626
]
2727
}

packages/app-shell/src/console/AppContent.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,14 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
619619
// name/email/role were forwarded → isPlatformAdmin-gated actions were
620620
// hidden even for platform admins.
621621
isPlatformAdmin: (user as any).isPlatformAdmin ?? false,
622+
// Positions are what the SERVER binds as `current_user` for per-option
623+
// `visibleWhen` authorization gating (ADR-0058; framework EvalUser —
624+
// objectui#2284). Forwarding them lets a position-gated option
625+
// (`'admin' in current_user.positions`) hide client-side too, instead
626+
// of failing open as visible and only being rejected on submit.
627+
positions: (user as any).positions ?? [],
622628
}
623-
: { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false };
629+
: { name: 'Anonymous', email: '', role: 'guest', isPlatformAdmin: false, positions: [] };
624630

625631
return (
626632
<ExpressionProvider user={expressionUser} app={activeApp} data={{}} features={features}>

packages/app-shell/src/views/RecordFormPage.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,15 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
159159
const expressionUser = useMemo(
160160
() =>
161161
user
162-
? { name: user.name, email: user.email, role: user.role ?? 'user' }
163-
: { name: 'Anonymous', email: '', role: 'guest' },
162+
? {
163+
name: user.name,
164+
email: user.email,
165+
role: user.role ?? 'user',
166+
// Server-parity actor shape for per-option `visibleWhen` gating
167+
// (ADR-0058): the rule-validator binds `current_user.positions`.
168+
positions: (user as any).positions ?? [],
169+
}
170+
: { name: 'Anonymous', email: '', role: 'guest', positions: [] },
164171
[user],
165172
);
166173

packages/core/src/evaluator/__tests__/optionLint.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('lintOptionPredicates — clean predicates', () => {
5858
type: 'select',
5959
options: [
6060
{ label: 'Standard', value: 'standard' },
61-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
61+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
6262
],
6363
},
6464
]);

packages/core/src/evaluator/__tests__/optionRules.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,18 @@ describe('resolveVisibleOptions — cascade', () => {
8989
describe('resolveVisibleOptions — role / context gating via scope', () => {
9090
const options: OptionLike[] = [
9191
{ label: 'Standard', value: 'standard' },
92-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.roles" },
92+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
9393
];
9494

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

102102
it('offers the admin option to an admin', () => {
103-
const vals = resolveVisibleOptions(options, {}, { current_user: { roles: ['admin'] } }).map(
103+
const vals = resolveVisibleOptions(options, {}, { current_user: { positions: ['admin'] } }).map(
104104
(o) => o.value,
105105
);
106106
expect(vals).toEqual(['standard', 'admin_only']);

packages/core/src/evaluator/optionLint.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
* 1. `syntax` — the predicate is valid CEL. Delegated to the canonical
2828
* `@objectstack/formula` `validateExpression` (no schema
2929
* hint → pure parse validation, so a legitimate
30-
* `current_user.roles` reference is never flagged).
30+
* `current_user.positions` reference is never flagged).
3131
* 2. `unknown-field` — a `record.<name>` reference names a field the form
3232
* doesn't declare (a sibling typo, `record.contry`).
3333
* 3. `option-literal-not-in-domain` — a `record.<enum> == '<literal>'` (or

packages/core/src/evaluator/optionRules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* {@link evalFieldPredicate}). This expresses both:
1717
* - cascades — `record.country == 'cn'` (the dependent list narrows as the
1818
* controlling field changes), and
19-
* - role/context gating — `'admin' in current_user.roles`.
19+
* - role/context gating — `'admin' in current_user.positions`.
2020
*
2121
* A field declares which sibling fields its option list reacts to via
2222
* `dependsOn` (aligns with `@objectstack/spec` Field.dependsOn — the same knob

0 commit comments

Comments
 (0)