Skip to content

Commit 46692c1

Browse files
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound member of the acting user (#22394) (#22427)
Fixes #22394 Clause-②: no (narrowing: a select option's `visibleWhen` that reads a member of the acting user (`EvalUser`) the option check does not bind — whether the schema does not declare it (`roles`) or declares it but the option check never sets it (`name`, `email`) — is refused at build and at the object save door) ## What changes A select option's `visibleWhen` is a gate the server enforces on write. The server's option check (`evaluateOptionVisibility` in `packages/objectql/src/validation/rule-validator.ts`) binds the acting user under four spellings: `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. All four are one `EvalUser` object. The family's first two cards refused an unbound root (#22157) and an unbound member of `ctx` / `os` (#22274). Neither judged the members of the acting user itself. So `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with 0 findings. At write time each one faulted and the value was admitted. - **The option verdict now judges the acting user's members.** `optionVisibleWhenMemberIssue` in `packages/lint/src/validate-expressions.ts` judges the first member of `current_user` / `user`, and the member one hop below `ctx.user` / `os.user`. Each is held to one allowlist. Any other member is refused at `error`, at the option slot, one finding per option. - **The allowlist is derived, never written out** (`optionVisibleWhenUserMembers`, computed on first use). It is the members that are BOTH: - **declared:** the keys of `EvalUserSchema` (`@objectstack/spec`, imported from `@objectstack/spec/identity`, an existing export); - **bound:** the keys of the object `@objectstack/formula`'s `buildScope` mounts as `current_user` for the acting user ObjectQL's `buildEvalUser` hands over (`{ id, positions, organizationId }`). - Today that is `id`, `positions`, `isPlatformAdmin` and `organizationId`. - **The members are read by the platform's own reader**, `analyzeRelationshipTraversals`, as for #22274. So `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, `ctx.user['roles']` and `ctx['user'].roles` are one read. A computed key names no member and is not judged. - **The message names what IS bound.** It names the member and the four members the acting user carries there, then gives a remedy: - `roles` / `role`: ADR-0090 D3 renamed the array `positions`, with no alias. The remedy is written in the author's own spelling, for example `'NAME' in ctx.user.positions`. - `name`, `email`: declared on `EvalUser`, but the server builds the user for this check from `id`, `positions` and `organizationId` only. Gate on a bound member or a column. - Anything else: not a member of `EvalUser`. Gate on a bound member or a column. - **One pass, two doors.** The object save door runs this same pass, so the door's finding is the build's finding. - **The runtime is unchanged (ruling).** The fault-open of `evaluateOptionVisibility`, computed keys and computed receivers stay with #22402. - **Refactor, no behaviour change:** the `ctx` / `os` refusal moved into its own helper (`optionVisibleWhenNamespaceMemberIssue`). Its message bytes are unchanged; the #22274 pins and the door parity pins pass unchanged. ## The enumeration pin: every receiver the option slot binds, and its member source This card closes the #22157 → #22274 family, so every receiver is listed with the source its members are judged against. The receivers are read off the REAL `buildScope` given the option check's context, `{ record, previous, user, permissions }`: | Receiver | Member source | Judged by | |---|---|---| | `record` | the object's declared fields | `validateExpression`'s `unknown field` check (field index) | | `previous` | the object's declared fields | the same check | | `ctx` | `buildScope`: the `user` member only | `OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (#22274) | | `os` | `buildScope`: the `user` member only | `OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (#22274) | | `current_user` | `EvalUserSchema`, as far as the option check binds it | `optionVisibleWhenUserMembers` (this PR) | | `user` | the same | the same | | `ctx.user` | the same | the same | | `os.user` | the same | the same | `permissions` mounts no receiver: it answers `current_user.can(OBJECT, VERB)` through the evaluator's environment (#18783), and `can` is a call, not a member read. The test `every receiver the option check binds judges its members against a declared source` walks the real `buildScope` output and asserts: - the receiver set equals this table, so a new receiver with no row is red; - for every receiver, an undeclared member (`RECEIVER.zz_undeclared`) is refused by the build and faults in the evaluator; - no member is mounted on the acting user without an `EvalUserSchema` declaration; - for every declared member, the build accepts it exactly when the option check mounts it, and evaluation agrees. The readings are pinned: declared `id, name, email, positions, isPlatformAdmin, organizationId`; accepted under `current_user` `id, positions, isPlatformAdmin, organizationId`. ## The dispatch's premises, measured - **P1 held.** At base `abd254508b`: - Runtime, through the built `@objectstack/objectql` (`evaluateValidationRules`, insert, authenticated caller `{ id, positions, organizationId }`, permissions passed): each of these was admitted with `predicate-fault`: - `'admin' in current_user.roles`, `ctx.user.roles == ['a']`, `'admin' in user.roles` and `'admin' in os.user.roles` (`No such key: roles`); - `current_user.role == 'admin'` (`No such key: role`); - `current_user.email == 'a@b.c'` (`No such key: email`) and `current_user.name != ''` (`No such key: name`); - `current_user['roles'] == ['a']` (`No such key: roles`). - `has(current_user.roles)` was refused on every write (a clean `false`), and `!has(current_user.roles)` was admitted on every write. - Controls evaluated cleanly: `'member' in current_user.positions`, `'member' in ctx.user.positions`, `user.id != ''`, `os.user.organizationId == 'org_1'` and `current_user.isPlatformAdmin == false`. `current_user.id == 'nobody'` and `current_user.can('fx', 'edit')` (empty permission map) were refused `VALIDATION_FAILED`, which shows the gate runs. - Build: the built `validateStackExpressions` gave 0 findings for all nine bodies. - Door: the new door block, run against the base `@objectstack/lint` build, went red exactly on (a) x3 and (c) x3, with `the save resolved — the door still accepts the option predicate`; (b) stayed green. - **P2 held, with one difference recorded.** No new export was needed: `EvalUserSchema` is already exported from `@objectstack/spec` and `@objectstack/spec/identity`. - Declared but not bound: `name` and `email`. The engine never passes them, so they fault (P1). **The option check wins: they are refused.** This is wider than the Clause-② line's wording ("the schema does not declare"); see Acceptance notes. - Bound but not declared: none. `can` is bound through the permissions source, but it is a receiver call, not a member, so it never enters the allowlist. The enumeration pin holds this set empty. - **P3 held: no corpus hit, so no fork.** At base `abd254508b`, and again on this branch's built `@objectstack/lint`: - Corpus: every git-tracked `*.object.ts` under `packages/**` and `examples/**`, plus the two `app-multi-package` sub-stacks. That is 113 files and 118 objects, with 0 import failures. - It carries 5 option predicates, all on `showcase_cascade`. Their roots are `record` x4 and `current_user` x1. The one acting-user member read is `current_user.positions`, which is bound. - Option findings were 0 at base and 0 on this branch. - Positive control: the same built verdict on this branch gives 1 finding for each of the nine P1 bodies. - A tree-wide grep for option predicates reading `roles`, `role`, `email` or `name` under a user receiver found none. The `current_user.email` hits are row-level security `using` / `check` predicates and a page `visibleWhen`, surfaces this verdict does not judge. - **P4: not touched.** The derivation reads `EvalUserSchema` and `buildScope`, never `EvaluateRulesOptions.currentUser` (`@objectstack/lint` cannot depend on ObjectQL). `packages/objectql` is not in this diff, so no cross-lane declaration is owed. - **Not caught mechanically:** `OPTION_CHECK_ACTING_USER` mirrors the key set of ObjectQL's `buildEvalUser` return value. This is the one fact the allowlist cannot read from code. Its docblock carries the rule that it changes in the same change as `buildEvalUser`, as `OPTION_VISIBLE_WHEN_BOUND_MEMBERS` does for the call shape. ## Pins - **Build side** (`validate-expressions.test.ts`, new describe `#22394`): - Seven bodies are each refused at `error`, at the option slot: `roles` under all four spellings, `current_user.role`, `current_user.email` and `ctx.user.name`. The message names the member path and the four bound members, and the evaluator faults on each body in the option check's context. - The `roles` refusal names `RECEIVER.positions` in the author's spelling. That replacement passes the build and evaluates `true`. - CONTROL: every bound member under every spelling, `RECEIVER.can(...)`, and a `record` field named `roles` all pass, and each evaluates. - POSITIVE CONTROL: the same `current_user.email` in an object action's `visible`, a surface this refusal does not judge, is not refused. - Spellings: `has()`, `.?`, `['roles']`, `ctx['user'].roles` and `has(os.user.email)` are each one finding. - Ordering: an unbound root first, then a namespace member, then `SCOPE_ROOTS` order (`os` before `current_user`), then name order. - The enumeration pin above. - The `#5017` declared-key meta-test registers five new local names (`declaredUserMembers`, `boundUserMembers`, `listedNames`, `tickedNames`, `membersRead`). Each is a `string[]` of member names, named to stay clear of metadata receivers. - **Door side** (`protocol.runtime-authoring-gate.test.ts`, new `#22394` block, through the real `saveMetaItem`): - (a) For each of the three bodies, a publish save answers 422 `INVALID_METADATA` with one `expression-invalid` issue at the option. The issue names the member and the bound members, and nothing lands. - (b) Control: six accepted bodies save and land `active`. - (c) PARITY: `rule`, `where`, `path`, `message` and `hint` are equal at the door and at the build. ## Reverse verification (ablation) The run was made from the committed head `f49bdb2fc8`. It went through `scripts/ablation-replace.mjs` (HOLD mode, `--expect 2`) inside a script with a `trap` restore on EXIT, INT and TERM against the absolute path. - **Mutation.** Both acting-user checks, `!optionVisibleWhenUserMembers().includes(m))`, were gated on `Reflect.has(Object, "ablation22394")`, which is always false. The anchor went x2 to x0 and the marker x0 to x2. The blob went `7343ff874edd` to `b85f0f7ad0d3`. - **Prediction, recorded before the run.** - Lint: 11 red. They are the 7 refusals, the `roles` remedy, the spellings, the ordering and the enumeration pin. - Protocol: 6 red, (a) x3 and (c) x3. - Everything else green, including every #22274 and #22157 pin. - **Observed.** - Lint `src/validate-expressions.test.ts`: 11 failed and 369 passed, the predicted eleven. - `@objectstack/lint` was then rebuilt. `ablation-dist-preflight` found the marker in 4 built files (`index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`). - Protocol `src/protocol.runtime-authoring-gate.test.ts`: 6 failed and 127 passed, the predicted six. - **Restore.** The blob is back at `7343ff874edd`, equal to HEAD, and `git diff HEAD` is empty. After a rebuild, `--absent` found the marker gone from all 20 built files and the whole tree clean. Lint went back to 380 of 380, and the protocol file to 133 of 133. ## Local verification Measured at `f49bdb2fc8`, the head before merging `origin/main`. - **The `origin/main` merge** (`1d8b879dd5`) brought 3 commits. They touch `packages/runtime`, `packages/verify`, `packages/qa/dogfood`, docs, a changeset, and five `packages/spec/src/**/*.test.ts` files (test titles only). - None of them is in this diff's packages or its build closure (`@objectstack/lint` → `formula`, `spec`, `sdui-parser`; `@objectstack/metadata-protocol`). - The spec side moved only in test files, which no build or generator reads. So the suites below were not re-run after the merge. This narrowing is declared; CI runs the full set. - `pnpm --filter @objectstack/lint test`: 128 files and 5894 tests passed. - `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` passed, and `check:test-typecheck` was OK. `tsc -p tsconfig.test.json --listFiles` includes `validate-expressions.test.ts`. - `pnpm --filter @objectstack/metadata-protocol test`: 223 files passed and 3 skipped. 28332 tests passed and 19 skipped. The skips were there before this change. - `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. `tsc --listFiles` includes the protocol test file. - Gates, at the merged head `1d8b879dd5`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived the same 63 commands as at claim time (`abd254508b`). 62 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET: packages unrelated to this diff have no `dist/` locally. **NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree.** - In its place, the one package whose bundle changed was loaded directly. A `require` of `@objectstack/lint`'s `dist/index.cjs` and `dist/runtime.cjs` succeeded, so the new `@objectstack/spec/identity` import resolves under CJS, and the CJS `validateStackExpressions` gave the refusal (1 finding). - `--ran`, with exit codes recorded: 63 derived, 62 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. - ESLint, narrowed to the 3 changed `.ts` files with `--no-inline-config --format json`: 3 files, 0 errors, 0 warnings. - The population was read from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, minus `NEVER_LINTED` and the `packages/spec/**` ignores. - That config enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any file it does not touch. - The repo-wide `pnpm lint` is CI's. - **Example apps, declared narrowing.** The four example apps build with `objectstack build`, which runs this verdict. They were not built locally. Their objects are in the P3 corpus above, with 0 option findings on this branch's built `@objectstack/lint`. CI builds them. ## Grade and changeset - `.changeset/22394-option-visible-when-evaluser-members.md` lists `@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`, as for #22274, since the BREAKING section names that package's doors. - It has the `fix(lint)!` prefix, the Clause-② line above, a BREAKING section with the remedy (`roles` → `positions`), and the ADR-0087 disposition `not-required (no-migration-prescription)`. - `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing]`. `check-changeset-no-major` and `check-empty-changeset` are green. - No export or signature moves. The new constants and helpers are module-private. ## File surface All four files are inside the claim's surface: - `packages/lint/src/validate-expressions.ts` - `packages/lint/src/validate-expressions.test.ts` - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` - `.changeset/22394-option-visible-when-evaluser-members.md` `rule-validator.ts` and `engine.ts` were read, not edited. The diff is +631/-49 lines against `origin/main`. ## Acceptance notes - **Declared-but-unbound members are refused too.** The Clause-② line (copied from the claim) says the refusal covers members "the schema does not declare". Following the dispatch's P2 (the allowlist is what is both declared AND bound), this PR also refuses `name` and `email`. `EvalUserSchema` declares them, but the option check never sets them, and P1 measured both faulting open. The changeset's BREAKING list names them. If the seat wants the Clause-② line to say so, the wording is the seat's to amend. - **Residual reachable paths to the fault-open, not this card's (carrier: #22402).** A computed key (`current_user[k]`, `os['o' + 'rg']`), a computed receiver (`[os].all(o, o.org.id != '')`), rows stored before this change, and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` writes are not judged statically. The fault-open itself is #22402's question. - **The `record` / `previous` row of the enumeration has a spelling gap, reported to the seat as its own finding.** Their member source is the `unknown field` check in `@objectstack/formula`'s `validateExpression`, and that check reads `record.FIELD` / `previous.FIELD` with a regex. So `record['zz_typo'] == 'a'` and `previous['zz_typo'] == 'a'` give 0 findings at the build. Through the built engine, the option check then faults (`No such key: zz_typo`) and admits the value. The same spelling also passes a field `requiredWhen` and a validation `condition` at the build. The fix belongs in formula's check, which covers every record-scoped slot, not in this option-only verdict. The enumeration pin probes the dot spelling, which the check does see. - **`EvaluateRulesOptions.currentUser`** (`rule-validator.ts`) still types `roles?`. It does not feed this allowlist, so it is untouched here. Carrier: #22402, which works in that file. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9222dc commit 46692c1

4 files changed

Lines changed: 631 additions & 49 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394)
7+
8+
Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door)
9+
10+
A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted.
11+
12+
The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`.
17+
- An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
18+
19+
**Remedy.**
20+
21+
- `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`).
22+
- `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`).
23+
- Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
24+
25+
**Unchanged.**
26+
27+
- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds.
28+
- The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone.
29+
- A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted.
30+
- A computed key such as `current_user[name]` names no member, so it is not judged.
31+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged.
32+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
33+
- Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it.
34+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build` and at the object save door of a select option's visibleWhen predicate that reads a member of the acting user the server's option check does not bind: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose option predicate is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate against what the option check binds, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
88
import { SCOPE_ROOTS, buildScope, ExpressionEngine } from '@objectstack/formula';
99
import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
1010
import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
11+
import { EvalUserSchema } from '@objectstack/spec/identity';
1112
import { SharingRuleSchema } from '@objectstack/spec/security';
1213
// [#15137] The published refusal sentence a `value`-slot finding must lead
1314
// with — asserted from the spec's own export, never re-spelled in a test.
@@ -2225,6 +2226,219 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
22252226
});
22262227
});
22272228

2229+
/**
2230+
* ── The acting user's own members (#22394) — the family's closing card ───
2231+
*
2232+
* The option check binds the acting user under four spellings
2233+
* (`current_user`, `user`, `ctx.user`, `os.user`), one `EvalUser` object.
2234+
* Its members are held to what is BOTH declared by `EvalUserSchema`
2235+
* (`@objectstack/spec`) AND bound by the option check: ObjectQL builds the
2236+
* acting user from the caller's id, positions and organization id, and
2237+
* `buildScope` normalises it (deriving `isPlatformAdmin`). `roles` —
2238+
* renamed `positions` by ADR-0090 D3 — is declared nowhere; `name` and
2239+
* `email` are declared but never set there. Measured through the built
2240+
* `evaluateValidationRules` with an authenticated caller before this
2241+
* verdict existed, each faulted (`No such key`) and the value was admitted,
2242+
* with 0 findings at the build; so the build refuses them.
2243+
*/
2244+
describe('a per-option `visibleWhen` member of the acting user the option check does not bind is refused (#22394)', () => {
2245+
const detail = (visibleWhen: unknown) => ({
2246+
objects: [
2247+
{
2248+
name: 'fx_line',
2249+
fields: {
2250+
x: { type: 'text' },
2251+
// A `record` field spelled like a refused member of the acting user.
2252+
roles: { type: 'text' },
2253+
tier: {
2254+
type: 'select',
2255+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
2256+
},
2257+
},
2258+
},
2259+
],
2260+
});
2261+
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
2262+
/**
2263+
* The context the option check hands the evaluator, mirrored from its one
2264+
* call (`evaluateOptionVisibility` in ObjectQL's `rule-validator.ts`): the
2265+
* merged record, `previous`, the acting user exactly as ObjectQL's
2266+
* `buildEvalUser` builds it (`{ id, positions, organizationId }`) and the
2267+
* permission map. Nothing else.
2268+
*/
2269+
const OPTION_CHECK_CONTEXT = {
2270+
record: { x: 'a', roles: 'r' },
2271+
previous: { x: 'a', roles: 'r' },
2272+
user: { id: 'u1', positions: ['member'], organizationId: 'org_1' },
2273+
permissions: {},
2274+
};
2275+
const cel = (source: string) => ({ dialect: 'cel' as const, source });
2276+
/** The four spellings of the acting user (ADR-0068 D1). */
2277+
const USER_RECEIVERS = ['current_user', 'user', 'ctx.user', 'os.user'];
2278+
/** What the acting user carries at the option check — the members the refusal names as bound. */
2279+
const BOUND = ['id', 'positions', 'isPlatformAdmin', 'organizationId'];
2280+
2281+
it.each([
2282+
["'admin' in current_user.roles", '`current_user.roles`'],
2283+
["ctx.user.roles == ['a']", '`ctx.user.roles`'],
2284+
["'admin' in user.roles", '`user.roles`'],
2285+
["'admin' in os.user.roles", '`os.user.roles`'],
2286+
["current_user.role == 'admin'", '`current_user.role`'],
2287+
["current_user.email == 'a@b.c'", '`current_user.email`'],
2288+
["ctx.user.name != ''", '`ctx.user.name`'],
2289+
])('⭐ refuses %s at error, located at the option, naming the member and the members that are bound', (body, path) => {
2290+
const issues = validateStackExpressions(detail(body));
2291+
expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1);
2292+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: body });
2293+
expect(issues[0]!.message).toContain(`option 'gold' on field 'tier' reads ${path}`);
2294+
for (const m of BOUND) expect(issues[0]!.message, m).toContain('`' + m + '`');
2295+
// …and the server's option check really cannot evaluate it.
2296+
expect(ExpressionEngine.evaluate(cel(body), OPTION_CHECK_CONTEXT).ok, body).toBe(false);
2297+
});
2298+
2299+
it('⭐ the `roles` refusal names `positions` in the spelling the author wrote, and that replacement passes and evaluates', () => {
2300+
for (const receiver of USER_RECEIVERS) {
2301+
const body = `'member' in ${receiver}.roles`;
2302+
expect(validateStackExpressions(detail(body))[0]!.message, body).toContain('`' + receiver + '.positions`');
2303+
const fixed = `'member' in ${receiver}.positions`;
2304+
expect(validateStackExpressions(detail(fixed)), fixed).toEqual([]);
2305+
expect(ExpressionEngine.evaluate(cel(fixed), OPTION_CHECK_CONTEXT), fixed).toEqual({ ok: true, value: true });
2306+
}
2307+
});
2308+
2309+
it('⭐ CONTROL — every bound member under every spelling, a grant check, and a `record` field named like a refused member pass', () => {
2310+
for (const receiver of USER_RECEIVERS) {
2311+
for (const body of [
2312+
`${receiver}.id != ''`,
2313+
`'member' in ${receiver}.positions`,
2314+
`${receiver}.isPlatformAdmin == false`,
2315+
`${receiver}.organizationId == 'org_1'`,
2316+
`${receiver}.can('fx_line', 'edit') || true`,
2317+
]) {
2318+
expect(validateStackExpressions(detail(body)), body).toEqual([]);
2319+
expect(ExpressionEngine.evaluate(cel(body), OPTION_CHECK_CONTEXT), body).toEqual({ ok: true, value: true });
2320+
}
2321+
}
2322+
expect(validateStackExpressions(detail("record.roles == 'r' && previous.roles == 'r'"))).toEqual([]);
2323+
});
2324+
2325+
it('⭐ POSITIVE CONTROL — the same `current_user.email` read in an object action predicate, a surface the refusal does not judge, is not refused', () => {
2326+
const stack = detail("record.x == 'a'");
2327+
(stack.objects[0] as Record<string, unknown>).actions = [
2328+
{ name: 'mark', label: 'Mark', type: 'script', visible: "current_user.email == 'a@b.c'" },
2329+
];
2330+
expect(validateStackExpressions(stack)).toEqual([]);
2331+
});
2332+
2333+
it('judges every member spelling as one read: optional, indexed and `has()`', () => {
2334+
for (const body of [
2335+
'has(current_user.roles)',
2336+
'current_user.?roles.orValue([]) == []',
2337+
"current_user['roles'] == []",
2338+
"ctx.user['roles'] == []",
2339+
"ctx['user'].roles == []",
2340+
'has(os.user.email)',
2341+
]) {
2342+
const issues = validateStackExpressions(detail(body));
2343+
expect(issues, body).toHaveLength(1);
2344+
expect(issues[0]!.where, body).toBe(WHERE);
2345+
}
2346+
});
2347+
2348+
it('one finding per option: an unbound root, then a namespace member, then members in `SCOPE_ROOTS` and name order', () => {
2349+
const withRoot = validateStackExpressions(detail("'a' in current_user.roles && input.k == 1"));
2350+
expect(withRoot).toHaveLength(1);
2351+
expect(withRoot[0]!.message).toContain('reads `input`');
2352+
const namespaceFirst = validateStackExpressions(detail("ctx.locale == 'en' && ctx.user.roles == []"));
2353+
expect(namespaceFirst).toHaveLength(1);
2354+
expect(namespaceFirst[0]!.message).toContain('reads `ctx.locale`');
2355+
// `os` precedes `current_user` in `SCOPE_ROOTS`.
2356+
const byRoot = validateStackExpressions(detail("'a' in current_user.roles && os.user.email == ''"));
2357+
expect(byRoot).toHaveLength(1);
2358+
expect(byRoot[0]!.message).toContain('reads `os.user.email`');
2359+
const byName = validateStackExpressions(detail("current_user.roles == [] && current_user.email == ''"));
2360+
expect(byName).toHaveLength(1);
2361+
expect(byName[0]!.message).toContain('reads `current_user.email`');
2362+
});
2363+
2364+
/**
2365+
* ⭐ THE ENUMERATION PIN — this card closes the #22157 → #22274 family.
2366+
*
2367+
* Every receiver the option check binds, read off the REAL `buildScope`
2368+
* given the option check's context, with the source its members are
2369+
* judged against:
2370+
*
2371+
* receiver member source
2372+
* record, previous the object's declared fields (`unknown field`)
2373+
* ctx, os `buildScope`: the `user` member only (#22274)
2374+
* current_user, user, `EvalUserSchema`, as far as the option
2375+
* ctx.user, os.user check binds it (#22394)
2376+
*
2377+
* `permissions` mounts no receiver: it answers `can()` through the
2378+
* environment. Red when `buildScope` mounts a receiver for the option
2379+
* check that has no row here; when any receiver's undeclared member
2380+
* passes the build or evaluates; when the acting user carries a member
2381+
* `EvalUserSchema` does not declare; or when a declared member's verdict
2382+
* disagrees with whether the option check binds it.
2383+
*/
2384+
it('every receiver the option check binds judges its members against a declared source', () => {
2385+
const SOURCES: Record<string, 'object fields' | 'buildScope namespace' | 'EvalUserSchema'> = {
2386+
record: 'object fields',
2387+
previous: 'object fields',
2388+
ctx: 'buildScope namespace',
2389+
os: 'buildScope namespace',
2390+
current_user: 'EvalUserSchema',
2391+
user: 'EvalUserSchema',
2392+
'ctx.user': 'EvalUserSchema',
2393+
'os.user': 'EvalUserSchema',
2394+
};
2395+
const optionScope = buildScope(OPTION_CHECK_CONTEXT);
2396+
const receivers = new Map<string, Record<string, unknown>>();
2397+
const walk = (prefix: string, value: Record<string, unknown>): void => {
2398+
for (const [key, child] of Object.entries(value)) {
2399+
if (child === null || typeof child !== 'object' || Array.isArray(child)) continue;
2400+
const path = prefix ? `${prefix}.${key}` : key;
2401+
receivers.set(path, child as Record<string, unknown>);
2402+
walk(path, child as Record<string, unknown>);
2403+
}
2404+
};
2405+
walk('', optionScope);
2406+
expect([...receivers.keys()].sort()).toEqual(Object.keys(SOURCES).sort());
2407+
2408+
const declaredUser = Object.keys(EvalUserSchema.shape);
2409+
const accepted: string[] = [];
2410+
for (const [path, value] of receivers) {
2411+
const probe = `${path}.zz_undeclared != null`;
2412+
expect(validateStackExpressions(detail(probe)), probe).toHaveLength(1);
2413+
expect(ExpressionEngine.evaluate(cel(probe), OPTION_CHECK_CONTEXT).ok, probe).toBe(false);
2414+
const mounted = Object.keys(value);
2415+
if (SOURCES[path] !== 'EvalUserSchema') {
2416+
for (const m of mounted) expect(validateStackExpressions(detail(`${path}.${m} != null`)), `${path}.${m}`).toEqual([]);
2417+
continue;
2418+
}
2419+
// No member is mounted on the acting user without a declaration.
2420+
expect(mounted.filter((m) => !declaredUser.includes(m)), path).toEqual([]);
2421+
for (const m of declaredUser) {
2422+
const body = `${path}.${m} != null`;
2423+
const issues = validateStackExpressions(detail(body));
2424+
const evaluated = ExpressionEngine.evaluate(cel(body), OPTION_CHECK_CONTEXT);
2425+
if (mounted.includes(m)) {
2426+
expect(issues, body).toEqual([]);
2427+
expect(evaluated.ok, body).toBe(true);
2428+
accepted.push(body);
2429+
} else {
2430+
expect(issues, body).toHaveLength(1);
2431+
expect(evaluated.ok, body).toBe(false);
2432+
}
2433+
}
2434+
}
2435+
// The readings, pinned: what the acting user is declared to carry, and
2436+
// what it carries at the option check.
2437+
expect(declaredUser).toEqual(['id', 'name', 'email', 'positions', 'isPlatformAdmin', 'organizationId']);
2438+
expect(accepted.filter((b) => b.startsWith('current_user.'))).toEqual(BOUND.map((m) => `current_user.${m} != null`));
2439+
});
2440+
});
2441+
22282442
it('flags a bare-field sharing-rule condition', () => {
22292443
const issues = validateStackExpressions({
22302444
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],
@@ -3253,6 +3467,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
32533467
// `grammar` excuse #19938 added here left with the import it excused:
32543468
// this file no longer imports `'./flow-template-grammar.js'`.)
32553469
'templateRefusal',
3470+
// [#22394] The acting user's member allowlist, derived in this file from
3471+
// `EvalUserSchema` and `buildScope`, and the message helpers around it.
3472+
// Every one is a `string[]` of member NAMES whose keys are Array methods,
3473+
// never metadata keys — each named so no metadata receiver hides behind it.
3474+
'declaredUserMembers', 'boundUserMembers', 'listedNames', 'tickedNames', 'membersRead',
32563475
]);
32573476
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
32583477
});

0 commit comments

Comments
 (0)