From 1b103adc5ab1d676dee99b95a81306a35a23828b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:10:04 +0000 Subject: [PATCH 1/3] fix(platform-objects): Setup offers Ban/Unban, Revoke/Restore and Unlock by the record's state Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude --- .../identity/action-state-visibility.test.ts | 130 ++++++++++++++++++ .../src/identity/sys-api-key.object.ts | 8 ++ .../src/identity/sys-user.object.ts | 34 ++++- .../src/platform-objects.test.ts | 8 +- 4 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 packages/platform-objects/src/identity/action-state-visibility.test.ts diff --git a/packages/platform-objects/src/identity/action-state-visibility.test.ts b/packages/platform-objects/src/identity/action-state-visibility.test.ts new file mode 100644 index 00000000000..bcb2f291e0f --- /dev/null +++ b/packages/platform-objects/src/identity/action-state-visibility.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup's Users and API Keys offer an action only in the record state it + * changes: Ban on a user who is not banned and Unban on one who is, Revoke on + * a live key and Restore on a revoked one, Unlock on an account that can hold + * a lock. Each pair used to judge the caller's standing alone, so both halves + * were offered on every row. + * + * The predicates are EVALUATED through the canonical engine the console + * delegates to (`@objectstack/formula`), on the parsed object, so the stored + * envelope with its `requiresFeature` lowering is what is judged. A string + * match would stay green on a predicate that faults or never answers true. + * The bindings are the rows a list or record header can present: the field + * projected with each value, projected as null, and not projected at all. + */ + +import { describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { SysUser } from './sys-user.object.js'; +import { SysApiKey } from './sys-api-key.object.js'; + +const ADMIN = { id: 'u_admin', email: 'admin@example.com', positions: [], isPlatformAdmin: true }; +const MEMBER = { id: 'u_member', email: 'member@example.com', positions: ['org_owner'], isPlatformAdmin: false }; +const FEATURES = { admin: true, apiKey: true }; + +function visibleOf(def: { actions?: unknown }, name: string): string { + const action = ((def.actions ?? []) as Array>).find((a) => a.name === name); + if (!action) throw new Error(`no action ${name}`); + const raw = action.visible; + const source = typeof raw === 'string' ? raw : (raw as { source?: unknown } | undefined)?.source; + if (typeof source !== 'string') throw new Error(`action ${name} has no CEL source`); + return source; +} + +/** The verdict, or the fault spelled out so a faulting predicate fails loudly. */ +function offered(source: string, record: Record, user = ADMIN): boolean | string { + const r = celEngine.evaluate( + { dialect: 'cel', source }, + { record, extra: { current_user: user, user, ctx: { user }, features: FEATURES } }, + ); + if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`; + return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`; +} + +describe('sys_user Ban / Unban switch with `banned`', () => { + const ban = visibleOf(SysUser, 'ban_user'); + const unban = visibleOf(SysUser, 'unban_user'); + + it('a user who is not banned is offered Ban and not Unban', () => { + for (const banned of [false, null]) { + expect(offered(ban, { banned }), `banned: ${banned}`).toBe(true); + expect(offered(unban, { banned }), `banned: ${banned}`).toBe(false); + } + }); + + it('a banned user is offered Unban and not Ban, a timed ban past its expiry included', () => { + // better-auth clears `banned` only at the user's next sign-in, so a row + // past `ban_expires` still says banned and Unban must stay offered. + for (const row of [{ banned: true }, { banned: true, ban_expires: '2020-01-01T00:00:00.000Z' }]) { + expect(offered(unban, row), JSON.stringify(row)).toBe(true); + expect(offered(ban, row), JSON.stringify(row)).toBe(false); + } + }); + + it('a row that does not carry `banned` offers neither, and never both', () => { + expect(offered(ban, {})).toBe(false); + expect(offered(unban, {})).toBe(false); + }); + + it('the standing leg stays: a caller without it is offered neither on any row', () => { + for (const banned of [true, false, null]) { + expect(offered(ban, { banned }, MEMBER)).toBe(false); + expect(offered(unban, { banned }, MEMBER)).toBe(false); + } + }); +}); + +describe('sys_user Unlock is offered on an account that can hold a lock', () => { + const unlock = visibleOf(SysUser, 'unlock_user'); + + it('an account with no lock stamp and no second factor is not offered Unlock', () => { + expect(offered(unlock, { locked_until: null, two_factor_enabled: false })).toBe(false); + expect(offered(unlock, { locked_until: null, two_factor_enabled: null })).toBe(false); + }); + + it('a password-stage lock is offered Unlock, an expired stamp included', () => { + // An expired lock leaves `failed_login_count` at the threshold, so the + // next failure re-locks; Unlock is what resets that counter. + for (const locked_until of ['2999-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z']) { + expect(offered(unlock, { locked_until, two_factor_enabled: false }), locked_until).toBe(true); + } + }); + + it('a two-factor user is offered Unlock: a second-factor lock lives on `sys_two_factor`, not this row', () => { + expect(offered(unlock, { locked_until: null, two_factor_enabled: true })).toBe(true); + }); + + it('a row that carries neither field is not offered Unlock, and the standing leg stays', () => { + expect(offered(unlock, {})).toBe(false); + expect(offered(unlock, { locked_until: '2999-01-01T00:00:00.000Z' }, MEMBER)).toBe(false); + expect(offered(unlock, { two_factor_enabled: true }, MEMBER)).toBe(false); + }); +}); + +describe('sys_api_key Revoke / Restore switch with `revoked`', () => { + const revoke = visibleOf(SysApiKey, 'revoke_api_key'); + const restore = visibleOf(SysApiKey, 'restore_api_key'); + + it('a live key is offered Revoke and not Restore', () => { + for (const revoked of [false, null]) { + expect(offered(revoke, { revoked }), `revoked: ${revoked}`).toBe(true); + expect(offered(restore, { revoked }), `revoked: ${revoked}`).toBe(false); + } + }); + + it('a revoked key is offered Restore and not Revoke, whatever the reason', () => { + // Who may restore a key a forced password reset revoked is the server's + // (plugin-auth `api-key-restore-guard.ts`), not this predicate's. + for (const row of [{ revoked: true }, { revoked: true, revoked_reason: 'admin_password_reset' }]) { + expect(offered(restore, row), JSON.stringify(row)).toBe(true); + expect(offered(revoke, row), JSON.stringify(row)).toBe(false); + } + }); + + it('a row that does not carry `revoked` offers neither, and never both', () => { + expect(offered(revoke, {})).toBe(false); + expect(offered(restore, {})).toBe(false); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 7e0f85c84b1..171ce7a84e6 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -135,6 +135,13 @@ export const SysApiKey = ObjectSchema.create({ method: 'PATCH', target: '/api/v1/data/sys_api_key/{id}', bodyExtra: { revoked: true }, + // Offered on a live key only, Restore on a revoked one, in the form of + // `sys_oauth_application`'s Disable / Enable pair: a null `revoked` + // reads as live. Display only, no door reads `visible`; who may restore + // a key a forced password reset revoked is the server's + // (`api-key-restore-guard.ts` in plugin-auth). Pinned in + // `action-state-visibility.test.ts`. + visible: 'has(record.revoked) && record.revoked != true', confirmText: 'Revoke this API key? Any clients using it will immediately lose access.', successMessage: 'API key revoked', refreshAfter: true, @@ -150,6 +157,7 @@ export const SysApiKey = ObjectSchema.create({ method: 'PATCH', target: '/api/v1/data/sys_api_key/{id}', bodyExtra: { revoked: false }, + visible: 'has(record.revoked) && record.revoked == true', confirmText: 'Restore this revoked API key? Existing clients holding the key will regain access.', successMessage: 'API key restored', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 7abf5fe3a3f..5cbafc983a3 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -140,6 +140,17 @@ export const SysUser = ObjectSchema.create({ // ⛔ Never `'platform_admin' in current_user.positions`: `EvalUserSchema` // rules that standing is read from this key, never from the array. The // repo-wide pin is `platform-admin-affordance-standing.test.ts`. + // + // Ban / Unban / Unlock also read the ROW's state after the standing, in + // the form of `sys_oauth_application`'s Disable / Enable pair, so a pair + // that undoes each other is never offered together. Display only: no door + // reads `visible` (the routes still answer whatever they are sent). On a + // list row the console projects every `record.` an object action's + // predicate reads (objectui `listViewPredicates`), so the default Users + // view carries `banned` without showing it; the record header reads the + // whole row. `banned` stays true past `ban_expires` until better-auth + // clears it at the user's next sign-in, so Unban is offered while the row + // says banned. Pinned in `action-state-visibility.test.ts`. { name: 'ban_user', label: 'Ban User', @@ -148,8 +159,9 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/ban-user', - // Platform-admin standing (ADR-0068 D4) — see the block header above. - visible: 'current_user.isPlatformAdmin == true', + // Platform-admin standing (ADR-0068 D4), then a user not banned yet — + // see the block header above. A null `banned` reads as not banned. + visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned != true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User banned', @@ -176,8 +188,9 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unban-user', - // Platform-admin standing (ADR-0068 D4) — see the block header above. - visible: 'current_user.isPlatformAdmin == true', + // Platform-admin standing (ADR-0068 D4), then a banned user — see the + // block header above. + visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned == true', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'User unbanned', @@ -194,8 +207,17 @@ export const SysUser = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/admin/unlock-user', - // Platform-admin standing (ADR-0068 D4) — see the block header above. - visible: 'current_user.isPlatformAdmin == true', + // Platform-admin standing (ADR-0068 D4), then an account that can hold + // a lock. `unlockUser` clears TWO stages: this row's `locked_until` + // (the password check) and `sys_two_factor.locked_until` (the second + // factor), which this row does not carry. So the button is offered when + // `locked_until` is set, or when the user has two-factor enabled and may + // be locked at the second factor; gating on `locked_until` alone would + // take away the only early release of a second-factor lock. The stamp is + // not compared with now() on purpose: an expired lock leaves + // `failed_login_count` at the threshold, so one more failure re-locks, + // and Unlock is what resets that counter. + visible: 'current_user.isPlatformAdmin == true && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))', requiresFeature: 'admin', recordIdParam: 'userId', successMessage: 'Account unlocked', diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index b3141cb6641..9a2a5e78797 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -731,9 +731,11 @@ describe('feature-gate lowering matrix (#2874)', () => { // #2874 P2b — audit gates: capability-dependent actions that previously // shipped UNGATED (rendered even with the backing plugin off, then 404'd). // Their doors are platform-admin gated, so the standing leads. - ['SysUser', SysUser, 'ban_user', `(${STANDING}) && features.admin == true`], - ['SysUser', SysUser, 'unban_user', `(${STANDING}) && features.admin == true`], - ['SysUser', SysUser, 'unlock_user', `(${STANDING}) && features.admin == true`], + // The three also read the row's state after the standing (the + // evaluated truth tables are `identity/action-state-visibility.test.ts`). + ['SysUser', SysUser, 'ban_user', `(${STANDING} && has(record.banned) && record.banned != true) && features.admin == true`], + ['SysUser', SysUser, 'unban_user', `(${STANDING} && has(record.banned) && record.banned == true) && features.admin == true`], + ['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`], ['SysUser', SysUser, 'set_user_password', `(${STANDING}) && features.admin == true`], ['SysUser', SysUser, 'impersonate_user', `(${STANDING}) && features.admin == true`], ['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'], From 42e34e5a57d93e617b81ed9f0270c2521e5e4b91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:11:40 +0000 Subject: [PATCH 2/3] chore(changeset): platform-objects patch for record-state action visibility Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude --- .changeset/24760-action-state-visibility.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/24760-action-state-visibility.md diff --git a/.changeset/24760-action-state-visibility.md b/.changeset/24760-action-state-visibility.md new file mode 100644 index 00000000000..a3fd1a96710 --- /dev/null +++ b/.changeset/24760-action-state-visibility.md @@ -0,0 +1,17 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state + +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) + +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: + +- `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. +- `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. +- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one. + +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. + +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. From 2740711c4a00966520c663ede66baeb2fc58d089 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:19:47 +0000 Subject: [PATCH 3/3] test(platform-objects): type the principal binding in the action-state pins Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude --- .../src/identity/action-state-visibility.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/identity/action-state-visibility.test.ts b/packages/platform-objects/src/identity/action-state-visibility.test.ts index bcb2f291e0f..58f1b2451e9 100644 --- a/packages/platform-objects/src/identity/action-state-visibility.test.ts +++ b/packages/platform-objects/src/identity/action-state-visibility.test.ts @@ -20,8 +20,10 @@ import { celEngine } from '@objectstack/formula'; import { SysUser } from './sys-user.object.js'; import { SysApiKey } from './sys-api-key.object.js'; -const ADMIN = { id: 'u_admin', email: 'admin@example.com', positions: [], isPlatformAdmin: true }; -const MEMBER = { id: 'u_member', email: 'member@example.com', positions: ['org_owner'], isPlatformAdmin: false }; +type Principal = { id: string; email: string; positions: string[]; isPlatformAdmin: boolean }; + +const ADMIN: Principal = { id: 'u_admin', email: 'admin@example.com', positions: [], isPlatformAdmin: true }; +const MEMBER: Principal = { id: 'u_member', email: 'member@example.com', positions: ['org_owner'], isPlatformAdmin: false }; const FEATURES = { admin: true, apiKey: true }; function visibleOf(def: { actions?: unknown }, name: string): string { @@ -34,7 +36,7 @@ function visibleOf(def: { actions?: unknown }, name: string): string { } /** The verdict, or the fault spelled out so a faulting predicate fails loudly. */ -function offered(source: string, record: Record, user = ADMIN): boolean | string { +function offered(source: string, record: Record, user: Principal = ADMIN): boolean | string { const r = celEngine.evaluate( { dialect: 'cel', source }, { record, extra: { current_user: user, user, ctx: { user }, features: FEATURES } },