Skip to content

Commit 9d13b5f

Browse files
fix(plugin-auth,platform-objects): revoke a session by its row id, resolved server-side (#24791)
Security fix (maintainer-ordered; no public card; detail withheld under the disclosure ruling). Clause-②: no ## The invariant Setup → Diagnostics → Sessions (and Account → Active Sessions): a row's **Revoke Session** ends the session it names, and that session's next request is refused. It works for the session's owner and for a platform administrator, and for nobody else. No request to or answer from the revoke path carries a session token, and `sys_session.token` stays `internal: true`. ## What changed - **`packages/platform-objects`**: `sys_session` `revoke_session` is keyed on the row's `id`, which every list row carries. It sets `recordIdParam: 'sessionId'`, and `recordIdField` falls back to its default `id`. It posts to `POST /api/v1/auth/sys-session/revoke`. The `token` field is unchanged: `internal`, `hidden`, `readonly`. The console runtime is generic, so objectui needs no change. - **`packages/plugins/plugin-auth`**: new ObjectStack mount `POST /api/v1/auth/sys-session/revoke`. The handler is `sys-session-revoke.ts`, the mount is in `auth-plugin.ts`, and the route is in `auth-route-ledger.ts` plus its conformance pin. The server looks up the session from the id through better-auth's own storage adapter, which hides ended sessions and re-attaches the internal column server-side. The token is used only inside the server. - **The owner lane** forwards the request in-process to better-auth's `POST /revoke-session`, with the caller's own credentials. That route's ownership and freshness checks still apply, and so do the existing admission guard and the `user_revoked` tombstone, all unchanged. - **The administrator lane** handles another user's session. The ADR-0068 D2 `judgePlatformAdmin` admits the caller (held sessions are refused per ADR-0069 D3). The session is then tombstoned in place with `revoke_reason: admin` (ADR-0069 D4). This is the same row shape `membership-ended-session.ts` and `forced-reset-revocation.ts` write. The reason value comes from the interactive-revoke ledger, so the two cannot drift. better-auth's `/admin/revoke-user-session` is not used as the delegate: it authorizes on the legacy `role` scalar, which ADR-0068 D2 administrators do not carry. - **Every other signed-in caller** gets one `404 RESOURCE_NOT_FOUND`, byte-identical to the answer for an id that names no live session. An anonymous caller gets `401 UNAUTHENTICATED`, and a body without `sessionId` gets `400 INVALID_REQUEST`. - The route is not under `/api/v1/auth/admin/`, because its first lane admits the owner. It belongs to the `sys-oauth-application/register` family. - **`session-tombstone.ts`**: one comment sentence. The `admin` reason now has a third writer. - **Tenant-audit census**: `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` were regenerated with `node scripts/tenant-audit-census.mjs --write`, and the page's hand-written figures were moved from 231 to 232. The administrator lane's tombstone write is a new engine write call site, and `check-tenant-audit-census` requires it to be counted. - **Changeset**: `.changeset/sys-session-revoke-by-id.md`, a patch for `@objectstack/plugin-auth` and `@objectstack/platform-objects`. ## Tests All on this branch. `plugin-auth` resolves the subject through relative source imports, so its runs read `src/`. - **New pins**, in `packages/plugins/plugin-auth/src/sys-session-revoke.test.ts`. They run through the plugin's real route registration on a real Hono app, in front of a real `AuthManager`, with cookies minted by better-auth's own sign-in. Every revoke asserts both halves: the cookie stops authenticating, and the row survives with its cause. Every refusal asserts that the target still authenticates and its row is untouched, as `status` plus `code`. - an administrator revoking another user's session by id ends it, so that session's next request is refused (`revoke_reason: admin`, and the administrator's own session is untouched); - a session's owner revoking their own other session by id ends that one and keeps the current one (`user_revoked`); - an already-ended session reads not-found, and its record keeps the first ending; - a signed-in caller without platform-admin standing cannot revoke another user's session (`404 RESOURCE_NOT_FOUND`, byte-identical to an id that names nothing); - an anonymous caller is refused before anything is revoked (`401 UNAUTHENTICATED`); - a request that names no session is refused, and nothing is revoked (`400 INVALID_REQUEST`); - the administrator lane records the interactive-revoke ledger's own `admin` value. - Each answer above is also asserted to carry no session token. - **New pins**, in `packages/platform-objects/src/platform-objects.test.ts`: - `revoke_session` identifies its session by the row id and posts `sessionId` to the new route; - `sys_session.token` stays `internal`, `hidden` and `readonly`; - no identity action identifies its record by a field the server never serves. This sweep covers every identity object and has a positive control. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` reported Test Files 166 passed (166) and Tests 3185 passed, 10 skipped (3195). - `pnpm --filter @objectstack/plugin-auth typecheck` passed: src, examples and the test layer (`check:test-typecheck: OK`). - `pnpm --filter @objectstack/platform-objects test` reported Test Files 79 passed (79) and Tests 1198 passed (1198). `typecheck` passed. - **After merging `origin/main`** (74f7815): `pnpm install --frozen-lockfile`, `@objectstack/spec` build, then `check:generated` ("All 14 generated artifacts are up to date"), then `plugin-auth` and `platform-objects` typecheck. All passed. - **Ablations.** Each one mutated one line, through `scripts/ablation-replace.mjs` in wrap mode (anchor hit 1 to 0, blob changed). Each restore was proven: blob equals HEAD, and `git diff HEAD` is empty. The direction was predicted before each run, and every result matched the prediction. - A1, the standing check removed from the administrator lane: 1 red, the non-admin pin. - A2, the administrator tombstone write removed: 2 red, the admin pin and the already-ended pin. - A3, the owner lane removed: 1 red, the owner pin. - A4, the action re-keyed to `token`: 2 red, the declaration pin and the sweep. ## Gates Derived by `node scripts/pm/dispatch-gates.mjs --commands` at 3270bb1 (the final head), then reconciled with `--ran` against recorded exit codes. Result: **103 derived, 100 run with exit 0, 3 not measured, 0 unrun.** - Code-reading families ran at 300e9d4. The only later commit (3270bb1) changes the two census docs. Every family that reads those docs was derived again and re-run at 3270bb1: the 30 doc families, plus the census, doc-authoring, docs-audit, nul-bytes, issue-citations, comment-mask and auth-mount-ledger families. - **NOT MEASURED** (each exited 3 on a prerequisite; CI runs all three): - `check-plugin-teardown-shape --self-test`, reason: this shallow checkout cannot reach the fixture commit; - `check:dual-build-cjs-loads`, reason: it needs every package's `dist/`; - `check:i18n`, reason: it needs the built CLI closure. No translatable string changed: the action's label, confirm text and success message are byte-identical. - `check:tenant-audit-census` went red on the new write site. It passed after the census was regenerated (232 write call sites). - `test:repo` and the full CI battery are left to CI. ## Acceptance notes - `content/docs/permissions/tenant-audit-census.mdx` records its measurement commit as the merge head the census was regenerated on. Per that page, the measurement date and sha are unenforced. --- _Generated by [Claude Code](https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b7286f commit 9d13b5f

11 files changed

Lines changed: 708 additions & 26 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(plugin-auth,platform-objects): Revoke Session on a `sys_session` row ends the session it names
7+
8+
Clause-②: no
9+
10+
- **The action.** The `sys_session` `revoke_session` row action (Setup → Sessions, Account → Active Sessions) posts the row's `id` as `sessionId` to a new plugin-auth route, `POST /api/v1/auth/sys-session/revoke`. The server resolves the session from the id. The session token stays `internal: true`: it is not served to the console or to any other client, and no request to or answer from this route carries it.
11+
- **Who may revoke.** The session's owner: the route hands the request to better-auth's own `POST /api/v1/auth/revoke-session`, so that route's checks and its `user_revoked` record apply exactly as before. Another user's session: a platform administrator only (the ADR-0068 D2 standing every `/api/v1/auth/admin/*` mount judges). The session is ended in place with `revoke_reason: admin` (ADR-0069 D4), stays listed under the Revoked view, and its next request is unauthenticated.
12+
- **Refusals.** An anonymous caller: `401 UNAUTHENTICATED`. A body without `sessionId`: `400 INVALID_REQUEST`. Every other caller, and an id that names no live session: `404 RESOURCE_NOT_FOUND`, one answer for every cause. A platform administrator whose own sign-in is held by the authentication policy: `403` with the policy's code.
13+
- **Unchanged.** `POST /api/v1/auth/revoke-session`, "Sign out other devices", and every `sys_session` read path.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **50 of the 231 sites are spelled that way**. A
125+
forwarding shim cannot, and **50 of the 232 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **231** |
190+
| 175 write call sites | quoted in the merged changeset | **232** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 231** decidable, **75** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 130 decidably elevated, 0 decidably not, 93 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 232** decidable, **76** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 131 decidably elevated, 0 decidably not, 93 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
130 of 231 (56%) as decidably elevated, with 93 more whose elevation is a
210+
131 of 232 (56%) as decidably elevated, with 93 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **29 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **231** |
226+
| write call sites on the application surface | **232** |
227227
| …whose object name is statically decidable | 156 |
228-
| …whose object name is chosen at run time | 75 |
228+
| …whose object name is chosen at run time | 76 |
229229
| …against an object with tenancy ENABLED | 155 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 173 |
231+
| threading a tenant context | 174 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
233233
| …of those, against a decidably tenancy-enabled object | **2** |
234234
| options argument UNREADABLE — may or may not carry one | 50 |
235235
| …of those, against a decidably tenancy-enabled object | 29 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 130 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 131 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 93 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 190 |
242+
| receiver carried a readable engine type | 191 |
243243
| receiver erased, placed by the object NAME | 31 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247247
| object name spelled inline | 107 |
248248
| object name spelled through a `const` | 49 |
249249
| object name is an `object: string` parameter | 7 |
250-
| object name is some other run-time expression | 68 |
250+
| object name is some other run-time expression | 69 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

@@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-20 at `1f2b0cb73`.
300+
Measured on 2026-10-20 at `300e9d436`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 674 |
304+
| tracked non-test sources scanned | 675 |
305305
| engine-shaped types recognised | 80 |
306306
| declared objects in the registry | 118 |
307307
| same-named calls subtracted as non-engine | 166 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 231 |
36+
| Write call sites | 232 |
3737
| Object name statically decidable | 156 |
38-
| Object name chosen at run time | 75 |
38+
| Object name chosen at run time | 76 |
3939
| Against a tenancy-enabled object | 155 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 173 |
41+
| Threading a tenant context | 174 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 50 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 130 |
46+
| Threading a decidably elevated context | 131 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 93 |
4949

@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-20 at `1f2b0cb73`.
93+
Measured on 2026-10-20 at `300e9d436`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 674 |
97+
| tracked non-test sources scanned | 675 |
9898
| engine-shaped types recognised | 80 |
9999
| declared objects in the registry | 118 |
100100
| same-named calls subtracted as non-engine | 166 |
@@ -158,6 +158,7 @@ Measured on 2026-10-20 at `1f2b0cb73`.
158158
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
159159
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
160160
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
161+
| `packages/plugins/plugin-auth/src/sys-session-revoke.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 |
161162
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 |
162163
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 |
163164
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,15 @@ export const SysSession = ObjectSchema.create({
9292
mode: 'delete',
9393
locations: ['list_item'],
9494
type: 'api',
95-
target: '/api/v1/auth/revoke-session',
96-
// better-auth `revoke-session` keys off the session token, not the id.
97-
recordIdParam: 'token',
98-
recordIdField: 'token',
95+
// Keyed on the row's `id` — every list row carries it — and resolved to
96+
// the session SERVER-side by plugin-auth's `sys-session/revoke` door, so
97+
// the `internal` token below is never sent to or from the console. That
98+
// door admits the session's owner (better-auth's own `/revoke-session`)
99+
// and, for another user's session, a platform administrator (ADR-0068
100+
// D2). ⛔ Never key this action on `token`: no read path serves it, so a
101+
// row can never supply it.
102+
target: '/api/v1/auth/sys-session/revoke',
103+
recordIdParam: 'sessionId',
99104
confirmText: 'Revoke this session? The user will be signed out from that device.',
100105
successMessage: 'Session revoked',
101106
refreshAfter: true,

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,53 @@ describe('@objectstack/platform-objects', () => {
327327
expect(role.description).toContain('OS_PLATFORM_OWNER_EMAIL');
328328
expect(role.description).toContain('`single`');
329329
});
330+
331+
it('SysSession revoke_session identifies its session by the row id, resolved server-side', () => {
332+
const revoke = (SysSession.actions ?? []).find((a) => a.name === 'revoke_session');
333+
expect(revoke).toBeDefined();
334+
expect(revoke?.type).toBe('api');
335+
expect(revoke?.locations).toEqual(['list_item']);
336+
expect(revoke?.target).toBe('/api/v1/auth/sys-session/revoke');
337+
expect(revoke?.recordIdParam).toBe('sessionId');
338+
// The row field that seeds `recordIdParam` defaults to `id` — the one
339+
// column every list row carries.
340+
expect(revoke?.recordIdField ?? 'id').toBe('id');
341+
expect(SysSession.fields.id).toBeDefined();
342+
});
343+
344+
it('SysSession keeps its session token internal: no read path serves it', () => {
345+
const token = SysSession.fields.token as { internal?: unknown; hidden?: unknown; readonly?: unknown };
346+
expect(token.internal).toBe(true);
347+
expect(token.hidden).toBe(true);
348+
expect(token.readonly).toBe(true);
349+
});
350+
351+
it('no identity action identifies its record by a field the server never serves', () => {
352+
// An action seeds `recordIdParam` from the ROW the console holds. An
353+
// `internal` field is omitted from every read, so it can never be on that
354+
// row, and an action keyed on one can never name its record.
355+
const objects = [
356+
SysAccount, SysApiKey, SysBusinessUnit, SysBusinessUnitMember, SysDeviceCode,
357+
SysInvitation, SysJwks, SysMember, SysOauthAccessToken, SysOauthApplication,
358+
SysOauthRefreshToken, SysOrganization, SysSession, SysSsoProvider, SysTeam,
359+
SysTeamMember, SysTwoFactor, SysUser, SysUserPreference, SysVerification,
360+
];
361+
const offenders: string[] = [];
362+
let seeded = 0;
363+
for (const object of objects) {
364+
for (const action of object.actions ?? []) {
365+
if (!action.recordIdParam && !action.recordIdField) continue;
366+
seeded += 1;
367+
const field = action.recordIdField ?? 'id';
368+
const declared = (object.fields as Record<string, { internal?: unknown } | undefined>)[field];
369+
if (field !== 'id' && !declared) offenders.push(`${object.name}.${action.name}: '${field}' is not a field`);
370+
if (declared?.internal === true) offenders.push(`${object.name}.${action.name}: '${field}' is internal`);
371+
}
372+
}
373+
// Positive control: the sweep reaches real declarations.
374+
expect(seeded).toBeGreaterThan(0);
375+
expect(offenders).toEqual([]);
376+
});
330377
});
331378

332379
describe('data portability — derived, not declared (#3025 / #3543)', () => {

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js
7171
import { runSetInitialPassword } from './set-initial-password.js';
7272
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
7373
import { runResendVerificationEmail } from './send-verification-email.js';
74+
import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js';
7475
import { readJsonRequestBody } from './request-json-body.js';
7576
import type { CounterStore } from './rate-limit-storage.js';
7677
import { authIdentityManifest } from './manifest.js';
@@ -3550,6 +3551,30 @@ export class AuthPlugin implements Plugin {
35503551
}
35513552
});
35523553

3554+
// ────────────────────────────────────────────────────────────────────
3555+
// Revoke ONE session, named by its `sys_session.id` — the door behind the
3556+
// `sys_session` Revoke Session row action (Setup → Sessions, Account →
3557+
// Active Sessions). The server resolves the session from the id, so the
3558+
// session token never travels to or from the console. The owner lane
3559+
// re-dispatches to better-auth's own `/revoke-session`; another user's
3560+
// session is revocable by a platform administrator only (ADR-0068 D2
3561+
// judge). Same family as `sys-oauth-application/register` above: an
3562+
// object-scoped mount outside `/admin/`, because it admits the owner.
3563+
// See `sys-session-revoke.ts` for the lanes and refusals.
3564+
rawApp.post(`${basePath}/sys-session/revoke`, async (c: any) => {
3565+
try {
3566+
const { status, body } = await runSysSessionRevoke(
3567+
sysSessionRevokeDepsFor(this.authManager!, basePath),
3568+
c.req.raw,
3569+
);
3570+
return c.json(body, status as any);
3571+
} catch (error) {
3572+
const err = error instanceof Error ? error : new Error(String(error));
3573+
ctx.logger.error('[AuthPlugin] sys-session/revoke failed', err);
3574+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
3575+
}
3576+
});
3577+
35533578
// ────────────────────────────────────────────────────────────────────
35543579
// Self-service resend of the email-verification link. SHADOWS better-auth's
35553580
// native `/send-verification-email` (registered before the catch-all below).

‎packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ describe('auth route ledger hygiene', () => {
232232
'POST /api/v1/auth/organization/add-member',
233233
'POST /api/v1/auth/set-initial-password',
234234
'POST /api/v1/auth/sys-oauth-application/register',
235+
// The fourteenth, on this pin's own terms: `auth-plugin.ts` mounts it
236+
// itself (a `rawApp.post` on the `${basePath}/sys-session/revoke`
237+
// template, ahead of the catch-all), and the `live.has(route)` loop
238+
// below holds it to the same proof — better-auth serves no by-id revoke.
239+
'POST /api/v1/auth/sys-session/revoke',
235240
]);
236241
for (const route of own) {
237242
expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false);

‎packages/plugins/plugin-auth/src/auth-route-ledger.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
243243
{ route: 'POST /api/v1/auth/admin/set-user-manager', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user set_user_manager action posts it directly; sets or clears sys_user.manager_id, which drives the approvals `manager` rung and the own_and_reports scope and had no product write surface at all (the ADR-0092 D2 managed-update whitelist is {name,image,locale}). Runs under a system context so it reaches the column BY CONTEXT, not by whitelist — no Tier-1 list moves and the column keeps readonly: true; platform-admin gated (ADR-0068), ADR-0057 / ADR-0092' },
244244
{ route: 'POST /api/v1/auth/admin/unlock-user', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user unlock_user action posts it directly; clears a brute-force lockout (sys_user.locked_until / failed_login_count), a custom per-identity mechanism with no better-auth endpoint; platform-admin gated (ADR-0068), ADR-0069 D2' },
245245
{ route: 'POST /api/v1/auth/sys-oauth-application/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application create action posts it directly; session-required self-service wrapper over better-auth /oauth2/create-client that splits the Console\'s newline-separated redirect-URL textarea into the redirect_uris array the vendor schema requires' },
246+
{ route: 'POST /api/v1/auth/sys-session/revoke', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_session revoke_session row action posts it directly with the row id; the server resolves the session from the id, so the session token never travels to or from the console. The session owner is re-dispatched to better-auth /revoke-session; another user\'s session is revocable by a platform admin only (ADR-0068 D2), tombstoned with reason admin (ADR-0069 D4)' },
246247
{ route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' },
247248
// #9941 — better-auth declares `addMember` with NO HTTP path (server-only
248249
// `auth.api.addMember`; measured on the installed 1.7.1), so the catch-all

‎packages/plugins/plugin-auth/src/session-tombstone.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,10 @@ import { SystemObjectName } from '@objectstack/spec/system';
185185
* the session. Recording `admin` for a user clicking "sign out my other
186186
* devices" would not be a vague audit record, it would be a **wrong** one — it
187187
* names an actor class that took no action, which is worse for an auditor than
188-
* no record at all. `admin` therefore means exactly the two `/admin/*` routes,
189-
* and `user_revoked` means the session's own owner did it.
188+
* no record at all. `admin` therefore means exactly the two `/admin/*` routes
189+
* — plus the administrator lane of `sys-session-revoke.ts`, which writes this
190+
* ledger's `admin` value itself — and `user_revoked` means the session's own
191+
* owner did it.
190192
*/
191193
export const INTERACTIVE_REVOKE_REASON: Readonly<Record<string, string>> = {
192194
// Self-service — the session owner acting on their own sessions.

0 commit comments

Comments
 (0)