From 0f9dab0601f86a3451e4ae433e7461ac6eb129ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 15:50:14 +0000 Subject: [PATCH 1/5] feat(spec)!: retire emailAndPassword.revokeSessionsOnPasswordReset A completed password reset always ends every session the account holds, and there is no setting. The key becomes a retiredKey() tombstone with an exported prescription; plugin-auth's entry guard refuses the retired key at any value with the same text, since the manager takes its configuration unparsed. Registers the retired key and the D3 semantic entry, adds the step-18 rationale fragment, a retirement pin with a tree-scoped absence scan, and updates HARDENING.md and the authentication docs. Claude-Session: https://claude.ai/code/session_01WGufzgjTaf6BB6T3vxdisU Co-authored-by: Claude --- ...voke-sessions-on-password-reset-retired.md | 19 ++ content/docs/permissions/authentication.mdx | 6 +- docs/HARDENING.md | 4 +- .../src/auth-manager-auth-instance.ts | 6 +- .../plugin-auth/src/auth-manager.test.ts | 5 +- .../plugins/plugin-auth/src/auth-manager.ts | 11 +- .../src/password-reset-ends-sessions.test.ts | 70 +++-- .../src/password-reset-ends-sessions.ts | 55 ++-- ...rdConfig__revokeSessionsOnPasswordReset.ts | 11 + ...ssword-revoke-sessions-on-reset-retired.ts | 30 +++ .../spec/src/migrations/registry.ts.template | 12 + packages/spec/src/system/auth-config.test.ts | 5 +- packages/spec/src/system/auth-config.zod.ts | 38 ++- ...sions-on-password-reset-retirement.test.ts | 243 ++++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 15 files changed, 445 insertions(+), 71 deletions(-) create mode 100644 .changeset/29909-revoke-sessions-on-password-reset-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts create mode 100644 packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts diff --git a/.changeset/29909-revoke-sessions-on-password-reset-retired.md b/.changeset/29909-revoke-sessions-on-password-reset-retired.md new file mode 100644 index 00000000000..54236248a92 --- /dev/null +++ b/.changeset/29909-revoke-sessions-on-password-reset-retired.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-auth': minor +--- + +feat(spec)!: retire `emailAndPassword.revokeSessionsOnPasswordReset` — a completed password reset always ends every session the account holds, and there is no setting (#29909) + +Clause-②: no (narrowing) — `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` becomes a `retiredKey()` tombstone, so the schema parse and `tsc` refuse it at any value, and plugin-auth's entry guard refuses it at any value too, `true` included, which it accepted before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** for a configuration that still writes `emailAndPassword.revokeSessionsOnPasswordReset`, at any value. + +- **`@objectstack/spec`.** `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` is retired (ADR-0049 enforce-or-remove). A completed self-service password reset already ended every session the account holds, on every reset door, and an explicit `false` was already refused at boot, so the key could hold one value only. It is now a tombstone: a typed configuration that writes it fails `tsc`, and `AuthConfigSchema` / `EmailAndPasswordConfigSchema` refuse it with the prescription. The authorable-surface row reads `[RETIRED]`, `RETIRED_KEYS_BY_MAJOR[18]` lists `system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset`, and the upgrade guide carries the semantic entry `email-and-password-revoke-sessions-on-reset-retired`. The prescription is exported as `REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED` (`@objectstack/spec/system`). No ADR-0087 conversion: the key lives in the configuration a host hands the auth plugin, never in a stack or a stored row, so `os migrate meta` has nothing to rewrite. +- **`@objectstack/plugin-auth`.** The auth manager takes its configuration unparsed, so it refuses the retired key itself, where the configuration enters: at construction, which stops the boot, and on a configuration patch, where the standing configuration keeps ruling. It now refuses `true` as well as `false`, with the tombstone's own prescription. `true` asked for what the platform does anyway; the key is to be deleted either way. The auth instance still sets better-auth's own option of that name itself, so a completed reset ends the account's sessions exactly as before. +- **Unchanged.** What a password reset does: every self-service reset door ends every session the account holds, and each ended session is stamped `revoke_reason: password_reset`. An absent key is accepted, as before. + +**The remedy.** Delete `revokeSessionsOnPasswordReset` from the `emailAndPassword` block of the auth configuration, whatever it holds; nothing replaces it. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 15b6ec68525..343c6f0c17f 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -309,8 +309,10 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password', A completed reset ends every existing session of the account — the emailed link and the phone OTP reset alike, the same as an administrator's forced reset — so -the user signs in again with the new password. It is not a setting: -`emailAndPassword.revokeSessionsOnPasswordReset: false` is refused at boot. +the user signs in again with the new password. It is not a setting: the former +`emailAndPassword.revokeSessionsOnPasswordReset` key is retired, and a +configuration that still carries it, at any value, is refused at boot — delete +the key. #### Setting a first local password diff --git a/docs/HARDENING.md b/docs/HARDENING.md index a3972c61228..5a883c306fd 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -260,7 +260,7 @@ ObjectStack uses [better-auth](https://www.better-auth.com/) via | Session TTL | 7 days | `session.expiresIn` (seconds) | | Access token TTL | inherited from session | configure in better-auth | | Refresh | better-auth `/auth/get-session` rolls TTL | `session.updateAge` (seconds) | -| 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) | +| 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 | — unconditional; no setting (the retired `emailAndPassword.revokeSessionsOnPasswordReset` key is refused at any value) | | Idle / absolute / concurrent caps | Off (opt-in) | `session_idle_timeout_minutes` / `session_absolute_max_hours` / `max_concurrent_sessions_per_user` (Setup → Authentication) | | Email verify TTL | 1 hour | `emailVerification.expiresIn` | @@ -277,7 +277,7 @@ curl -H "Authorization: Bearer $TOK" $API/data/account # → 401 Unauthorized # 3. Confirm a completed password reset ends the user's other sessions -# (always on: a session signed in before the reset answers 401 after it) +# (unconditional: a session signed in before the reset answers 401 after it) ``` ## CORS diff --git a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts index 70a715ad019..91aabc66a4f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts @@ -384,8 +384,10 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise { maxPasswordLength: 64, requireEmailVerification: true, autoSignIn: false, - revokeSessionsOnPasswordReset: true, }, }); await manager.getAuthInstance(); @@ -2069,8 +2068,10 @@ describe('AuthManager', () => { maxPasswordLength: 64, requireEmailVerification: true, autoSignIn: false, - revokeSessionsOnPasswordReset: true, }); + // better-auth's own option, set by the auth instance whatever the host + // passes (the spec key of that name is retired; the manager refuses it). + expect(capturedConfig.emailAndPassword.revokeSessionsOnPasswordReset).toBe(true); expect(typeof capturedConfig.emailAndPassword.sendResetPassword).toBe('function'); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ec7662b7a29..7c46b7cb160 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1606,9 +1606,10 @@ export class AuthManager { assertScimAdminCoherence(config.plugins); // A completed password reset ends the account's other sessions, always; - // an explicit `revokeSessionsOnPasswordReset: false` is refused here, at - // boot, rather than silently overridden (`password-reset-ends-sessions.ts`). - // `applyConfigPatch` re-runs it on the merged result. + // the retired `emailAndPassword.revokeSessionsOnPasswordReset` is refused + // here at any value, at boot, rather than silently ignored + // (`password-reset-ends-sessions.ts`). `applyConfigPatch` re-runs it on the + // merged result. assertPasswordResetEndsSessions(config.emailAndPassword); // The mobile sign-in hand-off's registered app schemes — same @@ -2083,8 +2084,8 @@ export class AuthManager { assertScimAdminCoherence(next.plugins); } // Same entry validation the constructor runs, on the MERGED block: a patch - // cannot ask a completed password reset to keep the account's other - // sessions. Refused loudly and the standing config keeps ruling. + // cannot write the retired `revokeSessionsOnPasswordReset`, at any value. + // Refused loudly and the standing config keeps ruling. if ('emailAndPassword' in patch) { assertPasswordResetEndsSessions(next.emailAndPassword); } diff --git a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts index 71130f61f13..85822ab4936 100644 --- a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts +++ b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts @@ -19,9 +19,10 @@ * - a reset that does not complete (a token that does not match) ends nothing; * - with no configuration at all, the auth instance asks better-auth to end * sessions on every reset door; - * - an explicit `revokeSessionsOnPasswordReset: false` is refused at - * construction and on a configuration patch, where the standing - * configuration keeps ruling; `true` is accepted; + * - the retired `revokeSessionsOnPasswordReset` is refused at any value, with + * the spec tombstone's prescription, at construction and on a + * configuration patch, where the standing configuration keeps ruling; an + * absent key passes; * - a session a password reset ends leaves a revocation trail: on each reset * door every session the account held is still a `sys_session` row * afterwards, stamped `revoked_at` and `revoke_reason: password_reset`, and @@ -35,6 +36,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AuthManager } from './auth-manager.js'; import { runAdminSetUserPassword, type AdminUserEndpointDeps } from './admin-user-endpoints.js'; import { authIdentityObjects } from './manifest.js'; +import { REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED } from '@objectstack/spec/system'; import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js'; import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; import { sqliteEnginePool } from './__tests__/sqlite-engine-pool.testkit'; @@ -51,6 +53,8 @@ const EMAIL = 'reset-ends-sessions@example.com'; const FIRST = 'F1rst!Password-For-Reset'; const SECOND = 'S3cond!Password-After-Reset'; const SYS = { context: { isSystem: true } } as never; +/** The retired configuration key, spelled once. */ +const RETIRED = 'revokeSessionsOnPasswordReset'; const engines = sqliteEnginePool(async () => { const engine = new ObjectQL(); @@ -325,31 +329,49 @@ describe('a session a password reset ends leaves a revocation trail', () => { }, 60_000); }); -describe('revokeSessionsOnPasswordReset: false is refused, never silently overridden', () => { - it('is refused at construction', () => { - expect( - () => - new AuthManager({ - secret: SECRET, - baseUrl: BASE, - emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: false }, - } as never), - ).toThrow(/revokeSessionsOnPasswordReset: false is not supported/); - }); +describe('the retired revokeSessionsOnPasswordReset is refused at any value, never silently ignored', () => { + // The manager takes its configuration unparsed, so the spec tombstone never + // sees a host's object: this guard answers it with the tombstone's own text. + const PRESCRIPTION = + /^\[auth\] `emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — .*Delete the key; nothing replaces it\.$/s; - it('is refused on a configuration patch, and the standing configuration keeps ruling', () => { - const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never); - expect(() => - manager.applyConfigPatch({ emailAndPassword: { revokeSessionsOnPasswordReset: false, minPasswordLength: 20 } } as never), - ).toThrow(/revokeSessionsOnPasswordReset: false is not supported/); - const standing = (manager as unknown as { config: { emailAndPassword?: Record } }).config.emailAndPassword; - expect(standing?.minPasswordLength).toBe(10); - expect(standing && 'revokeSessionsOnPasswordReset' in standing).toBe(false); + it('the refusal is the spec tombstone\'s prescription, word for word', () => { + expect(() => assertPasswordResetEndsSessions({ revokeSessionsOnPasswordReset: true })).toThrow( + `[auth] ${REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED}`, + ); }); - it('accepts true and an absent key', () => { - expect(() => assertPasswordResetEndsSessions({ revokeSessionsOnPasswordReset: true })).not.toThrow(); + for (const value of [false, true, 'false', 0, null]) { + it(`is refused at construction: ${JSON.stringify(value)}`, () => { + expect( + () => + new AuthManager({ + secret: SECRET, + baseUrl: BASE, + emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: value }, + } as never), + ).toThrow(PRESCRIPTION); + }); + } + + for (const value of [false, true]) { + it(`is refused on a configuration patch, and the standing configuration keeps ruling: ${value}`, () => { + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never); + expect(() => + manager.applyConfigPatch({ emailAndPassword: { revokeSessionsOnPasswordReset: value, minPasswordLength: 20 } } as never), + ).toThrow(PRESCRIPTION); + const standing = (manager as unknown as { config: { emailAndPassword?: Record } }).config.emailAndPassword; + expect(standing?.minPasswordLength).toBe(10); + expect(standing && RETIRED in standing).toBe(false); + }); + } + + it('CONTROL: an absent key, an undefined one and no block at all pass, and the manager is built', () => { expect(() => assertPasswordResetEndsSessions({})).not.toThrow(); + expect(() => assertPasswordResetEndsSessions({ [RETIRED]: undefined })).not.toThrow(); expect(() => assertPasswordResetEndsSessions(undefined)).not.toThrow(); + expect( + () => new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never), + ).not.toThrow(); }); }); diff --git a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts index 30a91c22e6a..51ab13f3707 100644 --- a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts +++ b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts @@ -38,36 +38,43 @@ * `/sign-out` alone. And the account's API keys are untouched; the forced * reset's key revocation is its own switch. * - * ## The declared key + * ## The retired key * - * `emailAndPassword.revokeSessionsOnPasswordReset` is still declared by the + * `emailAndPassword.revokeSessionsOnPasswordReset` is RETIRED from the * published configuration schema (`@objectstack/spec`, - * `EmailAndPasswordConfigSchema`). `true` is what the platform does anyway and - * is accepted. An explicit `false` asks for something the platform no longer - * does, so {@link assertPasswordResetEndsSessions} REFUSES it where the - * configuration enters the manager — at construction, which stops the boot, and - * on a configuration patch, where the standing configuration keeps ruling — - * rather than overriding it silently: a declaration that reads as in force and - * is not is the ADR-0049 hazard, and refusing is how this plugin treats every - * other declaration it will not honour (`assertAudienceConfig`, - * `assertScimAdminCoherence`). + * `EmailAndPasswordConfigSchema`): it could hold only one value, so it is a + * `retiredKey()` tombstone, and a typed or parsed configuration that writes it + * fails `tsc` or the parse with the prescription. The manager never parses its + * configuration through that schema, so {@link assertPasswordResetEndsSessions} + * holds the same line where the configuration enters the manager — at + * construction, which stops the boot, and on a configuration patch, where the + * standing configuration keeps ruling — and REFUSES the key at any value, with + * the tombstone's own prescription. `true` asked for what the platform does + * anyway and `false` for what it no longer does; either way the key is to be + * deleted, and a declaration that reads as a setting when there is none is the + * ADR-0049 hazard. Refusing is how this plugin treats every other declaration + * it will not honour (`assertAudienceConfig`, `assertScimAdminCoherence`). + * better-auth's own option of the same name is set by the auth instance, never + * read from the host configuration. */ -import type { EmailAndPasswordConfig } from '@objectstack/spec/system'; +import { REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED } from '@objectstack/spec/system'; + +/** The retired configuration key this guard refuses. */ +const RETIRED_KEY = 'revokeSessionsOnPasswordReset'; /** - * Refuse an `emailAndPassword` block that asks a completed password reset to - * keep the account's other sessions. Absent and `true` pass; see the header. + * Refuse an `emailAndPassword` block that still writes the retired + * `revokeSessionsOnPasswordReset`, whatever it holds. An absent key (or one + * holding `undefined`, which the schema's tombstone accepts too) passes; see + * the header. */ -export function assertPasswordResetEndsSessions( - emailAndPassword: Pick, 'revokeSessionsOnPasswordReset'> | undefined, -): void { - if (emailAndPassword?.revokeSessionsOnPasswordReset === false) { - throw new Error( - '[auth] emailAndPassword.revokeSessionsOnPasswordReset: false is not supported: a completed ' + - "password reset always ends the account's other sessions, on every reset door, the same as " + - "an administrator's forced reset. Remove the key (true is accepted and changes nothing). " + - 'Refused rather than ignored, so the configuration never reads as keeping sessions it does not keep.', - ); +export function assertPasswordResetEndsSessions(emailAndPassword: unknown): void { + if ( + emailAndPassword !== null + && typeof emailAndPassword === 'object' + && (emailAndPassword as Record)[RETIRED_KEY] !== undefined + ) { + throw new Error(`[auth] ${REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED}`); } } diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts new file mode 100644 index 00000000000..3676472fd67 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29909 — ADR-0049 enforce-or-remove, by the maintainer's ruling on that card: +// a completed self-service password reset always ends every session the +// account holds, and there is no setting. plugin-auth made the behaviour +// unconditional first (it sets better-auth's own option itself and refused an +// explicit `false`), which left `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` +// able to hold one value only. Tombstoned with `retiredKey()`. No D2 +// conversion: host plugin configuration, not a stack collection member, not a +// stored row. The D3 record is `email-and-password-revoke-sessions-on-reset-retired`. +export const entry = 'system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset'; diff --git a/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts b/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts new file mode 100644 index 00000000000..f4194bd587c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29909 — ADR-0049 enforce-or-remove — the D3 entry for the retired +// `system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset`. No D2 +// conversion carries it: the key lives in the configuration a host hands the +// auth plugin, never in a stack collection or a stored row, so there is no +// source `os migrate meta` could rewrite and the deletion is the author's. +export const entry: SemanticMigration = { + id: 'email-and-password-revoke-sessions-on-reset-retired', + surface: 'auth emailAndPassword.revokeSessionsOnPasswordReset — whether a completed password reset ends the account\'s sessions', + replacement: + 'Nothing: delete the key. A completed password reset always ends every session the account ' + + 'holds, on every reset door (the emailed link and the phone OTP reset alike), the same as an ' + + 'administrator\'s forced reset, and no setting turns that off.', + reason: + 'The key could hold only one value. A configuration that wrote `true` asked for what the ' + + 'platform does anyway, so deleting it changes no behaviour; one that wrote `false` was already ' + + 'refused at boot by the auth plugin before this retirement, so it cannot be running. Both ' + + 'spellings are now refused: by `tsc` and the schema parse where the configuration is typed or ' + + 'parsed, and by the auth plugin where a host hands it an unparsed object. What no tool can ' + + 'check is whether something the deployment maintains reads the key — a settings form, a ' + + 'generated configuration file or a script that writes it — and must stop writing it.', + acceptanceCriteria: + 'No configuration the deployment hands the auth plugin carries ' + + '`emailAndPassword.revokeSessionsOnPasswordReset`, and the deployment boots. A user signed in ' + + 'on a second device before a password reset is signed out there (the session answers 401) ' + + 'once the reset completes.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 9d56c03545e..0c44b1c1c71 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1840,6 +1840,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'the heading element each one always rendered, so the outline is unchanged and the heading ' + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', }, + { + id: 'email-and-password-revoke-sessions-on-reset-retired', + order: 118, + text: + 'It also retires `emailAndPassword.revokeSessionsOnPasswordReset` from the auth ' + + 'configuration (ADR-0049 enforce-or-remove). A completed self-service password reset now ' + + 'always ends every session the account holds, on every reset door, and the auth plugin ' + + 'already refused an explicit `false`, so the key could hold one value only. There is no D2 ' + + 'conversion: the key lives in the configuration a host hands the auth plugin, never in a ' + + 'stack collection or a stored row. Its D3 record is the semantic entry ' + + '`email-and-password-revoke-sessions-on-reset-retired`.', + }, { id: 'email-template-category-is-system-retired', order: 94, diff --git a/packages/spec/src/system/auth-config.test.ts b/packages/spec/src/system/auth-config.test.ts index 50ac335d689..c6702d6032d 100644 --- a/packages/spec/src/system/auth-config.test.ts +++ b/packages/spec/src/system/auth-config.test.ts @@ -296,13 +296,14 @@ describe('EmailAndPasswordConfigSchema', () => { minPasswordLength: 10, maxPasswordLength: 64, autoSignIn: false, - revokeSessionsOnPasswordReset: true, resetPasswordTokenExpiresIn: 7200, }); expect(config?.enabled).toBe(true); expect(config?.minPasswordLength).toBe(10); - expect(config?.revokeSessionsOnPasswordReset).toBe(true); + // `revokeSessionsOnPasswordReset` is retired: its refusal is pinned in + // `auth-revoke-sessions-on-password-reset-retirement.test.ts`. + expect(config).not.toHaveProperty('revokeSessionsOnPasswordReset'); }); it('should default enabled to true', () => { diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 42df324f621..6336afb7779 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const AuthProviderConfigSchema = lazySchema(() => z.object({ id: z.string().describe('Provider ID (github, google)'), clientId: z.string().describe('OAuth Client ID'), @@ -297,6 +298,18 @@ export type OidcProviderConfig = z.input; export type OidcProvidersConfig = z.input; +/** + * The prescription `EmailAndPasswordConfig.revokeSessionsOnPasswordReset`'s + * tombstone carries. plugin-auth's entry guard (`password-reset-ends-sessions.ts`) + * answers a host configuration that never passed this schema with the same + * text, so both doors say the same thing. + */ +export const REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED = + '`emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 ' + + '(ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset ' + + 'always ends every session the account holds, on every reset door, and no setting turns ' + + 'that off. Delete the key; nothing replaces it.'; + export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(true).describe('Enable email/password auth'), disableSignUp: z.boolean().optional().describe('Disable new user registration via email/password'), @@ -306,18 +319,27 @@ export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ minPasswordLength: z.number().optional().describe('Minimum password length (default 8)'), maxPasswordLength: z.number().optional().describe('Maximum password length (default 128)'), // `externalVocabulary` mirror (#14478 ruling B): this object's own describe - // says its options are "forwarded to better-auth", and every sibling here is - // a better-auth option name verbatim (`disableSignUp`, - // `requireEmailVerification`, `minPasswordLength`, `autoSignIn`, - // `revokeSessionsOnPasswordReset`). A key that is forwarded by name cannot be - // renamed without breaking the forwarding. + // says its options are "forwarded to better-auth", and every live sibling + // here is a better-auth option name verbatim, forwarded under that name by + // plugin-auth's auth instance (`disableSignUp`, `requireEmailVerification`, + // `minPasswordLength`, `maxPasswordLength`, `autoSignIn`). A key that is + // forwarded by name cannot be renamed without breaking the forwarding. resetPasswordTokenExpiresIn: z.number().optional().describe( 'Reset-password token TTL in seconds (default 3600)' ).meta({ externalVocabulary: 'better-auth `emailAndPassword.resetPasswordTokenExpiresIn`' }), autoSignIn: z.boolean().optional().describe('Auto sign-in after sign-up (default true)'), - revokeSessionsOnPasswordReset: z.boolean().optional().describe( - 'Revoke all other sessions on password reset' - ), + // RETIRED (#29909, ADR-0049 enforce-or-remove): a completed self-service + // password reset always ends every session the account holds, on every reset + // door, and no setting turns that off (maintainer ruling on #29909). After + // plugin-auth made the behaviour unconditional the key could hold one value + // only, so it is a tombstone: authoring it is a `tsc` error and a parse + // error carrying the prescription. plugin-auth still sets better-auth's own + // option of this name itself (`auth-manager-auth-instance.ts`), and refuses + // this key at any value where a host configuration enters the manager + // unparsed (`password-reset-ends-sessions.ts`). No ADR-0087 D2 conversion: + // this is host plugin configuration, not a stack collection member or a + // stored row. D3 record: `email-and-password-revoke-sessions-on-reset-retired`. + revokeSessionsOnPasswordReset: retiredKey(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED), }).optional().describe('Email and password authentication options forwarded to better-auth')); /** diff --git a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts new file mode 100644 index 00000000000..51eab0614e9 --- /dev/null +++ b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` RETIRED (#29909) — + * ADR-0049 enforce-or-remove, by the maintainer's ruling on that card: a + * completed self-service password reset always ends every session the account + * holds, and there is no setting. plugin-auth made the behaviour unconditional + * first (it sets better-auth's own option itself and refused an explicit + * `false`), which left the key able to hold one value only. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `EmailAndPasswordConfigSchema`, so the + * refusal carries its prescription and a typed configuration fails `tsc`. + * The prescription is exported (`REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED`) + * because plugin-auth, which takes its configuration unparsed, answers a + * host's object with the same text. + * 2. `RETIRED_KEYS_BY_MAJOR[18]` carries the key and the D3 record is + * `email-and-password-revoke-sessions-on-reset-retired`. No D2 conversion: + * host plugin configuration, not a stack collection member or a stored row. + * 3. A tree-scoped absence scan over the authored spelling. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (this schema has no authoring + * door of its own), so these pins assert refusal, the issue `code`, the `path` + * and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { + AuthConfigSchema, + EmailAndPasswordConfigSchema, + REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED, + type EmailAndPasswordConfig, +} from './auth-config.zod'; + +const KEY = 'revokeSessionsOnPasswordReset'; +const D3_ID = 'email-and-password-revoke-sessions-on-reset-retired'; + +const PRESCRIPTION = + /^`emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off\. Delete the key; nothing replaces it\.$/; + +/** A well-formed block — the keys a host commonly writes, not the retired one. */ +const BLOCK = { enabled: true, minPasswordLength: 12, autoSignIn: false } as const; + +describe('revokeSessionsOnPasswordReset retirement — the tombstone', () => { + it('the exported prescription is the house sentence, and carries no tracker number or migrate-tool promise', () => { + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).toMatch(PRESCRIPTION); + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).not.toMatch(/#\d{3,}/); + // No conversion covers host configuration, so the text must not promise one. + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).not.toMatch(/os migrate meta/); + }); + + for (const value of [true, false, 'false', 0, null, {}]) { + it(`the schema refuses ${JSON.stringify(value)} at the key, with the prescription`, () => { + const r = EmailAndPasswordConfigSchema.safeParse({ ...BLOCK, [KEY]: value }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([KEY]); + expect(issue.message).toBe(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED); + }); + } + + it('the whole auth configuration refuses it at the nested path', () => { + const r = AuthConfigSchema.safeParse({ emailAndPassword: { ...BLOCK, [KEY]: true } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === `emailAndPassword.${KEY}`); + expect(issue, JSON.stringify(r.error.issues)).toBeDefined(); + expect(issue!.message).toMatch(PRESCRIPTION); + // CONTROL: the same configuration without the key is accepted. + expect(AuthConfigSchema.safeParse({ emailAndPassword: { ...BLOCK } }).success).toBe(true); + }); + + it('CONTROL: the block without the key passes, live keys intact, and grows no key', () => { + const r = EmailAndPasswordConfigSchema.safeParse(BLOCK); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).not.toHaveProperty(KEY); + expect(r.data).toEqual(BLOCK); + // An explicit `undefined` is absence, as on every tombstone. + expect(EmailAndPasswordConfigSchema.safeParse({ ...BLOCK, [KEY]: undefined }).success).toBe(true); + }); + + it('fails tsc at the authoring site: the input type of the key is the retired mark', () => { + const withTrue: EmailAndPasswordConfig = { + ...BLOCK, + // @ts-expect-error — `revokeSessionsOnPasswordReset` is a retiredKey() tombstone: no value is assignable. + revokeSessionsOnPasswordReset: true, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => EmailAndPasswordConfigSchema.parse(withTrue)).toThrow(/`emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18/); + }); + + it('the walked shape keeps the key — the authorable-surface row stays reachable as [RETIRED]', () => { + const shape = (EmailAndPasswordConfigSchema as unknown as { unwrap?: () => { shape?: Record } }) + .unwrap?.()?.shape; + expect(shape, 'EmailAndPasswordConfigSchema must unwrap to a read-through shape').toBeDefined(); + expect(Object.keys(shape!)).toContain(KEY); + expect(Object.keys(shape!), 'CONTROL: a live neighbour').toContain('minPasswordLength'); + }); +}); + +describe('revokeSessionsOnPasswordReset retirement — ADR-0087 registration', () => { + it('declares the key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(`system/EmailAndPasswordConfig:${KEY}`); + }); + + it('carries one D3 entry, which judges no conversion, and no D2 conversion names the key', () => { + const entry = MIGRATIONS_BY_MAJOR[18].semantic.find((m) => m.id === D3_ID); + expect(entry, `D3 entry ${D3_ID} is not registered in step 18`).toBeDefined(); + expect(entry!.surface).toMatch(/emailAndPassword\.revokeSessionsOnPasswordReset/); + expect(entry!.replacement).toMatch(/delete the key/i); + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + expect(entry!.conversionIds).toBeUndefined(); + // No D2 by design: nothing in a stack or a stored row carries the key. + expect(ALL_CONVERSIONS.length).toBeGreaterThan(50); // anti-vacuity: the table is populated + expect(ALL_CONVERSIONS.filter((c) => JSON.stringify(c).includes(KEY))).toEqual([]); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — the tombstone types the key as the retired +// mark on `EmailAndPasswordConfig`, so every TYPED authoring site fails to +// compile. The residue is what `tsc` never judges: JSON, YAML, MD/MDX, untyped +// `.js`, and TS literals typed `any` / `unknown` / a record — the shape a host +// configuration handed to the auth plugin usually has. This walk covers that +// residue across the roots `scripts/cross-package-test-inputs.mjs` already +// declares for `@objectstack/spec` (the #15513 pin's rows, mirrored in +// `turbo.json`): `packages/` per extension, `examples/` for the non-code +// extensions plus each app's `src/` tree, and `skills/`, `content/`, `scripts/`. +// +// The key name is unique to this setting (better-auth's option of the same +// name aside — see the exclusions), so the matcher is the key in KEY POSITION +// (`revokeSessionsOnPasswordReset:` or its quoted JSON form), with comment +// contents masked in code; the tombstone declaration itself +// (`: retiredKey(`) is never an offence. Excluded, each for its reason: +// - this file, which writes the key to prove the refusal; +// - plugin-auth's `password-reset-ends-sessions.test.ts`, which writes it as +// a host's input to prove the plugin's refusal; +// - plugin-auth's `auth-manager-auth-instance.ts`, which writes +// better-auth's OWN option of that name into better-auth's configuration — +// the mechanism of the rule, never read from a host; +// - `**/CHANGELOG.md`, release-owned history of what shipped. +// The bound, stated: a key assembled under a computed name, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside this walk. +describe('tree-scoped absence: nothing inside the declared radius still writes emailAndPassword.revokeSessionsOnPasswordReset', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + const CODE_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const EXCLUDED = new Set([ + THIS_FILE, + 'packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts', + 'packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts', + ]); + + const IN_KEY_POSITION = /(^|[\s{,(])["']?revokeSessionsOnPasswordReset["']?\s*:(?!\s*retiredKey\()/g; + + /** Code with `//` and block-comment CONTENTS blanked, offsets kept. */ + const maskComments = (src: string): string => + src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (m, lead: string) => lead + ' '.repeat(m.length - lead.length)); + + const walk = (dir: string, out: string[]) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.isDirectory()) { + if (!SKIPPED_DIRS.has(ent.name)) walk(path.join(dir, ent.name), out); + } else if (ent.isFile()) { + out.push(path.join(dir, ent.name)); + } + } + }; + + const files = (): string[] => { + const all: string[] = []; + for (const root of WALK_ROOTS) { + const abs = path.join(REPO_ROOT, root); + if (fs.existsSync(abs)) walk(abs, all); + } + return all + .map((abs) => path.relative(REPO_ROOT, abs).split(path.sep).join('/')) + .filter((rel) => { + const ext = path.extname(rel); + if (!SCANNED_EXT.has(ext)) return false; + if (rel.startsWith('examples/')) return EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel); + return true; + }); + }; + + it('no file writes the retired key', () => { + const scanned = files(); + // Anti-vacuity: the walk reached the trees it claims, including the file + // that declares the tombstone and the plugin that refuses the key. + expect(scanned.length).toBeGreaterThan(1000); + expect(scanned).toContain('packages/spec/src/system/auth-config.zod.ts'); + expect(scanned).toContain('packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts'); + + const offences: string[] = []; + for (const rel of scanned) { + if (EXCLUDED.has(rel) || rel.endsWith('/CHANGELOG.md')) continue; + const raw = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); + if (!raw.includes('revokeSessionsOnPasswordReset')) continue; + const text = CODE_EXT.has(path.extname(rel)) ? maskComments(raw) : raw; + for (const m of text.matchAll(IN_KEY_POSITION)) { + const line = text.slice(0, m.index).split('\n').length; + offences.push(`${rel}:${line}`); + } + } + expect(offences, 'delete the key at each site: a completed password reset always ends every session').toEqual([]); + }); + + it('LIT CONTROL: the matcher finds the authored spellings and passes the tombstone and prose', () => { + const hits = (s: string) => [...s.matchAll(IN_KEY_POSITION)].length; + expect(hits('emailAndPassword: { revokeSessionsOnPasswordReset: true }')).toBe(1); + expect(hits('{ "revokeSessionsOnPasswordReset": false }')).toBe(1); + expect(hits('emailAndPassword:\n revokeSessionsOnPasswordReset: false')).toBe(1); + expect(hits(' revokeSessionsOnPasswordReset: retiredKey(X),')).toBe(0); + expect(hits('the `emailAndPassword.revokeSessionsOnPasswordReset` key')).toBe(0); + expect(hits(maskComments('// revokeSessionsOnPasswordReset: true'))).toBe(0); + // Each excluded file still exists, so an exclusion cannot outlive its reason. + for (const rel of EXCLUDED) expect(fs.existsSync(path.join(REPO_ROOT, rel)), rel).toBe(true); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 29c12e2a404..fbf980b353e 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -61,6 +61,7 @@ "src/security/permission-set-managed-by-retirement.test.ts", "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", + "src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts", "src/system/compliance-families-retirement.test.ts", "src/system/constants/platform-object-names.test.ts", "src/system/email-template-category-is-system-retirement.test.ts", From 640ff3f52a16f94d79d860c1aa1b684ff5a63b02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 15:58:14 +0000 Subject: [PATCH 2/5] chore(spec): regenerate artifacts for the revokeSessionsOnPasswordReset retirement Claude-Session: https://claude.ai/code/session_01WGufzgjTaf6BB6T3vxdisU Co-authored-by: Claude --- content/docs/references/system/auth-config.mdx | 4 ++-- packages/spec/api-surface/system.json | 1 + packages/spec/authorable-surface/system.json | 2 +- packages/spec/export-origins/system.json | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 849cab2475e..34cfb662983 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -155,7 +155,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | | **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | -| **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | +| **revokeSessionsOnPasswordReset** | `never` | optional | [REMOVED] `emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off. Delete the key; nothing replaces it. | ### Nested Shape: `AuthConfig.emailVerification` @@ -252,7 +252,7 @@ Email and password authentication options forwarded to better-auth | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | | **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | -| **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | +| **revokeSessionsOnPasswordReset** | `never` | optional | [REMOVED] `emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off. Delete the key; nothing replaces it. | --- diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 046517b6507..8ceb4c7c046 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -556,6 +556,7 @@ "QueueConfigSchema (const)", "QuotaEnforcementResult (type)", "QuotaEnforcementResultSchema (const)", + "REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED (const)", "RPO (type)", "RPOParsed (type)", "RPOSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index b784fc057ff..418ac0d241d 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -375,7 +375,7 @@ "system/EmailAndPasswordConfig:minPasswordLength", "system/EmailAndPasswordConfig:requireEmailVerification", "system/EmailAndPasswordConfig:resetPasswordTokenExpiresIn", - "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset", + "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset [RETIRED]", "system/EmailServiceConfig:apiKey", "system/EmailServiceConfig:appName", "system/EmailServiceConfig:defaultFrom", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 896dbe8fe59..23a842a3156 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -534,6 +534,7 @@ "QueueConfigSchema": "src/system/worker.zod.ts#QueueConfigSchema (const)", "QuotaEnforcementResult": "src/system/tenant.zod.ts#QuotaEnforcementResult (type)", "QuotaEnforcementResultSchema": "src/system/tenant.zod.ts#QuotaEnforcementResultSchema (const)", + "REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED": "src/system/auth-config.zod.ts#REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED (const)", "RPO": "src/system/disaster-recovery.zod.ts#RPO (type)", "RPOParsed": "src/system/disaster-recovery.zod.ts#RPOParsed (type)", "RPOSchema": "src/system/disaster-recovery.zod.ts#RPOSchema (const)", From 6c320a30ca1a8974547ec5187b431307a1687055 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 16:00:50 +0000 Subject: [PATCH 3/5] test(spec): leave the git-ignored json-schema projection out of the absence walk Claude-Session: https://claude.ai/code/session_01WGufzgjTaf6BB6T3vxdisU Co-authored-by: Claude --- ...-revoke-sessions-on-password-reset-retirement.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts index 51eab0614e9..89ba80f26b2 100644 --- a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts +++ b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts @@ -152,7 +152,10 @@ describe('revokeSessionsOnPasswordReset retirement — ADR-0087 registration', ( // - plugin-auth's `auth-manager-auth-instance.ts`, which writes // better-auth's OWN option of that name into better-auth's configuration — // the mechanism of the rule, never read from a host; -// - `**/CHANGELOG.md`, release-owned history of what shipped. +// - `**/CHANGELOG.md`, release-owned history of what shipped; +// - `packages/spec/json-schema/`, the git-ignored build projection of the +// schema, where the key appears as the tombstone itself (`not: {}` under +// its `[REMOVED]` description). // The bound, stated: a key assembled under a computed name, and `docs/**`, // `.claude/**`, `.github/**` and the repo-root files are outside this walk. describe('tree-scoped absence: nothing inside the declared radius still writes emailAndPassword.revokeSessionsOnPasswordReset', () => { @@ -173,6 +176,8 @@ describe('tree-scoped absence: nothing inside the declared radius still writes e 'packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts', ]); + const JSON_SCHEMA_PROJECTION = 'packages/spec/json-schema/'; + const IN_KEY_POSITION = /(^|[\s{,(])["']?revokeSessionsOnPasswordReset["']?\s*:(?!\s*retiredKey\()/g; /** Code with `//` and block-comment CONTENTS blanked, offsets kept. */ @@ -217,7 +222,7 @@ describe('tree-scoped absence: nothing inside the declared radius still writes e const offences: string[] = []; for (const rel of scanned) { - if (EXCLUDED.has(rel) || rel.endsWith('/CHANGELOG.md')) continue; + if (EXCLUDED.has(rel) || rel.endsWith('/CHANGELOG.md') || rel.startsWith(JSON_SCHEMA_PROJECTION)) continue; const raw = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); if (!raw.includes('revokeSessionsOnPasswordReset')) continue; const text = CODE_EXT.has(path.extname(rel)) ? maskComments(raw) : raw; From 2d3b20f5197952f72bbdf347d6f7b6c198d5b61e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 16:40:00 +0000 Subject: [PATCH 4/5] test(spec): mask comments through the shared js-comment-mask module in the absence walk Claude-Session: https://claude.ai/code/session_01WGufzgjTaf6BB6T3vxdisU Co-authored-by: Claude --- ...evoke-sessions-on-password-reset-retirement.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts index 89ba80f26b2..10d4b78f255 100644 --- a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts +++ b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts @@ -31,6 +31,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + import { ALL_CONVERSIONS } from '../conversions/registry'; import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; import { @@ -144,7 +146,7 @@ describe('revokeSessionsOnPasswordReset retirement — ADR-0087 registration', ( // The key name is unique to this setting (better-auth's option of the same // name aside — see the exclusions), so the matcher is the key in KEY POSITION // (`revokeSessionsOnPasswordReset:` or its quoted JSON form), with comment -// contents masked in code; the tombstone declaration itself +// contents masked in code (`scripts/js-comment-mask.mjs`); the tombstone declaration itself // (`: retiredKey(`) is never an offence. Excluded, each for its reason: // - this file, which writes the key to prove the refusal; // - plugin-auth's `password-reset-ends-sessions.test.ts`, which writes it as @@ -180,12 +182,6 @@ describe('tree-scoped absence: nothing inside the declared radius still writes e const IN_KEY_POSITION = /(^|[\s{,(])["']?revokeSessionsOnPasswordReset["']?\s*:(?!\s*retiredKey\()/g; - /** Code with `//` and block-comment CONTENTS blanked, offsets kept. */ - const maskComments = (src: string): string => - src - .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) - .replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (m, lead: string) => lead + ' '.repeat(m.length - lead.length)); - const walk = (dir: string, out: string[]) => { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { if (ent.isDirectory()) { From bfdab107c04687adb3e1a111ff43fa63e490d82c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:04:41 +0000 Subject: [PATCH 5/5] chore(spec): carry main's relatedListTitle row into the merged authorable-surface The os-regen driver kept the branch side of system.json on the main merge; this restores the row main added, beside the branch's [RETIRED] row. Claude-Session: https://claude.ai/code/session_01WGufzgjTaf6BB6T3vxdisU Co-authored-by: Claude --- packages/spec/authorable-surface/system.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 418ac0d241d..28c83782524 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -457,6 +457,7 @@ "system/FieldTranslation:label", "system/FieldTranslation:options", "system/FieldTranslation:placeholder", + "system/FieldTranslation:relatedListTitle", "system/FileDestinationConfig:append", "system/FileDestinationConfig:encoding", "system/FileDestinationConfig:path",