Skip to content

Commit 7b2bb66

Browse files
fix(plugin-auth): a session a password reset ends leaves a revocation trail (#29921)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:自助重置密码结束的会话,不再被直接删除,而是保留会话行并记下 `revoked_at` 与 `revoke_reason: password_reset`,与交互式撤销会话留下的记录同类;该会话照旧被拒(401)。 - 为什么改:重置密码结束的会话此前不留任何撤销记录,审计查不到"这个会话是因重置密码而结束"。记录值与管理员强制重置的 `admin_password_reset` 不同,从记录即可分辨是谁结束了会话。 - 风险与回滚:重置后会话行保留(与交互式撤销一样,`sys_session` 已有的保留方式),不改变哪些会话被结束,不撤销 API key,不写 `logout` 审计行。回滚 = 还原本 PR,无数据迁移。 - 先看:`packages/plugins/plugin-auth/src/session-tombstone.ts`(`INTERACTIVE_REVOKE_REASON` 新增三行重置入口)、`packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts`(新增的 describe)、`packages/platform-objects/src/identity/sys-session.object.ts`(`revoke_reason` 说明里的取值)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/password-reset-ends-sessions.test.ts`,预期全部通过,其中 "a session a password reset ends leaves a revocation trail" 下三个用例通过。 - 需要你做的:无,席位落地。 ## What changes **A session a password reset ends leaves a revocation trail.** - `INTERACTIVE_REVOKE_REASON` (`plugin-auth/src/session-tombstone.ts`) gains every reset door with the value `password_reset`: `/reset-password` (the emailed link), `/phone-number/reset-password` (the phone OTP reset), and `/email-otp/reset-password` (better-auth's email-OTP reset door, on its own path; the platform does not mount that plugin today, so the entry is keyed on the plugin's declared path and pinned against it). A completed reset now keeps each session row it ends, stamped `expires_at` a second in the past, `revoked_at`, and `revoke_reason: password_reset`, instead of deleting it; the session is still refused. - The value differs from `user_revoked` (the owner revoking their own sessions) and from `admin_password_reset`, which an administrator's forced reset keeps writing itself (`forced-reset-revocation.ts`, not through this ledger), so the row still says who ended the session. The ledger's docblock and the `password-reset-ends-sessions.ts` header now say what is true. - The reset doors are revocations, not erasures: they are not in `SESSION_ERASURE_PATHS`, so their tombstones stay hidden from better-auth's session reads like every other. - `sys_session.revoke_reason`'s description, the field's published vocabulary, names `password_reset` (`platform-objects`), with the four translated help strings (`en` regenerated, `zh-CN` / `ja-JP` / `es-ES` hand-written values extended the same way). - `docs/HARDENING.md`: the session table's revocation row said the reset path deletes the row; it now says sign-out deletes, and a revoke or a completed reset keeps the row stamped. - Out of scope by the ruling, and unchanged: no API key is revoked by a self-service reset, and no `logout` audit row is written. ## Tests - `password-reset-ends-sessions.test.ts`, new top-level describe "a session a password reset ends leaves a revocation trail", over the file's existing real-engine harness (`AuthManager.handleRequest`, `ObjectQL` over better-sqlite3): - the emailed-link reset stamps every earlier session `password_reset`, and the session still answers 401 `UNAUTHENTICATED`; - the phone OTP reset does the same; - the control: an administrator's forced reset (`runAdminSetUserPassword`, with the deps `auth-plugin.ts` hands it, over the same engine) still stamps `admin_password_reset`. - `session-tombstone.test.ts`: every reset door records `password_reset`, and no ledger route writes the forced reset's value; the mounted-route check now also reads the phone-number family off a manager that mounts it; the email-OTP door is pinned to the vendor plugin's declared path, with a tripwire that it is not mounted today. - `forced-reset-revoke-reason-vocabulary.test.ts`: the published vocabulary names every value the interactive-revoke ledger writes. ## Acceptance notes - The vendor's bulk delete does not bypass the per-row reconcile on these doors, measured on better-auth 1.7.12: `resetCredentialPassword` calls `internalAdapter.deleteUserSessions`, which with no `secondaryStorage` is `deleteManyWithHooks`, which calls this adapter's `deleteMany` inside the endpoint's context; outside a SCIM request the adapter's `transaction` hands the callback this same adapter. The two door tests are the measurement. - Other vendor routes that bulk-end a user's sessions outside both ledgers still delete their rows, `/change-password` with `revokeOtherSessions` among them. Not touched; the ruling covers the reset doors only. ## Verification All readings below are on head `c8b82289a5` (the branch with `origin/main` merged), unless a line names another commit. Heavy runs went through `scripts/pm/os-verify-lock.sh` on a shared box. <details><summary>Tests: targeted, full suites, typecheck</summary> - Build state refreshed after the merge: `pnpm install --frozen-lockfile` exit 0; closure build (`turbo run build` over plugin-auth, platform-objects, the CLI and the i18n gate's packages) exit 0; `pnpm --filter @objectstack/spec check:generated` exit 0, "All 14 generated artifacts are up to date". - Targeted: `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/password-reset-ends-sessions.test.ts src/session-tombstone.test.ts src/forced-reset-revoke-reason-vocabulary.test.ts` exit 0, Test Files 3 passed (3), Tests 34 passed (34). - `@objectstack/plugin-auth` full suite (`vitest run --maxWorkers=2`): exit 0, Test Files 219 passed (219), Tests 3766 passed, 10 skipped (3776). - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (check:test-typecheck OK, ledger held). - `@objectstack/platform-objects` suite: exit 0, Test Files 110 passed (110), Tests 1512 passed (1512); its `typecheck`: exit 0. - The same set was green before the merge too, on `78fd3bcf11`. </details> <details><summary>One-shot ablation (on 78fd3bc)</summary> Mutation: the three reset-door rows taken back out of `INTERACTIVE_REVOKE_REASON` (the ledger as `origin/main` has it), through `scripts/ablation-replace.mjs` (anchor hit 1 time, blob 7d3ba6a to d138b54ac6d4; `'password_reset'` rows on disk 3 to 0). The subject is imported from `src/` by relative path, so no build sits between the mutation and the run. - Expected direction: red. Observed: Test Files 3 failed (3), Tests 4 failed, 30 passed (34). Red: the emailed-link door and the phone OTP door (the earlier session row is gone: `kept: false`), the ledger's reset-door test, and the vocabulary test. The forced-reset control stayed green. - Restore: `git checkout HEAD --` on the absolute path, blob back to 7d3ba6a equal to HEAD, `git diff HEAD` empty, rows on disk 3. </details> <details><summary>Derived gates (dispatch-gates, 64 commands)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 commands, every one run on `c8b82289a5` with its exit code captured before any pipe. 62 exit 0, including `pnpm check:i18n` ("all bundles in sync": the hand-written `en` help string matches the generator's output) and `pnpm check:engine-double-contract`. `--ran` reconciliation: 64 derived, 62 run, 2 NOT-MEASURED, 0 UNRUN. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: exit 3, PREREQUISITE NOT MET, it reads the built output of every workspace package and 10 have no `dist/` in this worktree. CI's Lint & Repo Gates runs it. - NOT MEASURED: `pnpm check:type-check-debt`, reason: exit 3, its re-measure build never got a turn on the shared verify lock (another session's full dogfood run held it). Both changed packages' own `typecheck` (test layer included) is green above. CI runs the gate. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40a35de commit 7b2bb66

12 files changed

Lines changed: 261 additions & 21 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(plugin-auth): a session a password reset ends leaves a revocation trail
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **A session a password reset ends leaves a revocation trail.** When a completed self-service password reset ends the account's sessions — the emailed link (`POST /api/v1/auth/reset-password`), and the phone OTP reset (`POST /api/v1/auth/phone-number/reset-password`); better-auth's email-OTP reset door, which the platform does not mount, is covered on its own path too — each `sys_session` row is now kept and stamped, `revoked_at` with `revoke_reason: password_reset`, the same kind of trail an interactive revoke leaves, instead of being deleted. The session is still refused (`401`).
12+
- **The value says who ended the session.** `password_reset` is distinct from `user_revoked` (the owner revoking their own sessions) and from `admin_password_reset`, which an administrator's forced reset keeps writing.
13+
- **`sys_session.revoke_reason`'s published vocabulary names `password_reset`** (`@objectstack/platform-objects`, the field's description and its translated help).
14+
- **Unchanged.** Which sessions a reset ends; sign-out, which still deletes its row; the account's API keys, which a self-service reset does not revoke; and the audit log, which records no `logout` row for a reset.

‎docs/HARDENING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ ObjectStack uses [better-auth](https://www.better-auth.com/) via
260260
| Session TTL | 7 days | `session.expiresIn` (seconds) |
261261
| Access token TTL | inherited from session | configure in better-auth |
262262
| Refresh | better-auth `/auth/get-session` rolls TTL | `session.updateAge` (seconds) |
263-
| Revocation | DELETE on `session` row; a completed password reset ends every session of the account | — always on (`revokeSessionsOnPasswordReset: false` is refused at boot) |
263+
| Revocation | Sign-out deletes the `session` row. A revoke, and a completed password reset (which ends every session of the account), keep the row stamped `revoked_at` / `revoke_reason` (`password_reset` for the reset) and refuse it | — always on (`revokeSessionsOnPasswordReset: false` is refused at boot) |
264264
| Idle / absolute / concurrent caps | Off (opt-in) | `session_idle_timeout_minutes` / `session_absolute_max_hours` / `max_concurrent_sessions_per_user` (Setup → Authentication) |
265265
| Email verify TTL | 1 hour | `emailVerification.expiresIn` |
266266

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "Revoke Reason",
372-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, …)."
372+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
373373
},
374374
active_organization_id: {
375375
label: "Active Organization"

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "Motivo de la revocación",
372-
help: "Por qué se revocó la sesión (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, …)."
372+
help: "Por qué se revocó la sesión (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
373373
},
374374
active_organization_id: {
375375
label: "Organización activa"

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "失効理由",
372-
help: "セッションが失効した理由(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset など)。"
372+
help: "セッションが失効した理由(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset など)。"
373373
},
374374
active_organization_id: {
375375
label: "アクティブ組織"

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "撤销原因",
372-
help: "会话被撤销的原因(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset 等)。"
372+
help: "会话被撤销的原因(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset 等)。"
373373
},
374374
active_organization_id: {
375375
label: "当前组织"

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,12 @@ export const SysSession = ObjectSchema.create({
240240
// `admin_password_reset` is the second authorization-event cause: an
241241
// administrator's forced password reset revoked the session
242242
// (`plugin-auth/forced-reset-revocation.ts`, `FORCED_RESET_REVOKE_REASON`).
243+
// `password_reset` is its self-service counterpart: a completed password
244+
// reset ended the session (the reset doors in `INTERACTIVE_REVOKE_REASON`,
245+
// `plugin-auth/session-tombstone.ts`).
243246
description:
244247
'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, '
245-
+ 'admin, organization_membership_ended, admin_password_reset, …).',
248+
+ 'admin, organization_membership_ended, admin_password_reset, password_reset, …).',
246249
}),
247250

248251
// ── Active context (multi-org/team) ──────────────────────────

‎packages/plugins/plugin-auth/src/forced-reset-revoke-reason-vocabulary.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { SysApiKey, SysSession } from '@objectstack/platform-objects/identity';
33
import { FORCED_RESET_REVOKE_REASON } from './forced-reset-revocation';
4+
import { INTERACTIVE_REVOKE_REASON } from './session-tombstone';
45
import { managedExtensionEditableFields, managedExtensionFields } from './managed-extension-fields';
56

67
// `sys_session.revoke_reason` is free text; its description is the only
@@ -11,6 +12,15 @@ describe('sys_session.revoke_reason vocabulary', () => {
1112
const description = String((SysSession.fields as any).revoke_reason.description);
1213
expect(description).toContain(FORCED_RESET_REVOKE_REASON);
1314
});
15+
16+
it('names every value the interactive-revoke ledger writes, the password-reset doors\' included', () => {
17+
const description = String((SysSession.fields as any).revoke_reason.description);
18+
const written = [...new Set(Object.values(INTERACTIVE_REVOKE_REASON))].sort();
19+
expect(written).toEqual(['admin', 'password_reset', 'user_revoked']);
20+
for (const value of written) {
21+
expect({ value, named: new RegExp(`\\b${value}\\b`).test(description) }).toEqual({ value, named: true });
22+
}
23+
});
1424
});
1525

1626
// `sys_api_key.revoked_reason` is a closed select: the forced reset is its one

‎packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts‎

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@
2121
* sessions on every reset door;
2222
* - an explicit `revokeSessionsOnPasswordReset: false` is refused at
2323
* construction and on a configuration patch, where the standing
24-
* configuration keeps ruling; `true` is accepted.
24+
* configuration keeps ruling; `true` is accepted;
25+
* - a session a password reset ends leaves a revocation trail: on each reset
26+
* door every session the account held is still a `sys_session` row
27+
* afterwards, stamped `revoked_at` and `revoke_reason: password_reset`, and
28+
* it still answers 401; the control, an administrator's forced reset over
29+
* the same engine, stamps `admin_password_reset` instead.
2530
*/
2631

2732
import { describe, it, expect, afterAll, afterEach, vi } from 'vitest';
2833
import { ObjectQL } from '@objectstack/objectql';
2934
import { SqlDriver } from '@objectstack/driver-sql';
3035
import { AuthManager } from './auth-manager.js';
36+
import { runAdminSetUserPassword, type AdminUserEndpointDeps } from './admin-user-endpoints.js';
3137
import { authIdentityObjects } from './manifest.js';
3238
import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js';
3339
import { memoizeScrypt } from './__tests__/scrypt-memo.testkit';
@@ -209,6 +215,116 @@ describe("a completed phone OTP reset ends the account's other sessions", () =>
209215
}, 60_000);
210216
});
211217

218+
describe('a session a password reset ends leaves a revocation trail', () => {
219+
type SessionRow = { id: string; expires_at?: unknown; revoked_at?: unknown; revoke_reason?: unknown };
220+
221+
/** The account's `sys_session` rows, read off the table the way an auditor reads them. */
222+
async function sessionRowsOf(engine: ObjectQL): Promise<SessionRow[]> {
223+
const [user] = (await engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>;
224+
return (await engine.find('sys_session', { where: { user_id: user.id } }, SYS)) as SessionRow[];
225+
}
226+
227+
/** The sessions the account holds before the reset: at least one, none of them ended. */
228+
async function liveSessionsBefore(a: Account): Promise<SessionRow[]> {
229+
const before = await sessionRowsOf(a.engine);
230+
expect(before.length).toBeGreaterThan(0);
231+
expect(before.map((row) => row.revoked_at ?? null)).toEqual(before.map(() => null));
232+
return before;
233+
}
234+
235+
/**
236+
* Every session in `before` is still a row, stamped with when and why it
237+
* ended, and the session signed in before the reset is refused.
238+
*/
239+
async function expectTrail(a: Account, before: SessionRow[], reason: string, startedAt: number): Promise<void> {
240+
const after = new Map((await sessionRowsOf(a.engine)).map((row) => [row.id, row]));
241+
for (const { id } of before) {
242+
const row = after.get(id);
243+
expect({ id, kept: row !== undefined }).toEqual({ id, kept: true });
244+
expect(row!.revoke_reason).toBe(reason);
245+
const revokedAt = new Date(row!.revoked_at as string).getTime();
246+
expect(Number.isFinite(revokedAt)).toBe(true);
247+
// Whole-second storage may round the stamp down, never ahead of now.
248+
expect(revokedAt).toBeGreaterThanOrEqual(startedAt - 1000);
249+
expect(revokedAt).toBeLessThanOrEqual(Date.now());
250+
expect(new Date(row!.expires_at as string).getTime()).toBeLessThan(Date.now());
251+
}
252+
expect(await answer(await getSession(a.manager, a.earlier))).toEqual({ status: 401, code: 'UNAUTHENTICATED' });
253+
}
254+
255+
it('the emailed-link reset (/reset-password) stamps every earlier session password_reset, and the session still answers 401', async () => {
256+
const a = await signedInAccount();
257+
const token = await requestResetToken(a);
258+
const before = await liveSessionsBefore(a);
259+
const startedAt = Date.now();
260+
261+
const reset = await post(a.manager, '/reset-password', { token, newPassword: SECOND });
262+
expect(reset.status, await reset.clone().text()).toBe(200);
263+
264+
await expectTrail(a, before, 'password_reset', startedAt);
265+
}, 60_000);
266+
267+
it('the phone OTP reset (/phone-number/reset-password) stamps every earlier session password_reset, and the session still answers 401', async () => {
268+
const PHONE = '+15550143';
269+
const a = await signedInAccount({ plugins: { phoneNumber: true } });
270+
const texts: Array<{ to: string; templateParams?: { code?: string } }> = [];
271+
a.manager.setSmsService({
272+
async send(input: { to: string; templateParams?: { code?: string } }) {
273+
texts.push(input);
274+
return { id: `sms${texts.length}`, status: 'sent' };
275+
},
276+
isConfigured: () => true,
277+
} as never);
278+
const [user] = (await a.engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>;
279+
await a.engine.update('sys_user', { id: user.id, phone_number: PHONE, phone_number_verified: true }, SYS);
280+
const requested = await post(a.manager, '/phone-number/request-password-reset', { phoneNumber: PHONE });
281+
expect(requested.status, await requested.clone().text()).toBe(200);
282+
await vi.waitFor(() => expect(texts.map((t) => t.to)).toEqual([PHONE]));
283+
const otp = String(texts[0].templateParams?.code ?? '');
284+
expect(otp, 'the reset text carries its code').not.toBe('');
285+
const before = await liveSessionsBefore(a);
286+
const startedAt = Date.now();
287+
288+
const reset = await post(a.manager, '/phone-number/reset-password', { phoneNumber: PHONE, otp, newPassword: SECOND });
289+
expect(reset.status, await reset.clone().text()).toBe(200);
290+
291+
await expectTrail(a, before, 'password_reset', startedAt);
292+
}, 60_000);
293+
294+
it("the control: an administrator's forced reset still stamps admin_password_reset, so the value says who ended the session", async () => {
295+
const a = await signedInAccount();
296+
const before = await liveSessionsBefore(a);
297+
const [user] = (await a.engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>;
298+
const m = a.manager;
299+
const quiet = { warn() {}, info() {}, error() {}, debug() {} };
300+
// The deps `auth-plugin.ts` hands the forced-reset door, built from the same manager.
301+
const deps = {
302+
getAuthApi: () => m.getApi(),
303+
getAuthContext: () => m.getAuthContext(),
304+
getDataEngine: () => m.getDataEngine(),
305+
assertPasswordComplexity: (pw: string) => m.checkPasswordComplexity(pw),
306+
noteMustChangePasswordIssued: () => m.noteMustChangePasswordIssued(),
307+
phoneNumberEnabled: () => m.isPhoneNumberEnabled(),
308+
getMembershipPolicy: () => m.getMembershipPolicy(),
309+
logger: quiet,
310+
} as unknown as AdminUserEndpointDeps;
311+
const startedAt = Date.now();
312+
313+
const forced = await runAdminSetUserPassword(
314+
deps,
315+
new Request(`${AUTH}/admin/set-user-password`, {
316+
method: 'POST',
317+
headers: { 'Content-Type': 'application/json', origin: BASE },
318+
body: JSON.stringify({ userId: user.id, newPassword: SECOND }),
319+
}),
320+
{ id: 'admin-control', email: 'admin-control@example.com' },
321+
);
322+
expect(forced.status, JSON.stringify(forced.body)).toBe(200);
323+
324+
await expectTrail(a, before, 'admin_password_reset', startedAt);
325+
}, 60_000);
326+
});
327+
212328
describe('revokeSessionsOnPasswordReset: false is refused, never silently overridden', () => {
213329
it('is refused at construction', () => {
214330
expect(

‎packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,19 @@
2424
* (`auth-manager-auth-instance.ts`), and every door that sets a password from a
2525
* reset token or code inherits the rule without a door of its own.
2626
*
27-
* What an ended session leaves behind is not what the forced reset leaves. The
28-
* reset paths are in neither the interactive-revoke ledger nor the erasure
29-
* paths (`session-tombstone.ts`), so the session rows are deleted, not
30-
* stamped: no `revoked_at` / `revoke_reason` trail is written. No `logout`
31-
* audit row is written either — `logoutEventFor` (`auth-session-audit.ts`)
32-
* records a `/sign-out` alone. And the account's API keys are untouched; the
33-
* forced reset's key revocation is its own switch.
27+
* A session a password reset ends leaves a revocation trail. Every reset door
28+
* — the two above, and the email-OTP door, unmounted, on its own path — is in
29+
* the interactive-revoke ledger (`session-tombstone.ts`,
30+
* `INTERACTIVE_REVOKE_REASON`) with the value `password_reset`, and is not an
31+
* erasure path. better-auth's bulk delete reaches the adapter's per-row
32+
* reconcile, so each session row is stamped in place rather than deleted —
33+
* `expires_at` a second in the past, `revoked_at`, `revoke_reason:
34+
* password_reset` — and is hidden from better-auth's session reads, so the
35+
* session answers 401. The value is not the forced reset's
36+
* `admin_password_reset`: the row says who ended the session. No `logout`
37+
* audit row is written — `logoutEventFor` (`auth-session-audit.ts`) records a
38+
* `/sign-out` alone. And the account's API keys are untouched; the forced
39+
* reset's key revocation is its own switch.
3440
*
3541
* ## The declared key
3642
*

0 commit comments

Comments
 (0)