Skip to content

Commit 9a552db

Browse files
committed
docs(qa): re-point identity-auth's get-session clauses at the 401 refusal envelope
The five sites in `docs/qa/platform-checklist/areas/identity-auth.json` that taught better-auth's retired no-session convention (HTTP 200 + a JSON `null` body) were inverted by #17238/#17881: `refuseAnonymousSession` now converts that answer into `401` + `UNAUTHENTICATED` in the ADR-0112 refusal envelope. A runner following the file scored the correct implementation as defective, and the remedy the negative pointed at was undoing an auth tightening. Four instructional sites re-pointed (the step, clause 5's verify, the third negative, the evidence row). `revision 3` is left standing and unedited -- it records what the August judgement rested on -- and a new `revision 6` states that its cited authority was inverted afterwards. Measured on this checkout rather than relayed: live session 200 with { user, session }; after revoke-sessions, 401 UNAUTHENTICATED; fresh-manager control still 200. The refusal covers the REVOKED path, not only the never-signed-in one, because the seam keys on the answer shape. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02e19a7 commit 9a552db

1 file changed

Lines changed: 11 additions & 5 deletions

File tree

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@
370370
"title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins",
371371
"since": "v16",
372372
"status": "active",
373-
"revision": 5,
373+
"revision": 6,
374374
"priority": "P1",
375375
"surface": "mixed",
376376
"personas": ["platform admin", "target user", "non-admin forger"],
@@ -393,7 +393,7 @@
393393
"attempt to sign in as the banned user (POST /api/v1/auth/sign-in/email) and capture the refusal",
394394
"unban, then verify the same sign-in now succeeds",
395395
"set the target's password via the admin set-password (out-of-band recovery); verify the NEW password signs in and the OLD one is refused",
396-
"sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts), so a status-only assertion passes against a fully revoked session",
396+
"sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code — and since #17881 that is no longer because it cannot discriminate: an anonymous OR revoked get-session answers 401 with the ADR-0112 refusal envelope (code UNAUTHENTICATED), converted from better-auth's bare 200+null by packages/plugins/plugin-auth/src/anonymous-session-refusal.ts, while a live session still answers 200 with { user, session }. It is not the oracle because this clause's contract is an immediate kill on a PROTECTED request, and one auth-route seam's status is not proof the AUTHORIZATION layer refuses the target's in-flight protected reads",
397397
"change the target's role via POST /api/v1/auth/admin/set-role and prove the change bites: an operation the new role gates flips outcome (e.g. promote → an admin-only read now 2xx; demote → it now 403)",
398398
"impersonate the target from the admin surface; verify via the API that the impersonation session carries impersonated_by, and screenshot the console's impersonation state; stop impersonating and verify the admin's own session is restored",
399399
"POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row (task/note), then read that owned row back: its owner_id is cleared to null (engine referential-integrity FK clear), the row itself survives, and the owner-anchor transfer guard did NOT veto the cascade (#3023/#3048)",
@@ -428,7 +428,7 @@
428428
{
429429
"clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a PROTECTED authed request that succeeded a moment earlier is refused immediately after the admin revoke — not at token expiry",
430430
"oracle": "api",
431-
"verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts). If get-session is captured at all, read its BODY (user null) as corroboration only",
431+
"verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause. Since #17881 an anonymous OR revoked get-session answers 401 UNAUTHENTICATED in the ADR-0112 refusal envelope (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts) while a live session still answers 200 with { user, session }, so a 401 there is the CORRECT answer and must never be filed as the defect. It stays off the oracle seat for a reason that does not move with the wire: this clause's contract is an immediate kill on a PROTECTED request, and a refusal emitted by that one auth-route seam is not proof the authorization layer refuses the target's in-flight protected reads. If get-session is captured at all it is corroboration only — read its BODY as well as its status, the discipline packages/plugins/plugin-auth/src/session-of-record.test.ts states: a session that is gone is proven gone by the absence of a user, not by a status the file would then be trusting a single seam to keep emitting",
432432
"evidence": "the before/after protected-request pair (plus the get-session body, if captured)"
433433
},
434434
{
@@ -465,7 +465,7 @@
465465
"negative": [
466466
"a non-admin forged admin operation succeeding is a FAIL of the highest severity — apply RUNNER rule 7 (independent re-derivation) before acting on it",
467467
"a ban that hides the user in the UI while their sign-in still works is a FAIL — the sign-in refusal is the enforcement, not the list filter",
468-
"revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. A get-session that answers 200 after the revoke is NOT that failure (better-auth's no-session convention is 200-with-null-body); filing it as one is the false positive run #7663 corrected — read the body, or better, re-drive a protected request",
468+
"revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. Two scoring errors to avoid on get-session, and since #17881 inverted the wire answer they point OPPOSITE ways. (a) A 401 there after the revoke is the CORRECT answer, not the failure — filing it as one is the false positive run #7663 corrected, and the remedy that mis-filing invites is undoing anonymous-session-refusal.ts, an auth tightening. (b) A 200 carrying a user after the revoke is no longer the benign no-session convention this line once described; the live-session answer is the only 200 left, so it IS a real signal the kill did not land. Neither is this clause's oracle: read the body, or better, re-drive a protected request",
469469
"remove-user aborting because the owner-anchor guard vetoed the owner_id-null cascade (instead of exempting the engine FK clear) is the #3023 regression returned — FAIL; equally, a create-user that applies the GENERATED password when an explicit one was supplied is the #3031 failure — FAIL"
470470
],
471471
"automated": {
@@ -480,7 +480,7 @@
480480
"packages/plugins/plugin-security/src/security-plugin.ts#__referentialFieldClear (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'",
481481
"packages/spec/src/kernel/public-auth-features.ts#sys_user (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0134)",
482482
"packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts",
483-
"packages/plugins/plugin-auth/src/session-of-record.test.ts#body (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)"
483+
"packages/plugins/plugin-auth/src/anonymous-session-refusal.ts#ANONYMOUS_SESSION_REFUSAL_STATUS (since #17881 an anonymous or revoked /get-session answers 401 with the ADR-0112 refusal envelope, code UNAUTHENTICATED derived from that status; a live session still answers 200 with { user, session }. Both legs are driven end to end in packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts, and the body-not-status discipline for a revoke is kept in packages/plugins/plugin-auth/src/session-of-record.test.ts)"
484484
],
485485
"history": [
486486
{ "revision": 1, "date": "2026-08-07", "change": "new item: admin lifecycle operations with persistence, enforcement, attribution and both-sides gate checks", "ref": "claude/platform-test-checklist-ocwugl" },
@@ -492,6 +492,12 @@
492492
"date": "2026-08-24",
493493
"change": "the ADMIN side pinned, and three clauses re-graded from 'blocked on a product decision' to 'ruled'. Revision 4 recorded that ban/unban/set-role/remove-user/impersonate/revoke-user-session(s)/list-users/get-user/list-user-sessions/update-user all refuse the platform admin; that is no longer true. Re-measured on main: #9970 re-mounted ban-user/unban-user with the ADR-0068 gate and #10352 (consolidated onto hasPlatformAdminStanding by #11686) re-authorized impersonate-user, so those answer 200 and clauses 0 and 6 are now pinned with their stored effects read back. The remaining EIGHT routes refuse the platform admin BY DESIGN — #9969 closed not_planned for seven consumer-less routes, #9968 ruled B for set-role — so clauses 3, 4 and 5 are not coverage debt but clauses whose operation the maintainer declined to build. The new pin asserts both sides with SEPARATE instruments, behind an admin-identity control proving the subject is a platform admin who does NOT carry the legacy role scalar",
494494
"ref": "#9482"
495+
},
496+
{
497+
"revision": 6,
498+
"date": "2026-09-17",
499+
"change": "CORRECTION, and a correction OF a correction. Revision 3 was RIGHT when it was written: in August 2026 better-auth's bare no-session answer really was HTTP 200 with a JSON null body, so the literal 401 expectation it removed really did misdescribe a correct implementation. Its cited authority was then INVERTED underneath it — #17238 ruled the answer, #17881 landed refuseAnonymousSession (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts), and packages/plugins/plugin-auth/src/session-of-record.test.ts now pins the very 401 it was cited for denying. Revision 3 is therefore left standing and UNEDITED: it records what the judgement rested on at the time, which is the whole point of keeping a revision history. This revision re-points the four instructional sites (the step, clause 5's verify, the third negative and the evidence row) at today's live wire answer, measured on this checkout rather than relayed: a sign-up read 200 with { user, session }, a revoke-sessions then read 401 with {\"success\":false,\"error\":{\"code\":\"UNAUTHENTICATED\"}}, and a fresh manager's new session still read 200 as the control — so the refusal covers the REVOKED path and not only the never-signed-in one, because the seam keys on the answer shape (/get-session + 200 + a body that is exactly null) rather than on how the caller became anonymous. The SUBSTANTIVE advice is unchanged and now rests on something that does not move with the wire: get-session is still not this clause's oracle, because the contract is an immediate kill on a PROTECTED request and one auth-route seam's status is not proof the authorization layer refuses one. Consistency between this file and the runtime's refusal envelope is now pinned by packages/plugins/plugin-auth/src/checklist-refusal-envelope-consistency.test.ts, which reads the status and code from the runtime constants rather than from a copy, so the next behaviour change reds instead of replaying this drift",
500+
"ref": "#18650"
495501
}
496502
]
497503
},

0 commit comments

Comments
 (0)