Skip to content

Commit ca426cb

Browse files
feat(formula): current_user.hasCapability(name) — a page predicate can ask whether the viewer holds a system capability (#24478)
Fixes #24414 Clause-②: yes (widening) Dev session `session_01AQxxbsMRiqgkrsU3Xd54yE`, dispatched by the `domain:engine` seat 1 PM (`session_01JtSQQ3EWsbNPzyYMug1tXy`, claim 6259485251). Owes one `CONTRACT_REVIEW_TIER` review of record before the queue. ## What changed - **`packages/formula` standard library: `current_user.hasCapability(name)`**, registered beside `can`, receiver-only (a bare `hasCapability(name)` names no subject and does not resolve, exactly as a bare `can` does). It answers from the acting subject's held system capabilities: the `systemPermissions` list `GET /api/v1/auth/me/permissions` returns, which is the list the action gate checks `requiredPermissions` against (ADR-0066 D4). - held: `true`; not held: `false`; empty list: `false` (a real answer). - no list on the context: **throws** (`ok: false`, `kind: 'runtime'`), the way `can()` does. Also throws on a non-list, a non-string or empty name, and a receiver that is not the acting subject. - a name no action declares is just a string. No registry lookup. - `can(object, verb)` with an out-of-vocabulary verb still throws; its message now also names `current_user.hasCapability('NAME')` for a capability question. No new error code. - **The subject contract.** `EvalContext` gains `systemPermissions?: EvalSystemPermissions` (a list of names, the published field verbatim), and `PermissionBinding` gains the same field (optional, so existing constructors still compile). `celEngine.evaluate` pins it into the binding beside `permissions`. Like `permissions`, it is not mounted as a CEL variable. - **The save door's lint.** No code change was needed: `validateVisibilityPredicates`' unknown-function arm asks formula's `firstUnknownFunctionCall`, whose oracle is the evaluation environment itself (ruling on #13594, never a name list). Registering the function admits it. Pinned on a page component `visibleWhen`, beside a control (`hasCapabilities`, still refused as `visibility-predicate-unknown-function`). - **Docs.** The page component `visibleWhen` describe (`packages/spec/src/ui/page.zod.ts`) names `current_user.hasCapability('NAME')` as the way to gate a component on a capability, says it reads the list the action gate reads (never a `can(...)` proxy), says a host that binds no list makes it fault (and a faulting predicate renders the component), and keeps ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. `content/docs/references/ui/page.mdx` regenerated by `check:generated --fix` (only artifact it proved stale). - **Changeset** `.changeset/24414-capability-predicate.md`: `@objectstack/formula` minor, `@objectstack/spec` patch (describe text only), carrying the `Clause-②` line. ### Where the subject contract really lives (measured, so the file surface moved) The claim's `packages/core/src/evaluator/subjectPermissions.ts` does not exist on `origin/main` (`git ls-files | grep -i subjectPerm` = 0 hits; `packages/core/src` has no `evaluator/` directory). The evaluator subject is `EvalContext` (`packages/formula/src/types.ts`) plus `PermissionBinding` (`packages/formula/src/stdlib.ts`), so the field landed there. `@objectstack/core` is untouched, so it gets no changeset. ### Server-side binders of the subject, and which supply the list today Every server-side binder that passes object permissions into `EvalContext` is in `@objectstack/objectql`: `engine.ts` `permissionResolution` feeds CEL `defaultValue`s, option `visibleWhen` gates and formula fields, and `validation/rule-validator.ts` feeds validation rules (`permissions:` on `evaluateValidationRules`). **None supplies `systemPermissions` today**, so `hasCapability` in a server-evaluated predicate throws loudly until a binder supplies it. The page surface's binder is objectui's (the objectui card the ruling filed), not this PR. ## Verification All on head `d347ecd10` (after merging `origin/main` at `ec951e29e`) unless noted. - `pnpm --filter @objectstack/formula test`: 48 files, 1344 tests passed. New file `src/capability-predicate.test.ts` pins held / not held / empty list / list absent (throws) / undeclared name is just a string / every ADR-0068 alias / off-shape list / wrong receiver / non-string name / bare call / existence oracle (with an invented-sibling control) / `validateExpression('predicate', …)` ok / the `can` message pointer. - `pnpm --filter @objectstack/lint test`: 145 files, 6867 tests passed (the new page-predicate pin included). - typecheck exit 0: `@objectstack/formula`, `@objectstack/lint`, `@objectstack/spec`, and the subject type's consumers `@objectstack/core`, `@objectstack/metadata-protocol`, `@objectstack/objectql`, `@objectstack/plugin-security`. - Reverse verification of the cross-package type (consumer reads the rebuilt `.d.ts`): a scratch file in `packages/objectql/src` with `systemPermissions: [1]` gave `TS2322: Type 'number' is not assignable to type 'string'`, the string form compiled; file removed, tree clean. - Ablation (lint pin reads formula through `dist/`): the registered signature renamed to `hasCapabilityZZ` via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `83bc57b0907c` to `463e76c9182b`), formula rebuilt, `ablation-dist-preflight` marker present in 2 built files, lint pin **red** (1 failed / 195). Restored (blob equal to HEAD, `git diff HEAD` empty), rebuilt, preflight `--absent` over 6 built files, pin green (196 passed). - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived **112** commands on `d347ecd10`; every line run, exit captured before any pipe; **112 exit 0**. `--ran` reconciliation: "112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero, every line carried its exit code). Two families first exited 3 (prerequisite) on the earlier head and were built for and re-run, not left unmeasured: `check:skill-examples` (built `@objectstack/client-react`) and `check:dual-build-cjs-loads` (full `turbo run build`). History deepened first (`--shallow-since=2026-07-13`). - Not run locally: `test:repo` (CI's `Test Core`); the repo-wide `pnpm lint` (CI). ## Acceptance notes - **The runtime page save door does not run the visibility rule.** `validateVisibilityPredicates` declares `runtimeTypes: ['view']`, so `runtimeAuthoringRulesFor('page')` is `validatePresetComparands, validateComponentProps, validateComponentTypes, validatePrintPageBlocks`; a lint-level probe of those four on a page `visibleWhen` of `totallyBogusFn(1)` drew no function-existence finding. So the door this PR admits the predicate at is the CLI one (`os validate` / `os lint` / `os build`, `commands: ALL`), which is what the pin measures. Not measured through a booted HTTP save. - `skills/objectstack-formula/SKILL.md` is not edited (it documents bare-callable catalog functions; `can` is not in it either, and `skills/**` is a Tier H surface). `hasCapability` is receiver-only, so the catalog drift pin (bare-callables) does not move. - objectui's binding of `systemPermissions` into page predicates is the objectui card the ruling filed; until it lands, a page `visibleWhen` calling `hasCapability` faults and the component renders (fail-open, stated in the describe). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AQxxbsMRiqgkrsU3Xd54yE --- _Generated by [Claude Code](https://claude.ai/code/session_01AQxxbsMRiqgkrsU3Xd54yE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e58e0a commit ca426cb

9 files changed

Lines changed: 313 additions & 17 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(formula): `current_user.hasCapability(name)` — a page predicate can ask whether the viewer holds a system capability, from the same list the action gate reads
7+
8+
Clause-②: yes (widening)
9+
10+
- **The predicate.** The CEL standard library registers `hasCapability` beside `can`, receiver-only, so the one spelling is `current_user.hasCapability('<name>')` (the `user` / `ctx.user` / `os.user` aliases work too). It answers `true` when the name is in the acting subject's held system capabilities and `false` when it is not. A name no action declares is just a string: there is no registry lookup.
11+
- **The data.** `EvalContext` gains `systemPermissions` (type `EvalSystemPermissions`, a list of capability names): the `systemPermissions` array of the published `/auth/me/permissions` response, unchanged — the list an action's `requiredPermissions` is checked against (ADR-0066 D4). `PermissionBinding` gains the same field, optional. Pass it beside `permissions`:
12+
13+
```ts
14+
celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions });
15+
```
16+
17+
- **Loud, like `can`.** With no `systemPermissions` on the context the call fails (`ok: false`, `kind: 'runtime'`) rather than answering; an empty list is a real answer and evaluates to `false`. A non-list, a non-string or empty name, or a receiver that is not the acting subject fails the same way. A bare `hasCapability(name)` does not resolve.
18+
- **The authoring gate.** The visibility-predicate rule's function-existence check reads the evaluation environment, so a page `visibleWhen` calling `current_user.hasCapability(…)` now passes `os validate`, `os lint` and `os build`, where before it was refused as an unknown function (`visibility-predicate-unknown-function`). An invented sibling (`hasCapabilities`) is still refused.
19+
- **`can` points at it.** `current_user.can(object, verb)` with a verb outside the object-permission vocabulary still fails, and its message now names `current_user.hasCapability('<name>')` for a capability question.
20+
- **Docs.** The page component `visibleWhen` describe names `current_user.hasCapability('<name>')` as the way to gate a component on a capability: `visibleWhen` hides, `requiredPermissions` refuses.
21+
- **Hosts.** A host that evaluates page predicates supplies the list for the predicate to answer; one that does not makes it fail, and a failing page `visibleWhen` renders the component.

‎content/docs/references/ui/page.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ View filter rule
311311
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
312312
| **className** | `string` | optional | CSS class names |
313313
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
314-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
314+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('<name>')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('<object>', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
315315
| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. |
316316
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
317317
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -483,7 +483,7 @@ Sort field and direction pair
483483
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
484484
| **className** | `string` | optional | CSS class names |
485485
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
486-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
486+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. To gate a component on a system capability, write `current_user.hasCapability('<name>')` with the name the gated action's `requiredPermissions` declares — it answers from the viewer's `systemPermissions`, the list the action gate reads, never a proxy such as `current_user.can('<object>', 'edit')`; a host that binds no capability list makes it fault, and a faulting predicate renders the component. ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` refuses. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
487487
| **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. |
488488
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
489489
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { celEngine } from './cel-engine';
6+
import { firstUnknownFunctionCall } from './unknown-function';
7+
import { validateExpression } from './validate';
8+
import type { EvalContext, EvalResult } from './types';
9+
10+
/**
11+
* `current_user.hasCapability(name)` — the capability predicate (cloud#3036
12+
* ruling A: the action's declaration is the one source of truth, so a page
13+
* predicate must be able to ask the question the action gate asks).
14+
*
15+
* The property under test is the same PAIR `permission-predicate.test.ts` pins
16+
* for `can`: given the subject's capability list the predicate evaluates, and
17+
* without it the predicate fails LOUDLY — never `true`, never a silent `false`.
18+
*/
19+
20+
/** The `/auth/me/permissions` `systemPermissions` payload used across these cases. */
21+
const SYSTEM_PERMISSIONS = ['manage_users', 'view_setup'];
22+
23+
const USER = { id: 'usr_1', positions: ['sales_rep'], organizationId: 'org_1' };
24+
25+
function evaluate(source: string, ctx: EvalContext): EvalResult<boolean> {
26+
return celEngine.evaluate<boolean>({ dialect: 'cel', source }, ctx);
27+
}
28+
29+
function withCapabilities(source: string): EvalResult<boolean> {
30+
return evaluate(source, { user: USER, systemPermissions: SYSTEM_PERMISSIONS });
31+
}
32+
33+
/** A refusal is a reported `runtime` fault naming `needle` — ⛔ never a quiet `false`. */
34+
function expectLoud(result: EvalResult<boolean>, needle: string): void {
35+
expect(result.ok, 'must REFUSE, not answer').toBe(false);
36+
if (result.ok) return;
37+
expect(result.error.kind).toBe('runtime');
38+
expect(result.error.message).toContain(needle);
39+
}
40+
41+
describe('`hasCapability` answers from the subject\'s held capabilities', () => {
42+
it('answers TRUE for a held capability and FALSE for one not held', () => {
43+
// Both directions: a pin asserting only `true` stays green under an
44+
// implementation that answers `true` for everything.
45+
expect(withCapabilities("current_user.hasCapability('manage_users')")).toEqual({ ok: true, value: true });
46+
expect(withCapabilities("current_user.hasCapability('manage_billing')")).toEqual({ ok: true, value: false });
47+
});
48+
49+
it('treats a name no action declares as just a string — no registry lookup', () => {
50+
// Held ⇒ true, not held ⇒ false, whatever the platform's action catalog
51+
// says: the list is the subject's, and nothing else is consulted.
52+
const undeclared = 'zz_no_action_declares_this';
53+
expect(evaluate(`current_user.hasCapability('${undeclared}')`, {
54+
user: USER, systemPermissions: [undeclared],
55+
})).toEqual({ ok: true, value: true });
56+
expect(withCapabilities(`current_user.hasCapability('${undeclared}')`)).toEqual({ ok: true, value: false });
57+
});
58+
59+
it('answers FALSE from an EMPTY list — a real answer, not a fault', () => {
60+
expect(evaluate("current_user.hasCapability('manage_users')", { user: USER, systemPermissions: [] }))
61+
.toEqual({ ok: true, value: false });
62+
});
63+
64+
it('answers identically under every ADR-0068 alias of the acting subject', () => {
65+
for (const root of ['current_user', 'user', 'ctx.user', 'os.user']) {
66+
expect(withCapabilities(`${root}.hasCapability('manage_users')`), root).toEqual({ ok: true, value: true });
67+
}
68+
});
69+
70+
it('composes with `can` and the rest of a predicate', () => {
71+
expect(evaluate(
72+
"current_user.hasCapability('view_setup') && !current_user.hasCapability('manage_billing')",
73+
{ user: USER, systemPermissions: SYSTEM_PERMISSIONS },
74+
)).toEqual({ ok: true, value: true });
75+
});
76+
});
77+
78+
describe('`hasCapability` refuses LOUDLY', () => {
79+
it('THROWS when the context carries no capability list — never a silent false', () => {
80+
expectLoud(
81+
evaluate("current_user.hasCapability('manage_users')", { user: USER }),
82+
'carries no capability list',
83+
);
84+
});
85+
86+
it('THROWS on a capability list that is not a list of names', () => {
87+
expectLoud(
88+
evaluate("current_user.hasCapability('manage_users')", {
89+
user: USER,
90+
systemPermissions: { manage_users: true } as unknown as string[],
91+
}),
92+
'is not a list of capability names',
93+
);
94+
});
95+
96+
it('THROWS when called on anything but the acting subject', () => {
97+
expectLoud(
98+
evaluate("record.hasCapability('manage_users')", {
99+
user: USER, systemPermissions: SYSTEM_PERMISSIONS, record: { id: 'r1' },
100+
}),
101+
'must be called on it',
102+
);
103+
});
104+
105+
it('faults on an unbound `current_user` when the evaluation carries no user, even with a list', () => {
106+
// cel-js refuses the unresolved receiver before the binding is reached —
107+
// loud either way, as for `can`; this pins WHICH loud.
108+
const r = evaluate("current_user.hasCapability('manage_users')", { systemPermissions: SYSTEM_PERMISSIONS });
109+
expect(r.ok).toBe(false);
110+
expect(r.ok === false && r.error.message).toContain('current_user');
111+
});
112+
113+
it('THROWS on a non-string or empty name', () => {
114+
expectLoud(withCapabilities('current_user.hasCapability(1)'), 'must be a system capability NAME');
115+
expectLoud(withCapabilities("current_user.hasCapability('')"), 'must be a system capability NAME');
116+
});
117+
118+
it('a bare `hasCapability(name)` names no subject and does not resolve', () => {
119+
const result = withCapabilities("hasCapability('manage_users')");
120+
expect(result.ok).toBe(false);
121+
});
122+
});
123+
124+
describe('`hasCapability` exists for the publish gates', () => {
125+
it('is a registered name — the function-existence oracle reports nothing', () => {
126+
expect(firstUnknownFunctionCall("current_user.hasCapability('manage_users')")).toBeNull();
127+
// Control: an invented sibling on the same receiver IS reported, so the
128+
// null above is a reading, not a blank run.
129+
expect(firstUnknownFunctionCall("current_user.hasCapabilities('manage_users')")?.name)
130+
.toBe('hasCapabilities');
131+
});
132+
133+
it('validates clean as a boolean predicate', () => {
134+
expect(validateExpression('predicate', "current_user.hasCapability('manage_users')").ok).toBe(true);
135+
});
136+
137+
it('points an out-of-vocabulary `can` verb at the capability predicate', () => {
138+
expectLoud(
139+
evaluate("current_user.can('crm_lead', 'approve')", { user: USER, permissions: {} }),
140+
"current_user.hasCapability('<name>')",
141+
);
142+
});
143+
});

0 commit comments

Comments
 (0)