Skip to content

Commit b1f7a7a

Browse files
fix(lint)!: os build and the object save door refuse a select option's visibleWhen that reads an unbound ctx/os member (#22274) (#22392)
Fixes #22274 Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root 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 `record`, `previous` and the acting user. Under `ctx` and `os` it binds only their `user` member: it passes no organization and no environment. The root verdict from the sibling card #22157 accepted `ctx` and `os` as whole roots, so an option predicate reading `os.org.id`, `os.env` or `ctx.locale` passed `os build` and the object save door. At write time each one faulted and the value was admitted. - **The option verdict now judges members too.** `optionVisibleWhenRootIssue` in `packages/lint/src/validate-expressions.ts` keeps its root test. When no unbound root is read, it hands off to `optionVisibleWhenMemberIssue`, which checks each member read under `ctx` and `os` against `OPTION_VISIBLE_WHEN_BOUND_MEMBERS` (`{ ctx: ['user'], os: ['user'] }`). Any other member is refused at `error`, at the option slot, one finding per option. - **The members are read by the platform's own reader.** `@objectstack/formula`'s `analyzeRelationshipTraversals` reads the member. So `os.org`, `os.?org`, `os['org']` and `has(os.org)` count as one read. A computed key (`os[k]`) names no member and is not judged. - **The message names what IS bound.** It names the member and says that under that root the option check binds the `user` member and nothing else. Then it gives a remedy for that member: - `os.org`: compare `current_user.organizationId`. The engine builds the acting user with the caller's organization id (`null` outside one), so that fact IS bound at the option check. Measured: it evaluates there, a clean `true` admits the value and a clean `false` refuses it. - `os.env`: the option check has no environment. Gate on a column or on `current_user`. - Any other member, such as `ctx.locale`: rewrite against `record` / `previous` or `current_user`. - **One pass, two doors.** The object save door runs this same pass, so the door's finding is the build's finding. No second judge. - **The runtime is unchanged (ruling).** `evaluateOptionVisibility` binds no `org` or `env`, and its fault-open stays as it is. That behaviour is `domain:engine`'s question. - **A stale docblock is corrected.** The field-rule verdict's docblock said the option surface "binds the whole `os` namespace". That is false at the server, and this change made it contradict the new verdict. Comment only. ## The dispatch's premises, measured - **P1 held.** Through the built `@objectstack/objectql` (`evaluateValidationRules`, insert, authenticated caller `{ id, positions, organizationId }`, permissions passed), at base `bb4f5cc005`: - `os.org.id != ''` was admitted, with `predicate-fault` / `No such key: org`. - `os.env == 'prod'` was admitted, with `predicate-fault` / `No such key: env`. - `ctx.locale == 'en'` was admitted, with `predicate-fault` / `No such key: locale`. - Controls: `os.user.id != ''`, `ctx.user.id != ''`, `current_user.id != ''`, `user.id != ''`, `record.x == 'a'` and `current_user.can('fx', 'edit')` evaluated cleanly. `os.user.id == 'nobody'` was refused `VALIDATION_FAILED` (`option 'gold' is not available`), which shows the gate runs. - **P2 held: no corpus hit, so no fork.** - Corpus A: every git-tracked `*.object.ts` under `packages/**` and `examples/**`, plus the two `app-multi-package` sub-stacks. At base `bb4f5cc005` that is 112 files and 119 objects. At merged head `4e0f473df6` it is 111 files and 118 objects, because `origin/main` `117d34de3f` retired one. There were 0 import or parse failures. - Corpus B: the example stacks as `defineStack` composes them, 33 objects. - Both carry the same 5 option predicates, all on `showcase_cascade`. Their roots are `record` x4 and `current_user` x1. The members read under `os` and `ctx` are none, at base and at head. - Option findings were 0 at the build and 0 at the door, at both trees. - A tree-wide text grep for `visibleWhen` with an `os.` or `ctx.` member read found no option predicate. It found 5 lines: a lint test fixture, a page `visibleWhen`, and three spec `.describe()` strings. - **P3 held.** The allowlist mirrors three things in code: - the one call `evaluateOptionVisibility` makes, `ExpressionEngine.evaluate(expr, { record: merged, previous, user, permissions })`; - `@objectstack/formula`'s `buildScope`, which from that context mounts `ctx = { user }` and `os = { user }`, and mounts `os.org` / `os.env` only from an `org` / `env` in the context; - ADR-0068 D1's aliases (`user`, `ctx.user`, `os.user` are the same `EvalUser`). - `current_user` and `user` ARE the `EvalUser`, and its members are the same at every site, so they are not in the member map. That is also why `ctx.user.positions` is not judged here. - **How drift surfaces:** a new lint test drives the real `buildScope` and `ExpressionEngine.evaluate` with the option check's context. Every member mounted under `ctx` / `os` must be accepted and evaluate. Every member mounted only when an `org` and `env` are ALSO given must be refused and fault. The exact accepted/refused lists are pinned. So `buildScope` mounting a new member there, or dropping `user`, turns it red. - **Not caught mechanically:** a change to ObjectQL's call shape, such as the option check starting to pass `org`. `@objectstack/lint` cannot depend on ObjectQL. That direction rests on the constant's docblock rule (a member joins the list in the same change that binds it) and on ObjectQL's own `USER_SCOPE_ROOTS` docblock, which states the same exactness claim. This is named in the report. ## Pins (Zone 3) - **Build side** (`validate-expressions.test.ts`, new describe `#22274`): - `os.org.id != ''`, `os.env == 'prod'` and `ctx.locale == 'en'` are each refused at `error`, at the option slot. The message names the option, the field, the member path, and the `user` member as what is bound. - The `os.org` refusal names `current_user.organizationId`. That replacement passes the build, and it evaluates `true` in the option check's context. - CONTROL: `current_user.id`, `os.user.id`, `'org_admin' in ctx.user.positions`, `user.id`, `record.x`, `previous.x`, `current_user.can(...)`, and a `record` field spelled like a refused member (`record.locale`) all pass. - POSITIVE CONTROL: the same `os.org.id != ''` as a `formula` field's `expression`, a site whose evaluator binds `os.org` (`applyFormulaPlan`), is not refused. So the refusal belongs to the option slot, not to every slot. - Every member spelling is judged as one read: `has(os.org)`, `os.?org`, `os['org']` and `has(ctx.locale)`. - One finding per option. An unbound root wins over a member, and members are ordered by `SCOPE_ROOTS`. - The `buildScope` parity test (P3 above). - **Door side** (`protocol.runtime-authoring-gate.test.ts`, new `#22274` block, through the real `saveMetaItem`): - (a) Each of the three bodies: a publish save answers 422 `INVALID_METADATA` with one `expression-invalid` issue at the option that names the member and the bound `user` member, and nothing lands. - (b) Control: the eight accepted bodies save and land `active`. - (c) PARITY: `rule`, `where`, `path`, `message` and `hint` are equal at the door and at the build, for each body. ## Reverse verification (ablation) The run was made from the committed head `f270d45a7e` through `scripts/ablation-replace.mjs` in WRAP mode, with an outer `trap` restore on EXIT, INT and TERM against the absolute path. The restore was checked by comparing the file's blob hash with the HEAD blob. - **Mutation.** The member arm's call was gated on `Reflect.has(Object, "ablation22274")`, which is always false. The anchor went x1 to x0, and the blob went `256380b4d7c8` to `057b663b2c3a`. On disk, the anchor count was 0 and the marker count was 1. - **Prediction, recorded before the run.** - Lint: 7 red. They are the three refusals, the replacement test, the spelling test, the second half of the ordering test, and the parity test. CONTROL, POSITIVE CONTROL and every other test stay green. - Protocol: 6 red, (a) x3 and (c) x3. (b) and every other block stay green. - **Observed.** - Lint `src/validate-expressions.test.ts`: 7 failed, 360 passed, the predicted seven. - `@objectstack/lint` was then rebuilt. `ablation-dist-preflight` found the marker in 4 built files (`index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`). - Protocol: 6 failed, 120 passed, the predicted six. - **Restore.** The blob is `256380b4d7c8`, 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 367 of 367, and the protocol file to 126 of 126. ## Local verification (at `65ac7df278`, after merging `origin/main` `117d34de3f`) - The merge brought one commit that touches `@objectstack/metadata-protocol`, `metadata-core`, `metadata`, `platform-objects` and `spec`. None of this PR's files changed. After `pnpm install --frozen-lockfile` and a rebuild of the `@objectstack/metadata-protocol...` closure, both touched packages were re-run in full. - `pnpm --filter @objectstack/lint test`: 128 files, 5871 tests passed. The full `test` task, `vitest run`, is one project. - `pnpm --filter @objectstack/metadata-protocol test`: 223 files passed and 3 skipped. 28325 tests passed and 19 skipped. All the skips were there before this change. - `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` passed, and `check:test-typecheck` was OK, with `validate-expressions.test.ts` carrying no debt entry. - `pnpm --filter @objectstack/metadata-protocol typecheck`: OK. `tsc --listFiles` includes the protocol test file. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `65ac7df278` derived the same 63 commands as at claim time. - 62 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET: packages unrelated to this diff have no `dist/` in the local worktree. **NOT MEASURED: dual-build-cjs-loads, reason: prerequisite not met locally; CI builds the full tree.** - `--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. ## Grade and changeset - `.changeset/22274-option-visible-when-members.md` lists `@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. `metadata-protocol` was added in patch round 1 at `dffc512cf2`, per contract review `6072804991`: the BREAKING section names that package's doors. It has the `fix(lint)!` prefix, the Clause-② line above, a BREAKING section with the remedy, 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 constant and the two 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/22274-option-visible-when-members.md` `rule-validator.ts` was read, not edited. The diff is +412/-7 lines against the merge base `117d34de3`. ## Acceptance notes - **Deviation from the dispatch's pin list.** Zone 3 lists `ctx.user.roles` as still accepted. It IS accepted by this verdict, which stops at the first member. But measured through the built engine, `ctx.user.roles == ['a']` and `'admin' in current_user.roles` fault with `No such key: roles`: ADR-0090 D3 renamed `roles` to `positions`. Pinning it as an accepted case would endorse a gate that is never enforced, so the control pins `'org_admin' in ctx.user.positions` instead. The `EvalUser` member level is reported to the PM as the next finding in this family. - **Residual reachable paths to the fault-open, reported to the PM for the card (`domain:engine`'s question), not fixed here.** Measured: `os['o' + 'rg'].id != ''` passes the build with 0 findings, because a computed key names no member, and the built engine admits it with `predicate-fault` / `No such key: org`. Not measured: rows stored before this change, writes under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, and the `EvalUser` member level above. - **Two-step prescription, noted.** The field-rule verdict's user tier tells an author with a field-level `os.org.id` predicate to move it to an option's `visibleWhen`. There it now meets this refusal, which names `current_user.organizationId`. The author gets there in two steps, and no message is false. Carrier: none. - **Docs, noted.** The variable-scope table in `content/docs/data-modeling/formulas.mdx` lists `os.org` / `os.env` as available in "predicates". That is broader than what the option check binds. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a55fdc4 commit b1f7a7a

4 files changed

Lines changed: 414 additions & 7 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 `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274)
7+
8+
Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root 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 `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), 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 `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. 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 `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`.
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+
- For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead.
22+
- `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`.
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 `os.org` for an option, this refusal is lifted for that member in the same change.
28+
- The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`.
29+
- The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone.
30+
- A computed key such as `os[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). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, 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 ctx or os 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: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest';
55
// a copy of it: "a future `SCOPE_ROOTS` member is covered for free" is the whole
66
// argument for the allowlist, and a hand-copied list would go green on exactly
77
// the root the rule never saw.
8-
import { SCOPE_ROOTS } from '@objectstack/formula';
8+
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';
1111
import { SharingRuleSchema } from '@objectstack/spec/security';
@@ -2088,6 +2088,143 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
20882088
});
20892089
});
20902090

2091+
/**
2092+
* ── The members of `ctx` / `os` the option check never fills (#22274) ───
2093+
*
2094+
* `ctx` and `os` pass the root verdict above, but the option check fills
2095+
* each with its `user` member only: `evaluateOptionVisibility` hands the
2096+
* evaluator `{ record, previous, user, permissions }`, and `buildScope`
2097+
* mounts `os.org` / `os.env` only from an `org` / `env` in that context.
2098+
* Measured through the built `evaluateValidationRules` with an
2099+
* authenticated caller, `os.org.id`, `os.env` and `ctx.locale` each fault
2100+
* (`No such key`) and the value is admitted, so the build refuses them.
2101+
*/
2102+
describe('a per-option `visibleWhen` member of `ctx` / `os` the option check does not bind is refused (#22274)', () => {
2103+
const detail = (visibleWhen: unknown, extra: Record<string, unknown> = {}) => ({
2104+
objects: [
2105+
{
2106+
name: 'fx_line',
2107+
fields: {
2108+
x: { type: 'text' },
2109+
locale: { type: 'text' },
2110+
tier: {
2111+
type: 'select',
2112+
options: [{ label: 'Standard', value: 'standard' }, { label: 'Gold', value: 'gold', visibleWhen }],
2113+
},
2114+
...extra,
2115+
},
2116+
},
2117+
],
2118+
});
2119+
const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen";
2120+
/**
2121+
* The context the option check hands the evaluator, mirrored from its one
2122+
* call (`evaluateOptionVisibility` in ObjectQL's `rule-validator.ts`): the
2123+
* merged record, `previous`, the acting user as the engine builds it (with
2124+
* the caller's organization id) and the permission map. Nothing else.
2125+
*/
2126+
const OPTION_CHECK_CONTEXT = {
2127+
record: { x: 'a' },
2128+
previous: { x: 'a' },
2129+
user: { id: 'u1', positions: ['member'], organizationId: 'org_1' },
2130+
permissions: {},
2131+
};
2132+
2133+
it.each([
2134+
["os.org.id != ''", '`os.org`'],
2135+
["os.env == 'prod'", '`os.env`'],
2136+
["ctx.locale == 'en'", '`ctx.locale`'],
2137+
])('⭐ refuses %s at error, located at the option, naming the member and what is bound', (body, path) => {
2138+
const issues = validateStackExpressions(detail(body));
2139+
expect(issues, JSON.stringify(issues, null, 2)).toHaveLength(1);
2140+
expect(issues[0]).toMatchObject({ where: WHERE, severity: 'error', source: body });
2141+
expect(issues[0]!.message).toContain(`option 'gold' on field 'tier' reads ${path}`);
2142+
expect(issues[0]!.message).toContain('the `user` member and nothing else');
2143+
});
2144+
2145+
it('⭐ the `os.org` refusal names the bound replacement, and that replacement passes and evaluates', () => {
2146+
expect(validateStackExpressions(detail("os.org.id != ''"))[0]!.message).toContain('`current_user.organizationId`');
2147+
const body = "current_user.organizationId == 'org_1'";
2148+
expect(validateStackExpressions(detail(body))).toEqual([]);
2149+
expect(ExpressionEngine.evaluate({ dialect: 'cel', source: body }, OPTION_CHECK_CONTEXT)).toEqual({ ok: true, value: true });
2150+
});
2151+
2152+
it('⭐ CONTROL — the acting user under every ADR-0068 spelling, `record`, `previous` and `can` pass', () => {
2153+
for (const body of [
2154+
"current_user.id != ''",
2155+
"os.user.id != ''",
2156+
"'org_admin' in ctx.user.positions",
2157+
"user.id != ''",
2158+
"record.x == 'a'",
2159+
"previous.x == 'a'",
2160+
"current_user.can('fx_line', 'edit')",
2161+
// A `record` member merely spelled like a refused one.
2162+
"record.locale == 'en'",
2163+
]) {
2164+
expect(validateStackExpressions(detail(body)), body).toEqual([]);
2165+
}
2166+
});
2167+
2168+
it('⭐ POSITIVE CONTROL — the same `os.org.id` on a `formula` field, which binds `os.org`, is not refused', () => {
2169+
const atFormula = detail("record.x == 'a'", { in_org: { type: 'formula', expression: "os.org.id != ''" } });
2170+
expect(validateStackExpressions(atFormula)).toEqual([]);
2171+
});
2172+
2173+
it('judges every member spelling as one read: optional, indexed and `has()`', () => {
2174+
for (const body of ['has(os.org)', 'os.?org.orValue({}) == {}', "os['org'].id != ''", "has(ctx.locale)"]) {
2175+
const issues = validateStackExpressions(detail(body));
2176+
expect(issues, body).toHaveLength(1);
2177+
expect(issues[0]!.where, body).toBe(WHERE);
2178+
}
2179+
});
2180+
2181+
it('one finding per option: an unbound root before a member, then members in `SCOPE_ROOTS` order', () => {
2182+
const withRoot = validateStackExpressions(detail("ctx.locale == 'en' && input.k == 1"));
2183+
expect(withRoot).toHaveLength(1);
2184+
expect(withRoot[0]!.message).toContain('reads `input`');
2185+
const twoMembers = validateStackExpressions(detail("ctx.locale == 'en' && os.env == 'prod'"));
2186+
expect(twoMembers).toHaveLength(1);
2187+
expect(twoMembers[0]!.message).toContain('reads `os.env`');
2188+
});
2189+
2190+
/**
2191+
* The allowlist is derived from the code, both ways. The real
2192+
* `buildScope` and evaluator, given the option check's context: every
2193+
* member they mount under `ctx` / `os` is accepted and evaluates, and
2194+
* every member they mount there only when ALSO given an organization and
2195+
* an environment is refused and faults. A member `buildScope` starts
2196+
* mounting from the option check's context, or one the verdict accepts
2197+
* that it no longer mounts, turns this red.
2198+
*/
2199+
it('the accepted members are exactly the ones `buildScope` mounts for the option check', () => {
2200+
const optionScope = buildScope(OPTION_CHECK_CONTEXT);
2201+
const fullScope = buildScope({ ...OPTION_CHECK_CONTEXT, org: { id: 'org_1' }, env: 'prod' });
2202+
const accepted: string[] = [];
2203+
const refused: string[] = [];
2204+
for (const root of ['ctx', 'os']) {
2205+
const mounted = Object.keys(optionScope[root] as Record<string, unknown>);
2206+
for (const member of Object.keys(fullScope[root] as Record<string, unknown>)) {
2207+
const body = `${root}.${member} != null`;
2208+
const issues = validateStackExpressions(detail(body));
2209+
const evaluated = ExpressionEngine.evaluate({ dialect: 'cel', source: body }, OPTION_CHECK_CONTEXT);
2210+
if (mounted.includes(member)) {
2211+
expect(issues, body).toEqual([]);
2212+
expect(evaluated.ok, body).toBe(true);
2213+
accepted.push(body);
2214+
} else {
2215+
expect(issues, body).toHaveLength(1);
2216+
expect(evaluated.ok, body).toBe(false);
2217+
refused.push(body);
2218+
}
2219+
}
2220+
}
2221+
expect({ accepted, refused }).toEqual({
2222+
accepted: ['ctx.user != null', 'os.user != null'],
2223+
refused: ['os.org != null', 'os.env != null'],
2224+
});
2225+
});
2226+
});
2227+
20912228
it('flags a bare-field sharing-rule condition', () => {
20922229
const issues = validateStackExpressions({
20932230
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],

0 commit comments

Comments
 (0)