Skip to content

Commit d606fc1

Browse files
fix(platform-objects): Setup offers Ban/Unban, Revoke/Restore and Unlock by the record's state (#24921)
Fixes #24760 Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares) Dev session `session_01VXimSk38NSs68ntncNxKS8`, dispatched by the `domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`) under claim 6292572117. ## What changes Five Setup actions judged only the caller's standing, so both halves of each pair were offered on every row. Each now also reads the record's state, in the form of `sys_oauth_application`'s Disable / Enable pair (`has(record.X) && record.X != true` and `== true`): | Action | `visible` (authored) | |---|---| | `sys_user.ban_user` | `current_user.isPlatformAdmin == true && has(record.banned) && record.banned != true` | | `sys_user.unban_user` | `current_user.isPlatformAdmin == true && has(record.banned) && record.banned == true` | | `sys_user.unlock_user` | `current_user.isPlatformAdmin == true && ((has(record.locked_until) && record.locked_until != null) \|\| (has(record.two_factor_enabled) && record.two_factor_enabled == true))` | | `sys_api_key.revoke_api_key` | `has(record.revoked) && record.revoked != true` | | `sys_api_key.restore_api_key` | `has(record.revoked) && record.revoked == true` | `requiresFeature: 'admin'` still AND-composes onto the three `sys_user` predicates at parse time. Labels, texts, targets, params and every other key are unchanged, so no translation bundle moves. ## Read before the edit **Row context.** On a list row (`list_item`), objectui's `ObjectGrid` builds `$select` from the view's columns plus every `record.FIELD` that any of the object's actions reads in `visible` / `disabled`. The read is `listViewPredicates({ objectActions: resolvedSchema.actions })` then `collectPredicateFieldRefs`, in `packages/core/src/utils/predicate-fields.ts` and `packages/plugin-grid/src/ObjectGrid.tsx` at the pinned `.objectui-sha` `616df659`. Only fields the object declares are kept. So the default Users view (`name`, `email`, `created_at`) still carries `banned`, `locked_until` and `two_factor_enabled`, and the API Keys views carry `revoked` (also a column there). On the record header (`record_header`), `RecordDetailView` / `DetailView` call `findOne` with no `$select`, which returns the whole row. The console judges `visible` through `@objectstack/formula`'s engine (`fieldRules.ts`, `ExpressionEvaluator`). No objectui change is needed. This is a code reading; I did not boot a showcase, because the code left no doubt. **Who reads `visible`.** No server door reads it. `packages/runtime/src/action-execution.ts` says so for declarative actions ("Deliberately unread; pinned as unread"), and `domains/actions.ts` says it for the dispatch door ("rendering predicates that no dispatch door reads"). The MCP `list_actions` filter is a system-object filter, not an action's `visible`. Five other sites were read too, and none of them reads `visible`: the ban, unban and unlock routes (`admin-ban-endpoints.ts`, `unlockUser`, the plugin-auth routes), and the `sys_api_key` `PATCH` (`api-key-restore-guard.ts`, the identity write guard). `Clause-②: no` stands. **States.** - `banned` / `ban_expires`: better-auth 1.7.3 (`plugins/admin/admin.mjs`, the session-create hook) clears `banned` only when a session is next created after `ban_expires`. Until the user signs in again, the row still says `banned: true`, so Unban stays offered for as long as the row says banned. - `locked_until` (ADR-0069 D2): the CEL engine *can* compare it with `now()` (measured: an ISO string compared with `now()` gives the right boolean). I chose not to compare it, because `unlockUser` clears two stages. One is this row's `locked_until`; the other is `sys_two_factor.locked_until`, which the user row does not carry. Gating on `locked_until` alone would take away the only early release of a second-factor lock. An expired stamp also leaves `failed_login_count` at the threshold, so one more failure re-locks the account, and Unlock is what resets the counter. So Unlock is offered when `locked_until` is set or when two-factor is enabled. It is hidden on a plain account with neither, which is the case the card reports. - `revoked` / `revoked_reason`: only a platform administrator may restore a key that a forced password reset revoked. That gate is the server's (`api-key-restore-guard.ts`, a `beforeUpdate` hook), and it stays there. The predicate offers Restore on every revoked key. ## Verification All on `2740711c4` (main `a7199ef97` merged in), unless noted. - Evaluated pins: `packages/platform-objects/src/identity/action-state-visibility.test.ts`, 11 tests. They evaluate each parsed predicate through `celEngine`, on a banned, unbanned and null row, a row without the field, and a non-admin caller. The same is done for Unlock and the API key pair. The `platform-objects.test.ts` lowering matrix rows for the three `sys_user` actions move to the new sources. - Ablation, run twice. Each time the restored blob was proven equal to `HEAD` with `git hash-object` and `git diff HEAD` came back empty. - Leg 1: both object files restored to base `a8b44d40`. Result: 2 files failed. The new pin file fails at collection, because base `revoke_api_key` has no `visible`. The 3 lowering rows also fail. - Leg 2: only `sys-user.object.ts` reverted. Result: 5 of 11 tests failed: Ban/Unban not-banned, banned, unprojected; Unlock no-lock; Unlock unprojected. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0. The first run exited 1 on 4 TS2345 errors in the new test file; I fixed them by typing the principal. - `pnpm --filter @objectstack/platform-objects test`: exit 0, 82 files, 1232 tests. - `pnpm --filter @objectstack/plugin-auth test`: exit 0, 171 files, 3277 passed, 10 skipped. - `pnpm --filter @objectstack/core test:repo`: exit 0, 6 files, 82 tests. - `platform-admin-affordance-standing.test.ts` and `action-predicate-sparse-face.test.ts`: green. The standing leg stays, and every new `record.*` read opens with `has()`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `2740711c4`: 66 commands, all exit 0. `--ran`: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN". - `check:dual-build-cjs-loads` and `check:i18n` first exited 3 (PREREQUISITE NOT MET). After `pnpm build` they were re-run and exited 0. - `check:type-check-debt` ran in a `nohup` slice waited on by PID, and exited 0. - The clone was deepened with `--shallow-since=2026-07-13` before the run. ## Pin sweep The five action names and their `visible` strings were swept across every package, `qa/`, `docs/qa/platform-checklist/` and `content/docs/`. The only pins that move are the three `platform-objects.test.ts` lowering rows. Everything else is unaffected: - the dogfood API-key lifecycle tests, `api-key-lifecycle.test.ts`, `api-methods-batch-conformance.test.ts` and the `identity-auth.json` checklist items: they issue the PATCH directly and never read `visible`; - `content/docs/protocol/objectui/actions.mdx`: its `ban_user` example shows only `requiresFeature`; - the confirm-dialog pins and `action-execution-destructive.test.ts`: they do not read `visible`. No `docs/qa/platform-checklist/**` edit. ## Acceptance notes - `origin/main` moved 3 commits after the merge (`eee65afc8`, `e2c2355af`, `81e6edd46`: driver-sql and dogfood), none touching `platform-objects`. They were not merged. - A row can show an expired timed ban as banned until the user's next sign-in. Ban is then hidden; Unban and then Ban re-bans. This follows better-auth's lazy clear and is not changed here. - The console judges `visible` on the row it holds, so a lock that expires while the page is open is not re-judged until the row is re-read. --- _Generated by [Claude Code](https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b42c621 commit d606fc1

5 files changed

Lines changed: 190 additions & 9 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
6+
7+
Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
8+
9+
Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
10+
11+
- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
12+
- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
13+
- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
14+
15+
The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
16+
17+
Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Setup's Users and API Keys offer an action only in the record state it
5+
* changes: Ban on a user who is not banned and Unban on one who is, Revoke on
6+
* a live key and Restore on a revoked one, Unlock on an account that can hold
7+
* a lock. Each pair used to judge the caller's standing alone, so both halves
8+
* were offered on every row.
9+
*
10+
* The predicates are EVALUATED through the canonical engine the console
11+
* delegates to (`@objectstack/formula`), on the parsed object, so the stored
12+
* envelope with its `requiresFeature` lowering is what is judged. A string
13+
* match would stay green on a predicate that faults or never answers true.
14+
* The bindings are the rows a list or record header can present: the field
15+
* projected with each value, projected as null, and not projected at all.
16+
*/
17+
18+
import { describe, expect, it } from 'vitest';
19+
import { celEngine } from '@objectstack/formula';
20+
import { SysUser } from './sys-user.object.js';
21+
import { SysApiKey } from './sys-api-key.object.js';
22+
23+
type Principal = { id: string; email: string; positions: string[]; isPlatformAdmin: boolean };
24+
25+
const ADMIN: Principal = { id: 'u_admin', email: 'admin@example.com', positions: [], isPlatformAdmin: true };
26+
const MEMBER: Principal = { id: 'u_member', email: 'member@example.com', positions: ['org_owner'], isPlatformAdmin: false };
27+
const FEATURES = { admin: true, apiKey: true };
28+
29+
function visibleOf(def: { actions?: unknown }, name: string): string {
30+
const action = ((def.actions ?? []) as Array<Record<string, unknown>>).find((a) => a.name === name);
31+
if (!action) throw new Error(`no action ${name}`);
32+
const raw = action.visible;
33+
const source = typeof raw === 'string' ? raw : (raw as { source?: unknown } | undefined)?.source;
34+
if (typeof source !== 'string') throw new Error(`action ${name} has no CEL source`);
35+
return source;
36+
}
37+
38+
/** The verdict, or the fault spelled out so a faulting predicate fails loudly. */
39+
function offered(source: string, record: Record<string, unknown>, user: Principal = ADMIN): boolean | string {
40+
const r = celEngine.evaluate(
41+
{ dialect: 'cel', source },
42+
{ record, extra: { current_user: user, user, ctx: { user }, features: FEATURES } },
43+
);
44+
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`;
45+
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;
46+
}
47+
48+
describe('sys_user Ban / Unban switch with `banned`', () => {
49+
const ban = visibleOf(SysUser, 'ban_user');
50+
const unban = visibleOf(SysUser, 'unban_user');
51+
52+
it('a user who is not banned is offered Ban and not Unban', () => {
53+
for (const banned of [false, null]) {
54+
expect(offered(ban, { banned }), `banned: ${banned}`).toBe(true);
55+
expect(offered(unban, { banned }), `banned: ${banned}`).toBe(false);
56+
}
57+
});
58+
59+
it('a banned user is offered Unban and not Ban, a timed ban past its expiry included', () => {
60+
// better-auth clears `banned` only at the user's next sign-in, so a row
61+
// past `ban_expires` still says banned and Unban must stay offered.
62+
for (const row of [{ banned: true }, { banned: true, ban_expires: '2020-01-01T00:00:00.000Z' }]) {
63+
expect(offered(unban, row), JSON.stringify(row)).toBe(true);
64+
expect(offered(ban, row), JSON.stringify(row)).toBe(false);
65+
}
66+
});
67+
68+
it('a row that does not carry `banned` offers neither, and never both', () => {
69+
expect(offered(ban, {})).toBe(false);
70+
expect(offered(unban, {})).toBe(false);
71+
});
72+
73+
it('the standing leg stays: a caller without it is offered neither on any row', () => {
74+
for (const banned of [true, false, null]) {
75+
expect(offered(ban, { banned }, MEMBER)).toBe(false);
76+
expect(offered(unban, { banned }, MEMBER)).toBe(false);
77+
}
78+
});
79+
});
80+
81+
describe('sys_user Unlock is offered on an account that can hold a lock', () => {
82+
const unlock = visibleOf(SysUser, 'unlock_user');
83+
84+
it('an account with no lock stamp and no second factor is not offered Unlock', () => {
85+
expect(offered(unlock, { locked_until: null, two_factor_enabled: false })).toBe(false);
86+
expect(offered(unlock, { locked_until: null, two_factor_enabled: null })).toBe(false);
87+
});
88+
89+
it('a password-stage lock is offered Unlock, an expired stamp included', () => {
90+
// An expired lock leaves `failed_login_count` at the threshold, so the
91+
// next failure re-locks; Unlock is what resets that counter.
92+
for (const locked_until of ['2999-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z']) {
93+
expect(offered(unlock, { locked_until, two_factor_enabled: false }), locked_until).toBe(true);
94+
}
95+
});
96+
97+
it('a two-factor user is offered Unlock: a second-factor lock lives on `sys_two_factor`, not this row', () => {
98+
expect(offered(unlock, { locked_until: null, two_factor_enabled: true })).toBe(true);
99+
});
100+
101+
it('a row that carries neither field is not offered Unlock, and the standing leg stays', () => {
102+
expect(offered(unlock, {})).toBe(false);
103+
expect(offered(unlock, { locked_until: '2999-01-01T00:00:00.000Z' }, MEMBER)).toBe(false);
104+
expect(offered(unlock, { two_factor_enabled: true }, MEMBER)).toBe(false);
105+
});
106+
});
107+
108+
describe('sys_api_key Revoke / Restore switch with `revoked`', () => {
109+
const revoke = visibleOf(SysApiKey, 'revoke_api_key');
110+
const restore = visibleOf(SysApiKey, 'restore_api_key');
111+
112+
it('a live key is offered Revoke and not Restore', () => {
113+
for (const revoked of [false, null]) {
114+
expect(offered(revoke, { revoked }), `revoked: ${revoked}`).toBe(true);
115+
expect(offered(restore, { revoked }), `revoked: ${revoked}`).toBe(false);
116+
}
117+
});
118+
119+
it('a revoked key is offered Restore and not Revoke, whatever the reason', () => {
120+
// Who may restore a key a forced password reset revoked is the server's
121+
// (plugin-auth `api-key-restore-guard.ts`), not this predicate's.
122+
for (const row of [{ revoked: true }, { revoked: true, revoked_reason: 'admin_password_reset' }]) {
123+
expect(offered(restore, row), JSON.stringify(row)).toBe(true);
124+
expect(offered(revoke, row), JSON.stringify(row)).toBe(false);
125+
}
126+
});
127+
128+
it('a row that does not carry `revoked` offers neither, and never both', () => {
129+
expect(offered(revoke, {})).toBe(false);
130+
expect(offered(restore, {})).toBe(false);
131+
});
132+
});

‎packages/platform-objects/src/identity/sys-api-key.object.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ export const SysApiKey = ObjectSchema.create({
135135
method: 'PATCH',
136136
target: '/api/v1/data/sys_api_key/{id}',
137137
bodyExtra: { revoked: true },
138+
// Offered on a live key only, Restore on a revoked one, in the form of
139+
// `sys_oauth_application`'s Disable / Enable pair: a null `revoked`
140+
// reads as live. Display only, no door reads `visible`; who may restore
141+
// a key a forced password reset revoked is the server's
142+
// (`api-key-restore-guard.ts` in plugin-auth). Pinned in
143+
// `action-state-visibility.test.ts`.
144+
visible: 'has(record.revoked) && record.revoked != true',
138145
confirmText: 'Revoke this API key? Any clients using it will immediately lose access.',
139146
successMessage: 'API key revoked',
140147
refreshAfter: true,
@@ -150,6 +157,7 @@ export const SysApiKey = ObjectSchema.create({
150157
method: 'PATCH',
151158
target: '/api/v1/data/sys_api_key/{id}',
152159
bodyExtra: { revoked: false },
160+
visible: 'has(record.revoked) && record.revoked == true',
153161
confirmText: 'Restore this revoked API key? Existing clients holding the key will regain access.',
154162
successMessage: 'API key restored',
155163
refreshAfter: true,

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ export const SysUser = ObjectSchema.create({
140140
// ⛔ Never `'platform_admin' in current_user.positions`: `EvalUserSchema`
141141
// rules that standing is read from this key, never from the array. The
142142
// repo-wide pin is `platform-admin-affordance-standing.test.ts`.
143+
//
144+
// Ban / Unban / Unlock also read the ROW's state after the standing, in
145+
// the form of `sys_oauth_application`'s Disable / Enable pair, so a pair
146+
// that undoes each other is never offered together. Display only: no door
147+
// reads `visible` (the routes still answer whatever they are sent). On a
148+
// list row the console projects every `record.<field>` an object action's
149+
// predicate reads (objectui `listViewPredicates`), so the default Users
150+
// view carries `banned` without showing it; the record header reads the
151+
// whole row. `banned` stays true past `ban_expires` until better-auth
152+
// clears it at the user's next sign-in, so Unban is offered while the row
153+
// says banned. Pinned in `action-state-visibility.test.ts`.
143154
{
144155
name: 'ban_user',
145156
label: 'Ban User',
@@ -148,8 +159,9 @@ export const SysUser = ObjectSchema.create({
148159
locations: ['list_item', 'record_header'],
149160
type: 'api',
150161
target: '/api/v1/auth/admin/ban-user',
151-
// Platform-admin standing (ADR-0068 D4) — see the block header above.
152-
visible: 'current_user.isPlatformAdmin == true',
162+
// Platform-admin standing (ADR-0068 D4), then a user not banned yet —
163+
// see the block header above. A null `banned` reads as not banned.
164+
visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned != true',
153165
requiresFeature: 'admin',
154166
recordIdParam: 'userId',
155167
successMessage: 'User banned',
@@ -176,8 +188,9 @@ export const SysUser = ObjectSchema.create({
176188
locations: ['list_item', 'record_header'],
177189
type: 'api',
178190
target: '/api/v1/auth/admin/unban-user',
179-
// Platform-admin standing (ADR-0068 D4) — see the block header above.
180-
visible: 'current_user.isPlatformAdmin == true',
191+
// Platform-admin standing (ADR-0068 D4), then a banned user — see the
192+
// block header above.
193+
visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned == true',
181194
requiresFeature: 'admin',
182195
recordIdParam: 'userId',
183196
successMessage: 'User unbanned',
@@ -194,8 +207,17 @@ export const SysUser = ObjectSchema.create({
194207
locations: ['list_item', 'record_header'],
195208
type: 'api',
196209
target: '/api/v1/auth/admin/unlock-user',
197-
// Platform-admin standing (ADR-0068 D4) — see the block header above.
198-
visible: 'current_user.isPlatformAdmin == true',
210+
// Platform-admin standing (ADR-0068 D4), then an account that can hold
211+
// a lock. `unlockUser` clears TWO stages: this row's `locked_until`
212+
// (the password check) and `sys_two_factor.locked_until` (the second
213+
// factor), which this row does not carry. So the button is offered when
214+
// `locked_until` is set, or when the user has two-factor enabled and may
215+
// be locked at the second factor; gating on `locked_until` alone would
216+
// take away the only early release of a second-factor lock. The stamp is
217+
// not compared with now() on purpose: an expired lock leaves
218+
// `failed_login_count` at the threshold, so one more failure re-locks,
219+
// and Unlock is what resets that counter.
220+
visible: 'current_user.isPlatformAdmin == true && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))',
199221
requiresFeature: 'admin',
200222
recordIdParam: 'userId',
201223
successMessage: 'Account unlocked',

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -731,9 +731,11 @@ describe('feature-gate lowering matrix (#2874)', () => {
731731
// #2874 P2b — audit gates: capability-dependent actions that previously
732732
// shipped UNGATED (rendered even with the backing plugin off, then 404'd).
733733
// Their doors are platform-admin gated, so the standing leads.
734-
['SysUser', SysUser, 'ban_user', `(${STANDING}) && features.admin == true`],
735-
['SysUser', SysUser, 'unban_user', `(${STANDING}) && features.admin == true`],
736-
['SysUser', SysUser, 'unlock_user', `(${STANDING}) && features.admin == true`],
734+
// The three also read the row's state after the standing (the
735+
// evaluated truth tables are `identity/action-state-visibility.test.ts`).
736+
['SysUser', SysUser, 'ban_user', `(${STANDING} && has(record.banned) && record.banned != true) && features.admin == true`],
737+
['SysUser', SysUser, 'unban_user', `(${STANDING} && has(record.banned) && record.banned == true) && features.admin == true`],
738+
['SysUser', SysUser, 'unlock_user', `(${STANDING} && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))) && features.admin == true`],
737739
['SysUser', SysUser, 'set_user_password', `(${STANDING}) && features.admin == true`],
738740
['SysUser', SysUser, 'impersonate_user', `(${STANDING}) && features.admin == true`],
739741
['SysUser', SysUser, 'enable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true) && features.twoFactor == true'],

0 commit comments

Comments
 (0)