Skip to content

fix(plugin-auth)!: a completed password reset ends the account's other sessions; a reset request answers the same way for any address without waiting on its mail - #29827

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/sec-hardening-c
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/sec-hardening-c

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Clause-②: no (narrowing) — the auth manager refuses an emailAndPassword.revokeSessionsOnPasswordReset: false it accepted before, at construction and on every configuration patch. Nothing is widened: no export, route, key or answer is added.
Metadata-protocol: none
Skills-docs: none — no skills/** file teaches the reset doors or this key; the hand-written guides that do (content/docs/permissions/authentication.mdx, docs/HARDENING.md) are updated in this diff.

维护者速读

  • 改了什么:自助重置密码完成后,该账号此前的所有会话一律结束(与管理员强制重置一致,不是开关);显式配置 revokeSessionsOnPasswordReset: false 改为启动即拒绝。重置请求对任何地址都同样作答,重置邮件改在响应之后发送,发送失败只记服务端日志。
  • 为什么改:重置密码应当让旧登录失效;重置请求的回答不应随地址或邮件发送而变化。
  • 风险与回滚:用户在其他设备上的登录会在重置后被登出(预期行为)。若某部署显式写了 false,升级后无法启动,删掉该键即可。回滚 = 还原本 PR,无数据迁移。
  • 先看:packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts(revokeSessionsOnPasswordReset: true 与 sendResetPassword 外包的一行)、packages/plugins/plugin-auth/src/run-after-response.ts、packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/password-reset-ends-sessions.test.ts src/reset-request-mail-after-response.test.ts,预期两个文件全部通过。
  • 需要你做的:无,席位落地。

What changes

  • A completed password reset ends the account's other sessions. The auth instance now always hands better-auth emailAndPassword.revokeSessionsOnPasswordReset: true, so every reset door that sets a password from a reset token or code (the emailed link, and the phone OTP reset where it is mounted) ends every existing session of the account in the same unit as the password write — the same as an administrator's forced reset. It is not a setting.
  • revokeSessionsOnPasswordReset: false is refused, never silently overridden. The key stays declared by @objectstack/spec. assertPasswordResetEndsSessions (new, password-reset-ends-sessions.ts) refuses an explicit false where the configuration enters the manager — the constructor (stops the boot) and applyConfigPatch (the standing configuration keeps ruling) — the way the plugin already treats a declaration it will not honour (assertAudienceConfig, assertScimAdminCoherence). true and an absent key are accepted.
  • A reset request answers the same way for any address, without waiting on its mail. sendResetPassword is wrapped by sendAfterResponseOnPublicDoor (new, run-after-response.ts): a call that carries its Request (the public door) resolves at once and the mail is built and sent after the response (setImmediate, the shape createRefusedAttachTombstoner in service-storage already uses); a failure is logged on the server at error. A call with no Request — the identity import's server-side reset — is handed straight through and awaited as before, because it reads the delivery receipt the send settles.
  • Docs. docs/HARDENING.md no longer asks operators to set the key; content/docs/permissions/authentication.mdx states both rules.

Tests

  • plugin-auth/src/password-reset-ends-sessions.test.ts (new): the session signed in before a completed reset answers 401 UNAUTHENTICATED afterwards (through the emailed link and, the same, through the phone OTP reset — /phone-number/request-password-reset and /phone-number/reset-password with an SMS service) and a fresh sign-in with the new password is served (control); a reset that does not complete ends nothing; better-auth is handed the flag with no configuration; false is refused at construction and on a patch (standing config kept); true/absent accepted.
  • plugin-auth/src/reset-request-mail-after-response.test.ts (new): a send that never settles still lets POST /request-password-reset answer, with the body an address with no account gets; the mail is still sent, after the response (control); a failed send is logged at error and the answer is unchanged; the server-side reset call still awaits its send; runAfterResponse failure handling.
  • qa/dogfood/test/password-reset-ends-sessions.dogfood.test.ts (new): on the booted showcase, through the HTTP doors — the earlier session is served before the reset, refused (401) on the session door and a data read after it, and a fresh sign-in is served on both.
  • Updated, because the public door's reset mail now lands after the response: auth-email-locale.test.ts, identity-mail-sibling.test.ts, admin-import-users-invite-delivery-failure.test.ts ⑥, and identity-mail-read-gate.dogfood.test.ts (waits for the new identity row instead of reading the newest one at once).

Acceptance notes

  • The phone OTP reset is pinned too (password-reset-ends-sessions.test.ts, plugins.phoneNumber with an SMS service): the session signed in before a reset completed with the texted code answers 401 afterwards, and a fresh sign-in is served.
  • A session a reset ends is deleted, not stamped: the reset paths are in neither the interactive-revoke ledger nor the erasure paths (session-tombstone.ts), so no revoked_at / revoke_reason trail is written and no logout audit row either (logoutEventFor records a /sign-out alone). The account's API keys are untouched. Parity with the forced reset's trail and key revocation is left to the maintainer.
  • The after-response send lives as long as the process: a host that freezes its process right after the response would lose it. The platform's own Node server does not.
  • upgrade-harness/populate-fixture.ts (the 18.0 fixture generator, never collected by vitest) requests a reset and does not read the mail row, so it is unchanged.

Verification

Round 2 (the contract review's three must-fixes), head 9c006b978b: touched plugin tests 5 files, 94/94; typecheck exit 0. Ablation of revokeSessionsOnPasswordReset: true, now turns 3 cases red (the emailed-link door, the phone OTP door, the flag); restored. Derived gates for the round's paths: 51 run, 51 × exit 0, check:doc-authoring included; check:optional-error-sink exit 0. CI on 9c006b978b: 38 success, 12 skipped, 0 failed. Round 1 follows.

All readings below are on head 3ef2258988 (this branch merged with origin/main at 6f094645b4).

plugin-auth: full suite and typecheck
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 → exit 0 · Test Files 218 passed (218) · Tests 3715 passed, 10 skipped (3725).
  • pnpm --filter @objectstack/plugin-auth run typecheck → exit 0 (tsc --noEmit, the examples project, and check:test-typecheck: OK, test layer held at its ledger).
One-shot ablation of each fix (via scripts/ablation-replace.mjs; restored to the HEAD blob, tree clean)
  • Session rule — delete revokeSessionsOnPasswordReset: true, in auth-manager-auth-instance.ts (anchor 1 → 0, blob e85d8f531be9 → 12592d665800): password-reset-ends-sessions.test.ts 2 failed / 4 passed — the two cases that state the rule ("the session signed in before the reset is refused afterwards…" and "asks better-auth to end sessions on every reset door…") went red; the config-refusal cases stayed green. Restored blob e85d8f531be9.
  • Reset request answer — force the public-door branch of sendAfterResponseOnPublicDoor to await the send (if (true) return send(...), blob 4d5bc946ea91 → fd8c1027b80f): reset-request-mail-after-response.test.ts 2 failed / 4 passed — the two cases that state the rule ("answers while the send has not settled…" and "logs a send that fails on the server…") went red; the control (the mail is still sent) and the server-side call case stayed green. Restored.
dogfood
  • New pin password-reset-ends-sessions.dogfood.test.ts and the updated identity-mail-read-gate.dogfood.test.ts: pass (1/1 and 25/25 targeted).
  • Full battery pnpm --filter @objectstack/dogfood test (no shard, no path; title carries !): Test Files 403 passed, 1 failed, 3 skipped (407) · Tests 3320 passed, 1 failed, 28 skipped (3349). The one failure is managed-content-sealed.dogfood.test.ts › "control … a NEW flow is created through /meta and through /automation" — Test timed out in 5000ms inside a 35-minute run on a shared box; the file passes alone on the same head (10/10). It touches no auth path.
Gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)
  • 93 derived commands, all run with the exit code captured before any pipe: 93 × exit 0. Three first answered PREREQUISITE NOT MET (exit 3: check:skill-examples and check:dual-build-cjs-loads read packages outside the dogfood build closure; check:type-check-debt did not get the shared verify lock) and were re-run to exit 0 after building those packages / acquiring the lock.
  • dispatch-gates --ran reconciliation: 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a derived zero: every entry recorded its exit code).
  • After the merge: pnpm install --frozen-lockfile, the dogfood closure build and pnpm --filter @objectstack/spec check:generated → exit 0.
  • CI-owned families (Lint & Repo Gates, Type Check, Test Core, Build Core, the UI lanes) are read from this PR's check-runs.

Generated by Claude Code

… sessions

Every self-service reset door (the emailed link and the phone OTP reset)
now ends every existing session of the account when it sets the new
password, the same as an administrator's forced reset. It is not a
deployment option: an explicit emailAndPassword.revokeSessionsOnPasswordReset
false is refused where the configuration enters the manager (constructor
and applyConfigPatch) instead of being silently overridden; true is
accepted and changes nothing.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…ime, for any address

On the public door the reset mail is built and sent after the response,
never inside it; a send failure is logged on the server and never
reaches the caller. The identity import's own server-side reset call
still awaits its send, because it reads the delivery receipt the send
settles.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…est does not wait on its mail

New pins, stated as the rules: the session signed in before a completed
reset is refused afterwards while a fresh sign-in is served (plugin and
showcase HTTP doors); revokeSessionsOnPasswordReset false is refused at
construction and on a patch; a reset request answers while its mail has
not been sent, the mail is still sent after the response, and a failed
send is logged on the server. Tests that read the public door's reset
mail right after the response now wait for it to land.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…equest answer

The hardening guide no longer asks operators to set
revokeSessionsOnPasswordReset (it is always on, and false is refused);
the authentication guide states both rules. The changeset declares the
narrowing (false refused) with its remedy.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/self-hosting.mdx (via emailAndPassword (literal, a string literal in applyConfigPatch))
  • content/docs/permissions/authentication.mdx (via emailAndPassword (literal, a string literal in applyConfigPatch))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via emailAndPassword (literal, a string literal in applyConfigPatch))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4b71a82b76531059ad45ba224899aaadaa219ebc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a09b73f6889d41bb096e1b1d8e57f929eaa61704 — the merge of head 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f into base 4b71a82b76531059ad45ba224899aaadaa219ebc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a09b73f6889d41bb096e1b1d8e57f929eaa61704 && git checkout a09b73f6889d41bb096e1b1d8e57f929eaa61704
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b71a82b76531059ad45ba224899aaadaa219ebc 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f && git checkout -B drift-repro 4b71a82b76531059ad45ba224899aaadaa219ebc && git merge --no-ff 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f

node scripts/docs-audit/affected-docs.mjs --json 4b71a82b76531059ad45ba224899aaadaa219ebc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4b71a82b76531059ad45ba224899aaadaa219ebc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ts warn channel

The failure sink type declared both `error` and `warn` optional, a
contract that permits silence (check:optional-error-sink). `warn` is the
channel every host logger guarantees (AuthManagerOptions.logger), so the
type now says so; `error` stays optional and the console remains the
floor when no host logger is wired.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 35af63a · 2026-11-03T07:26Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 35af63a44cdc714df3b04255b9733e149c166b49
Local-runs: none
Scope: packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts, packages/plugins/plugin-auth/src/run-after-response.ts, packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts, packages/plugins/plugin-auth/src/reset-request-mail-after-response.test.ts, packages/plugins/plugin-auth/src/auth-email-locale.test.ts, packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts, packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts, packages/qa/dogfood/test/password-reset-ends-sessions.dogfood.test.ts, packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts, .changeset/password-reset-ends-sessions-and-request-answer.md + packages/plugins/plugin-auth/src/forced-reset-revocation.ts, packages/plugins/plugin-auth/src/account-invitation-marker.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/plugins/plugin-auth/src/session-tombstone.ts, packages/plugins/plugin-auth/src/auth-session-audit.ts, packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/objectql-adapter.ts, packages/services/service-storage/src/attachment-lifecycle.ts, packages/spec/src/system/auth-config.zod.ts, packages/spec/authorable-surface/system.json, docs/HARDENING.md, content/docs/permissions/authentication.mdx, .github/workflows/ci.yml; better-auth 1.7.12 resetCredentialPassword / requestPasswordReset: NOT READ (no installed package in this checkout; the only in-tree readings of the vendor door are the ones account-invitation-marker.ts records, re-measured on 1.7.12)

① Derived judgments

  • Rule (1), the emailed link. createAuthInstance hands the vendor revokeSessionsOnPasswordReset: true unconditionally (the conditional forward is gone), and password-reset-ends-sessions.test.ts drives the real door over a sqlite engine: the session signed in before a completed reset answers 401 UNAUTHENTICATED afterwards, a fresh sign-in is served (control), a reset that does not complete ends nothing, and the flag is present with no configuration. Test Core is green on this head. Implemented and pinned.
  • Rule (1), the other doors. The phone OTP reset (/phone-number/reset-password, mounted only with an SMS service, auth-manager-plugin-list.ts) is claimed covered through the same vendor flag; the email-OTP reset is not mounted. The phone claim rests on a vendor reading this review could not repeat (NOT READ) and on no pin: the plugin test harness already has setSmsService and plugins.phoneNumber, so a pin is within reach. See ④.
  • The session that completes the reset. Both self-service doors are unauthenticated (a token or a code, no session), so there is no completing session to spare or to end; the vendor ends every session of the account and the person signs in again — the pin's control and the dogfood pin both exercise exactly that. The forced path ends every session of the target user too. Same outcome for sessions.
  • Where it differs from the forced path. forced-reset-revocation.ts stamps each session in place (expires_at past, revoked_at, revoke_reason, the ADR-0069 D4 trail) and revokes the user's API keys. The self-service path deletes the rows: /reset-password and /phone-number/reset-password are in neither INTERACTIVE_REVOKE_REASON nor SESSION_ERASURE_PATHS (session-tombstone.ts), so no tombstone is written, and API keys are untouched. Rule (1) as ruled says sessions, so this is not a must-fix; it is a fork for the maintainer (③).
  • Rule (2). sendAfterResponseOnPublicDoor wraps the whole sendResetPassword body: a call carrying its Request resolves at once and the send runs under setImmediate; a call with no Request is the same promise as before. reset-request-mail-after-response.test.ts pins the answer with a send that never settles, equal bodies for an address with an account and one without, the mail still landing after the response (control), a failed send logged at error with the answer unchanged, and the server-side call still awaiting its send. Implemented and pinned. What the diff does not do: the vendor door's own work ahead of the answer is unchanged (③).
  • Refusing a spec-declared value at runtime. assertPasswordResetEndsSessions refuses an explicit false in the constructor (boot stops) and in applyConfigPatch on the merged emailAndPassword block before this.config = next (standing config kept; pinned, including that the patch's other keys do not land). true and absent pass. This is the plugin's established shape for a declaration it will not honour (assertAudienceConfig, assertScimAdminCoherence), and it is honest: the key cannot read as in force while it is not. The remaining declared-not-enforced residue is true itself, now inert, and the spec still parsing false (auth-config.test.ts pins true parses). That is an ADR-0049 enforce-or-remove question for the spec lane with the spec-property-retirement kit — correctly left out of this diff, and it must be filed, not forgotten (③).
  • Tests and pins as evidence. The three updated plugin tests wait for the public-door send with vi.waitFor, which is the right reading of the new ordering; the server-side drive stays inline. The identity-mail dogfood reads the row that appears after the 200 rather than the newest row, which is the correct repair for the same ordering. Dogfood legs run in the full battery only (ci.yml, two speeds): the "Dogfood Regression Gate" context on a PR head counts zero attestations by contract, so neither dogfood pin has a CI verdict on this head; they are judged here by source read and will first run on main's full battery.

② Semver level

  • patch with the BREAKING banner: right. The kind is none, so the level follows patch; the banner, the one affected population (a host that passes false) and the one-line remedy are in the changeset.
  • Metadata-protocol: none: accepted on the precedent the brief names (the plugin-auth deployment-config narrowing that landed with Clause-②: yes (narrowing) / Metadata-protocol: none). revokeSessionsOnPasswordReset IS on the authorable surface baseline (system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset), which is why this is worth stating: the surface lists every system schema key, deployment configuration included, and this key is handed to the plugin in host code, never stored as metadata — nothing for migrate meta to rewrite. The retirement card, when it tombstones the key, carries its own narrowed and its ADR-0087 disposition; this diff does not.
  • ADR-0087 not-required (no-migration-prescription): right for this diff, for the same reason. Becomes required at retirement.

③ Boundary flags

  • "In the same time" (commit subject). Overclaims, and the same phrase is in the tree: run-after-response.ts header and the sendResetPassword comment in auth-manager-auth-instance.ts. The ruling says "at once and the same way"; what the diff delivers is that the answer no longer waits on the send. The vendor door's own work still precedes the answer. The prose must say what the code does (④, must-fix).
  • Dogfood battery on the parent, byte-identical dist. Judged on the tree: the head commit changes one type annotation and doc comments in run-after-response.ts, nothing the emitted JavaScript carries, so a run on the parent exercises the same code. No CI dogfood verdict exists on any PR head by contract (①), so the dev's local run is not a gate verdict either way.
  • One unrelated dogfood timeout. Outside this review's inputs; main's full battery after merge is the verdict of record.
  • Open question A, the spec retirement. Correctly not done here. It is a spec-lane card: retire emailAndPassword.revokeSessionsOnPasswordReset (EmailAndPasswordConfigSchema) under ADR-0049 with the spec-property-retirement kit (tombstone, baselines, docs, ADR-0087 entry). The owning seat files it; this record does not.
  • Open question B, the token write. Ruling fork for the maintainer. The vendor's requestPasswordReset orders its own work (the lookup and the token write) ahead of the answer, and this diff moves only the send. Taking that off the path means owning the door (the route-boundary precedent adopt-membership.ts and session-tombstone.ts record why re-implementing a vendor endpoint was refused before) or a vendor option that does not exist on 1.7.12. The maintainer decides: accept the residual as the standing posture, or open a card to own the door.
  • Open question C, parity with the forced path. The forced reset leaves a D4 trail and revokes API keys; the self-service reset deletes session rows and leaves keys. Does rule (1) want the two reset doors in the tombstone ledger with their own reason, and does a self-service reset revoke keys? Maintainer's call; not required by the rule as worded.

④ Design judgment

Must-fix (prose claims wider than the code, in the tree):

  1. password-reset-ends-sessions.ts header says the session-delete hooks still run "so the logout audit row and any OIDC back-channel logout fire for each ended session". The logout row is written by sessionDeleteAfter through logoutEventFor, which answers null for any path but /sign-out (auth-session-audit.ts, by design: a delete that is not a sign-out must not be recorded as one). A reset-ended session leaves no logout row and, per ①, no tombstone. The back-channel half depends on the vendor running delete hooks on a bulk session delete, which is NOT READ here. Correct the sentence to what the tree does; state the back-channel half only as measured, or drop it.
  2. The "in the same time" claim in run-after-response.ts and in the sendResetPassword comment: narrow to the rule as ruled and as implemented (the answer does not wait on the send).
  3. authentication.mdx and the changeset advertise the phone OTP reset as ending sessions. No pin exercises that door and the vendor reading behind it could not be repeated here. Pin it in the plugin harness (setSmsService plus plugins.phoneNumber, a reset by code, the earlier session refused) or narrow both sentences to the emailed link until it is pinned.

Tried to break it, held:

  • A host that suspends after the response. Nothing in-tree offers an after-response hook with waitUntil; the platform's own Node server keeps the process alive, and createRefusedAttachTombstoner already ships the same setImmediate shape. On a host that freezes after the answer the mail is lost with no log line. Accepted as the standing posture with the precedent; a follow-up card for an adapter-level after-response hook is worth filing, not a blocker.
  • Context the deferred send runs in. Unlike the tombstoner it takes no snapshot, which is safe here: core vendor flows run on the adapter's sequential fallback, not inside an ambient engine transaction (objectql-adapter.ts, SCIM-only scope), and the public-door send reads no vendor request state (both readers return early when a Request is present), so neither a closed transaction handle nor another request's store can be picked up, on Node or under the WebContainer polyfill.
  • Send failure. Promise.resolve().then(task) turns a synchronous throw into a rejection; the rejection goes to onFailure; an onFailure throw is swallowed; pinned with an unhandledRejection listener. logAfterResponseSendFailure uses error, else the guaranteed warn, else the console; the level is right under the degradation rule (the caller was answered as if the mail went). No unhandled rejection path found.
  • A server-side caller that needs the receipt. The outer wrapper and settleImportResetDelivery branch on the same Request test, so the identity import gets the same awaited promise and its receipt settles before requestPasswordReset returns; pinned. admin-import-users.ts is unchanged and still reads the receipt.
  • A door the revocation misses. Everything that sets a password from a reset token or code goes through the vendor flag; set-initial-password and change-password are not resets and are out of rule (1); the forced reset has its own revocation. The one door not shown is the phone OTP reset (must-fix 3).
  • A runtime patch that flips it back. The assertion runs on the merged block before it becomes current; the instance hardcodes true regardless, so even a patch that slipped past could not reach the vendor. No settings key maps to this option, so the console cannot write it.
  • The refusal's guidance. Names the key, the rule, the remedy (remove the key; true accepted), and why it refuses rather than ignores; carries no tracker number. Adequate.

Implemented-by: claude/sec-hardening-c
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: FAIL

…ming wording, pin the phone OTP reset

- password-reset-ends-sessions.ts: a session a reset ends is deleted, not
  stamped (the reset paths are in neither the interactive-revoke ledger
  nor the erasure paths), writes no logout audit row (logoutEventFor
  records a /sign-out alone), and leaves the account's API keys alone.
  The earlier sentence claimed a logout row and back-channel logout.
- run-after-response.ts and the sendResetPassword comment: the reset
  request answers at once and does not wait on the send; the wording no
  longer claims more.
- password-reset-ends-sessions.test.ts: the phone OTP reset door
  (plugins.phoneNumber with an SMS service) is now pinned: the session
  signed in before a reset completed with the texted code is refused
  afterwards, and a fresh sign-in is served.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9c006b978b70553bc173d8dca28397acb4f62091
Local-runs: none
Scope: delta from the record at 35af63a (comment 6598074091), which stands for everything this head leaves unchanged — packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts, packages/plugins/plugin-auth/src/run-after-response.ts, packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts, packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts + packages/plugins/plugin-auth/src/session-tombstone.ts, packages/plugins/plugin-auth/src/auth-session-audit.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/forced-reset-revocation.ts, content/docs/permissions/authentication.mdx, .changeset/password-reset-ends-sessions-and-request-answer.md (unchanged, re-read against the new pin); better-auth 1.7.12: NOT READ, as before

① Derived judgments

One commit on top of the reviewed head, four files, no runtime change: two module headers, one comment, one new pin. Each of the three must-fixes from the earlier record, judged in the tree:

  1. The audit claim (password-reset-ends-sessions.ts header) — closed. The header now says what the tree does: the reset paths are in neither the interactive-revoke ledger nor the erasure paths, so the rows are deleted and no revoked_at / revoke_reason trail is written; no logout row is written, naming logoutEventFor as the /sign-out-only writer; the account's API keys are untouched and the forced reset's key revocation is named as its own switch. The unmeasured back-channel sentence is gone. Every sentence checks against session-tombstone.ts, auth-session-audit.ts and forced-reset-revocation.ts.
  2. "In the same time" (run-after-response.ts header, the sendResetPassword comment in auth-manager-auth-instance.ts) — closed. Both now read "answers the same way for any address, answers at once and does not wait on the send": the ruling's own words, and no more than the code delivers.
  3. The phone OTP door — closed by a pin, not by narrowing. password-reset-ends-sessions.test.ts gains a describe that mounts plugins.phoneNumber with a stub SMS service, verifies a phone on the account, drives /phone-number/request-password-reset and /phone-number/reset-password with the texted code through handleRequest, and pins the earlier session refused (401 UNAUTHENTICATED) with a fresh sign-in served as control. Test Core is green on this head, so the vendor claim the docs and changeset carry ("the emailed link and the phone OTP reset alike") is now backed by a drive of the real door, which is what the earlier record asked for.

The PR body's Tests line and Acceptance notes were rewritten for this head and were checked sentence by sentence against the tree: the Tests line names the phone drive and its two routes as the pin has them; the Acceptance notes state the deleted-not-stamped outcome, the absent logout row with its reason, the untouched API keys, and that parity with the forced reset is the maintainer's — nothing is claimed that the tree does not do. The Verification line's CI reading (38 success, 12 skipped, 0 failed) matches the check-runs on this head.

② Semver level

Unchanged from the earlier record: patch with the BREAKING banner, Metadata-protocol: none on the deployment-config precedent, ADR-0087 not-required. The delta touches no published surface and no declaration.

③ Boundary flags

  • The three escalations of the earlier record — the spec retirement of emailAndPassword.revokeSessionsOnPasswordReset, the vendor door's own work ahead of the answer, and parity with the forced reset's trail and key revocation — are with the maintainer. Not re-opened here; the tree now states the parity gap plainly instead of claiming it away, which is the honest posture while that ruling is pending.
  • Check-runs on this head: 38 success, 12 skipped, 0 failed. Seven of the ten required contexts are successes (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI); the other three (Lint UI, Type Check UI, Build & E2E UI) are skipped because the filter found no ui/ path in the diff — the same shape as on the earlier head. The 12 skips are those three, the full-battery legs that never run on a PR head (the dogfood shards and verify CLI, temporal conformance, the UI shard matrix and its deps), the two opt-in harnesses and the schedule gate — the same set as on the earlier head.
  • Dogfood pins still have no CI verdict on a PR head by contract (two speeds); unchanged by this delta.

④ Design judgment

The delta is prose and a pin. Nothing in it reaches the runtime, so the earlier record's ④ findings hold unchanged (send failure, the receipt path, the runtime patch, the refusal's guidance, the deferred send's context). On the one new piece of evidence: the phone pin exercises the real vendor door end to end rather than a stub of it, uses the same session-refused-then-fresh-sign-in shape as the emailed-link pin, and puts no code or link value in a title or message. No must-fix found.

Implemented-by: claude/sec-hardening-c
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 9c006b978b70553bc173d8dca28397acb4f62091 · 2026-11-03T08:30Z

  • Shape: 14 files, about +805/−16, not governed, under 3000. Clause-②: no (narrowing) (an explicit revokeSessionsOnPasswordReset: false is refused). Patch with a BREAKING banner, Metadata-protocol: none (plugin deployment configuration, precedent PR fix(plugin-auth)!: a cloud-managed environment refuses local self-registration, invitations (resends included), user creation and import, and sets passwords only on local accounts #25399), ADR-0087 not-required.
  • Rules held (the maintainer's rulings):
    • A completed self-service password reset ends the account's sessions on every reset door, including the emailed link, the email OTP and the phone OTP. There is no setting for this.
    • A reset request answers at once, the same way for any address, without waiting on its mail.
  • Contract review: FAIL at 35af63a44c (comment 6598074091), with three must-fixes, all in-tree prose claiming more than the code does. Delta review PASS at this head (comment 6598938473), with each must-fix judged closed in the tree. Served-tier: CONTRACT_REVIEW_TIER.
  • PR body: the seat rewrote the Acceptance notes, Tests and Verification lines for round 2. The delta review checked them against the tree sentence by sentence.
  • Check-runs at this head: 38 success, 12 skipped, 0 failed, all completed. The skips were judged by name (path-filtered UI jobs, PR-never full-battery legs, opt-in harnesses, the schedule gate). check-expected-skips.mjs NOT MEASURED (no node_modules in this container).
  • Escalated to the maintainer, not in this PR: retiring the spec key; the reset-token write still on the request path; whether a self-service reset should match the forced reset's revocation trail.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 08:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 08:32
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge November 3, 2026 08:33
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft November 3, 2026 08:33
One conflict, packages/plugins/plugin-auth/src/auth-manager.ts, two hunks:
main added assertTrustedAppSchemes at the same two entry points (the
constructor, after assertScimAdminCoherence, and applyConfigPatch, after
the merged-plugins check) where this branch adds
assertPasswordResetEndsSessions. The two assertions are independent;
both are kept, this branch's first, main's lines unchanged.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f
Local-runs: none
Scope: merge-resolution delta from the record at 9c006b9 (comment 6598938473), which stands for the PR's content — the head is a merge of origin/main 4b71a82 into the reviewed branch with one conflicted file, packages/plugins/plugin-auth/src/auth-manager.ts (two hunks), read as git diff origin/main...64669bc8e4 + packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts (the assert's home, unchanged); every other path of the net diff compared file by file against the earlier head's net diff

① Derived judgments

  • The resolution adds exactly this PR's lines and changes none of main's. The three-dot diff of auth-manager.ts against the merge base is 13 added lines and nothing removed: the import, the constructor call with its comment, and the applyConfigPatch block guarded by 'emailAndPassword' in patch. Main's assertTrustedAppSchemes lines appear only as context, byte-identical to main, in both hunks.
  • Both entry points keep both asserts, in the stated order. Constructor: assertScimAdminCoherence → assertPasswordResetEndsSessions → assertTrustedAppSchemes. applyConfigPatch: the plugins assert, then this PR's assert on next.emailAndPassword, then main's assertTrustedAppSchemes on next.trustedAppSchemes, then this.config = next. Both asserts run on the merged block before it becomes current, so a refused patch — on either rule — leaves the standing configuration ruling, as the earlier record judged for this PR's assert alone. The two rules read disjoint keys and cannot mask each other.
  • The net diff against main is otherwise unchanged. 14 files, +805/−16, the same file list as at 9c006b9, and every file but auth-manager.ts carries a byte-identical diff to the earlier head's; auth-manager.ts carries the same 13 lines at the positions the merge gave them.

② Semver level

Unchanged: patch with the BREAKING banner, Metadata-protocol: none, ADR-0087 not-required. The merge adds no declaration and touches no published surface of this PR's.

③ Boundary flags

  • Check-runs on this head: 38 success, 12 skipped, 0 failed — the reported count holds. Required contexts: seven successes (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI); Lint UI, Type Check UI and Build & E2E UI skipped on the ui/ filter, the same shape as on both earlier heads. mergeable: true, mergeable_state: clean.
  • origin/main has moved past the merge parent since the merge was made (now 9edd6e7); the reading above is against the parent the merge resolved, which is what the resolution is judged on. A later move of main is the queue's rebase, not this record's.
  • The three escalations (spec retirement, the vendor door's own work ahead of the answer, parity with the forced reset) remain with the maintainer; nothing in the merge touches them.

④ Design judgment

The merge brings in a sibling entry validation at the same two seams. Tried: an ordering in which one assert ran after this.config = next, or in which the conflict dropped one of the two — neither is in the tree. The boot-loudly shape is preserved for both rules: either refusal stops the boot at construction and leaves the standing config in place on a patch. No must-fix.

Implemented-by: claude/sec-hardening-c
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 64669bc8e48ecfb33428fb4e1cd4e3c13ffa1f8f · 2026-11-03T10:18Z

  • Why a new ACCEPT: the earlier ACCEPT (comment 6598965936, head 9c006b978b) was withdrawn. The seat disarmed auto-merge and returned the PR to draft because mergeable_state was dirty.
  • This head: a real merge of origin/main 4b71a82b7 (parents 9c006b978b and 4b71a82b7). It has one conflict, in plugin-auth/src/auth-manager.ts, resolved by keeping both entry-point assertions. The net diff against main is unchanged: 14 files, +805/−16.
  • Contract review: a merge-resolution delta PASS at this head (comment 6600957770, Served-tier: CONTRACT_REVIEW_TIER). It follows the round-2 delta PASS (6598938473) and the round-1 FAIL (6598074091).
  • Check-runs at this head: 38 success, 12 skipped, 0 failed, all completed; mergeable_state clean. Skips judged by name. check-expected-skips.mjs NOT MEASURED.
  • Escalated to the maintainer: the spec key's retirement; the reset-token write residual; whether a self-service reset matches the forced reset's trail.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 10:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 3a856f6 Nov 3, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-c branch November 3, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant