From 46e473fcf75e90de3882447da45bf89edc025e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:06:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(e2e-live):=20B3=20cascading-options=20?= =?UTF-8?q?live=20e2e=20=E2=80=94=20client=20re-filter=20+=20server=20reje?= =?UTF-8?q?ction=20(#2559=20/=20#1583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg --- e2e/live/cascading-options.spec.ts | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 e2e/live/cascading-options.spec.ts diff --git a/e2e/live/cascading-options.spec.ts b/e2e/live/cascading-options.spec.ts new file mode 100644 index 000000000..648d5c917 --- /dev/null +++ b/e2e/live/cascading-options.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, type Page, type Locator } from '@playwright/test'; +import { selectOption } from './helpers'; + +/** + * B3 live e2e: cascading / dependent `select` options + server-side enforcement + * (#1583 / objectui#2559). + * + * Drives the showcase `showcase_cascade` object (framework + * examples/app-showcase) — a `country` -> `province` cascade authored with + * per-option `visibleWhen` + field-level `dependsOn`, plus a role-gated `tier`. + * This is the live-backend counterpart to the no-backend docs e2e + * `e2e/cascading-options.spec.ts`: it proves the dual-side B3 contract against a + * REAL stack — + * + * 1. CLIENT (UX): opening the create form and driving the parent `country` + * re-filters the child `province`'s OFFERED set via the canonical + * @objectstack/formula engine, and clears a now-invalid child value when + * the parent changes (cascade-clear). + * 2. SERVER (boundary): the objectql rule-validator re-evaluates the SUBMITTED + * option's `visibleWhen` and REJECTS an out-of-set value (client hiding is + * UX, not a security boundary) — verified by POSTing a bad value straight + * at the data API and asserting a `VALIDATION_FAILED` / `invalid_option` + * violation, and that the matching in-set value is accepted. + * + * Like every other spec under e2e/live/, this drives the real console (:5180) + + * backend (:3000). The default/CI Playwright run ignores the e2e/live directory, + * so this is opt-in via `pnpm test:e2e:live` with the stack up (auth is handled + * by e2e/live/global-setup.ts). + */ + +const API = process.env.LIVE_API_URL || 'http://localhost:3000'; +const OBJECT = 'showcase_cascade'; + +/** + * Open a field's Radix Select in the create dialog and return the OFFERED option + * values (the `select-option-` testid suffixes), sorted, then close it. + * Only the open select's options are mounted in the portal, so a bare + * `select-option-*` query is unambiguous. + */ +async function offeredValues(page: Page, dialog: Locator, fieldName: string): Promise { + await dialog.getByTestId(`select-trigger-${fieldName}`).first().click(); + const options = page.locator('[data-testid^="select-option-"]'); + await options.first().waitFor({ state: 'visible' }); + const testids = await options.evaluateAll((els) => + els.map((el) => (el as HTMLElement).dataset.testid ?? ''), + ); + await page.keyboard.press('Escape'); + return testids.map((t) => t.replace('select-option-', '')).filter(Boolean).sort(); +} + +test('province options re-filter live as country changes, and the stale value clears', async ({ page }) => { + await page.goto(`/apps/showcase_app/${OBJECT}`); + await page.getByRole('button', { name: /^(New|新建)$/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByTestId('md-form-submit')).toBeVisible(); + await dialog.locator('input[name="name"]').fill(`Cascade ${Date.now()}`); + + // --- country = China -> the dependent province offers only Chinese provinces. --- + await selectOption(dialog, 'country', 'cn'); + await expect(dialog.getByTestId('select-trigger-province')).toBeVisible(); + expect(await offeredValues(page, dialog, 'province')).toEqual(['gd', 'zj']); + + // Choose one so we can prove the cascade-clear on the next parent change. + await selectOption(dialog, 'province', 'zj'); + await expect(dialog.getByTestId('select-trigger-province')).toContainText(/zhejiang/i); + + // --- country = United States -> the offered set flips and the stale value clears. --- + await selectOption(dialog, 'country', 'us'); + expect(await offeredValues(page, dialog, 'province')).toEqual(['ca', 'tx']); + // 'Zhejiang' is no longer offered under country=us, so the widget dropped it. + await expect(dialog.getByTestId('select-trigger-province')).not.toContainText(/zhejiang/i); +}); + +test('the server rejects an out-of-set option value, and accepts an in-set one', async ({ request }) => { + // Out-of-set: province 'zj' is visible only when country=='cn'. Submitting it + // under country=='us' must be rejected by the objectql rule-validator — + // client hiding is UX, the server is the boundary. + const bad = await request.post(`${API}/api/v1/data/${OBJECT}`, { + data: { name: `Bad ${Date.now()}`, country: 'us', province: 'zj' }, + }); + expect(bad.status(), await bad.text()).toBe(400); + const badBody = await bad.json(); + expect(badBody.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field?: string; code?: string }> = badBody.fields ?? []; + expect( + fields.some((f) => f.field === 'province' && f.code === 'invalid_option'), + `expected an invalid_option violation on province, got ${JSON.stringify(fields)}`, + ).toBeTruthy(); + + // In-set: the same value is accepted when the cascade predicate holds. + const ok = await request.post(`${API}/api/v1/data/${OBJECT}`, { + data: { name: `Good ${Date.now()}`, country: 'cn', province: 'zj' }, + }); + expect(ok.status(), await ok.text()).toBe(201); + const created = await ok.json(); + const id = created?.id ?? created?.record?.id ?? created?.data?.id; + expect(id, 'created record id').toBeTruthy(); + + // Clean up the accepted row so reruns stay idempotent. + if (id) await request.delete(`${API}/api/v1/data/${OBJECT}/${id}`); +}); From 78242db09058ca2e14bc1de6c7b60237469091a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 05:30:11 +0000 Subject: [PATCH 2/2] fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg --- .changeset/b3-positions-predicate-scope.md | 5 +++++ content/docs/fields/select.mdx | 2 +- docs/adr/0058-cascading-select-options.md | 6 +++--- .../src/schemas/fields-select/role-gated-options.json | 2 +- packages/app-shell/src/console/AppContent.tsx | 8 +++++++- packages/app-shell/src/views/RecordFormPage.tsx | 11 +++++++++-- .../core/src/evaluator/__tests__/optionLint.test.ts | 2 +- .../core/src/evaluator/__tests__/optionRules.test.ts | 6 +++--- packages/core/src/evaluator/optionLint.ts | 2 +- packages/core/src/evaluator/optionRules.ts | 2 +- .../fields/src/widgets/SelectField.cascade.test.tsx | 6 +++--- packages/fields/src/widgets/SelectField.tsx | 2 +- packages/types/src/field-types.ts | 2 +- packages/types/src/form.ts | 2 +- skills/objectui/guides/schema-expressions.md | 2 +- 15 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 .changeset/b3-positions-predicate-scope.md diff --git a/.changeset/b3-positions-predicate-scope.md b/.changeset/b3-positions-predicate-scope.md new file mode 100644 index 000000000..69a6dbc75 --- /dev/null +++ b/.changeset/b3-positions-predicate-scope.md @@ -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`. diff --git a/content/docs/fields/select.mdx b/content/docs/fields/select.mdx index c4911cfa3..94906c2a0 100644 --- a/content/docs/fields/select.mdx +++ b/content/docs/fields/select.mdx @@ -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" } ]} ``` diff --git a/docs/adr/0058-cascading-select-options.md b/docs/adr/0058-cascading-select-options.md index cd01e75d2..0ece3c35a 100644 --- a/docs/adr/0058-cascading-select-options.md +++ b/docs/adr/0058-cascading-select-options.md @@ -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**. @@ -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.` +`current_user.positions` reference is never flagged), `unknown-field` (a `record.` 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 diff --git a/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json b/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json index a5a31bf9c..26fa6f8e7 100644 --- a/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json +++ b/examples/schema-catalog/src/schemas/fields-select/role-gated-options.json @@ -21,7 +21,7 @@ { "label": "Public — external", "value": "public", - "visibleWhen": "'admin' in current_user.roles" + "visibleWhen": "'admin' in current_user.positions" } ] } diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index b9f5b42a0..ffed4b060 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -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 ( diff --git a/packages/app-shell/src/views/RecordFormPage.tsx b/packages/app-shell/src/views/RecordFormPage.tsx index ded678b5a..8d4db1dd5 100644 --- a/packages/app-shell/src/views/RecordFormPage.tsx +++ b/packages/app-shell/src/views/RecordFormPage.tsx @@ -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], ); diff --git a/packages/core/src/evaluator/__tests__/optionLint.test.ts b/packages/core/src/evaluator/__tests__/optionLint.test.ts index 2360899cb..32285b60c 100644 --- a/packages/core/src/evaluator/__tests__/optionLint.test.ts +++ b/packages/core/src/evaluator/__tests__/optionLint.test.ts @@ -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" }, ], }, ]); diff --git a/packages/core/src/evaluator/__tests__/optionRules.test.ts b/packages/core/src/evaluator/__tests__/optionRules.test.ts index 34968edfa..6824d2933 100644 --- a/packages/core/src/evaluator/__tests__/optionRules.test.ts +++ b/packages/core/src/evaluator/__tests__/optionRules.test.ts @@ -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']); diff --git a/packages/core/src/evaluator/optionLint.ts b/packages/core/src/evaluator/optionLint.ts index 58e3babbf..97bb5a197 100644 --- a/packages/core/src/evaluator/optionLint.ts +++ b/packages/core/src/evaluator/optionLint.ts @@ -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.` reference names a field the form * doesn't declare (a sibling typo, `record.contry`). * 3. `option-literal-not-in-domain` — a `record. == ''` (or diff --git a/packages/core/src/evaluator/optionRules.ts b/packages/core/src/evaluator/optionRules.ts index 6107d5c81..9b653b199 100644 --- a/packages/core/src/evaluator/optionRules.ts +++ b/packages/core/src/evaluator/optionRules.ts @@ -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 diff --git a/packages/fields/src/widgets/SelectField.cascade.test.tsx b/packages/fields/src/widgets/SelectField.cascade.test.tsx index 2da26677c..6c7656355 100644 --- a/packages/fields/src/widgets/SelectField.cascade.test.tsx +++ b/packages/fields/src/widgets/SelectField.cascade.test.tsx @@ -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( - + { it('keeps an admin-only value for an admin', () => { const onChange = vi.fn(); render( - +