Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/24414-capability-predicate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/formula': minor
'@objectstack/spec': patch
---

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

Clause-②: yes (widening)

- **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.
- **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`:

```ts
celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions });
```

- **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.
- **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.
- **`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.
- **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.
- **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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ View filter rule
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **className** | `string` | optional | CSS class names |
| **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) |
| **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 != ''" |
| **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 != ''" |
| **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`. |
| **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 |
| **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. |
Expand Down Expand Up @@ -483,7 +483,7 @@ Sort field and direction pair
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **className** | `string` | optional | CSS class names |
| **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) |
| **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 != ''" |
| **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 != ''" |
| **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`. |
| **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 |
| **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. |
Expand Down
143 changes: 143 additions & 0 deletions packages/formula/src/capability-predicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { celEngine } from './cel-engine';
import { firstUnknownFunctionCall } from './unknown-function';
import { validateExpression } from './validate';
import type { EvalContext, EvalResult } from './types';

/**
* `current_user.hasCapability(name)` — the capability predicate (cloud#3036
* ruling A: the action's declaration is the one source of truth, so a page
* predicate must be able to ask the question the action gate asks).
*
* The property under test is the same PAIR `permission-predicate.test.ts` pins
* for `can`: given the subject's capability list the predicate evaluates, and
* without it the predicate fails LOUDLY — never `true`, never a silent `false`.
*/

/** The `/auth/me/permissions` `systemPermissions` payload used across these cases. */
const SYSTEM_PERMISSIONS = ['manage_users', 'view_setup'];

const USER = { id: 'usr_1', positions: ['sales_rep'], organizationId: 'org_1' };

function evaluate(source: string, ctx: EvalContext): EvalResult<boolean> {
return celEngine.evaluate<boolean>({ dialect: 'cel', source }, ctx);
}

function withCapabilities(source: string): EvalResult<boolean> {
return evaluate(source, { user: USER, systemPermissions: SYSTEM_PERMISSIONS });
}

/** A refusal is a reported `runtime` fault naming `needle` — ⛔ never a quiet `false`. */
function expectLoud(result: EvalResult<boolean>, needle: string): void {
expect(result.ok, 'must REFUSE, not answer').toBe(false);
if (result.ok) return;
expect(result.error.kind).toBe('runtime');
expect(result.error.message).toContain(needle);
}

describe('`hasCapability` answers from the subject\'s held capabilities', () => {
it('answers TRUE for a held capability and FALSE for one not held', () => {
// Both directions: a pin asserting only `true` stays green under an
// implementation that answers `true` for everything.
expect(withCapabilities("current_user.hasCapability('manage_users')")).toEqual({ ok: true, value: true });
expect(withCapabilities("current_user.hasCapability('manage_billing')")).toEqual({ ok: true, value: false });
});

it('treats a name no action declares as just a string — no registry lookup', () => {
// Held ⇒ true, not held ⇒ false, whatever the platform's action catalog
// says: the list is the subject's, and nothing else is consulted.
const undeclared = 'zz_no_action_declares_this';
expect(evaluate(`current_user.hasCapability('${undeclared}')`, {
user: USER, systemPermissions: [undeclared],
})).toEqual({ ok: true, value: true });
expect(withCapabilities(`current_user.hasCapability('${undeclared}')`)).toEqual({ ok: true, value: false });
});

it('answers FALSE from an EMPTY list — a real answer, not a fault', () => {
expect(evaluate("current_user.hasCapability('manage_users')", { user: USER, systemPermissions: [] }))
.toEqual({ ok: true, value: false });
});

it('answers identically under every ADR-0068 alias of the acting subject', () => {
for (const root of ['current_user', 'user', 'ctx.user', 'os.user']) {
expect(withCapabilities(`${root}.hasCapability('manage_users')`), root).toEqual({ ok: true, value: true });
}
});

it('composes with `can` and the rest of a predicate', () => {
expect(evaluate(
"current_user.hasCapability('view_setup') && !current_user.hasCapability('manage_billing')",
{ user: USER, systemPermissions: SYSTEM_PERMISSIONS },
)).toEqual({ ok: true, value: true });
});
});

describe('`hasCapability` refuses LOUDLY', () => {
it('THROWS when the context carries no capability list — never a silent false', () => {
expectLoud(
evaluate("current_user.hasCapability('manage_users')", { user: USER }),
'carries no capability list',
);
});

it('THROWS on a capability list that is not a list of names', () => {
expectLoud(
evaluate("current_user.hasCapability('manage_users')", {
user: USER,
systemPermissions: { manage_users: true } as unknown as string[],
}),
'is not a list of capability names',
);
});

it('THROWS when called on anything but the acting subject', () => {
expectLoud(
evaluate("record.hasCapability('manage_users')", {
user: USER, systemPermissions: SYSTEM_PERMISSIONS, record: { id: 'r1' },
}),
'must be called on it',
);
});

it('faults on an unbound `current_user` when the evaluation carries no user, even with a list', () => {
// cel-js refuses the unresolved receiver before the binding is reached —
// loud either way, as for `can`; this pins WHICH loud.
const r = evaluate("current_user.hasCapability('manage_users')", { systemPermissions: SYSTEM_PERMISSIONS });
expect(r.ok).toBe(false);
expect(r.ok === false && r.error.message).toContain('current_user');
});

it('THROWS on a non-string or empty name', () => {
expectLoud(withCapabilities('current_user.hasCapability(1)'), 'must be a system capability NAME');
expectLoud(withCapabilities("current_user.hasCapability('')"), 'must be a system capability NAME');
});

it('a bare `hasCapability(name)` names no subject and does not resolve', () => {
const result = withCapabilities("hasCapability('manage_users')");
expect(result.ok).toBe(false);
});
});

describe('`hasCapability` exists for the publish gates', () => {
it('is a registered name — the function-existence oracle reports nothing', () => {
expect(firstUnknownFunctionCall("current_user.hasCapability('manage_users')")).toBeNull();
// Control: an invented sibling on the same receiver IS reported, so the
// null above is a reading, not a blank run.
expect(firstUnknownFunctionCall("current_user.hasCapabilities('manage_users')")?.name)
.toBe('hasCapabilities');
});

it('validates clean as a boolean predicate', () => {
expect(validateExpression('predicate', "current_user.hasCapability('manage_users')").ok).toBe(true);
});

it('points an out-of-vocabulary `can` verb at the capability predicate', () => {
expectLoud(
evaluate("current_user.can('crm_lead', 'approve')", { user: USER, permissions: {} }),
"current_user.hasCapability('<name>')",
);
});
});
Loading
Loading