test(plugin-auth): pin the SCIM DELETE of the last administrator, de-flake the timed-ban face, and describe the 1.7.2 lifecycle - #14815
Conversation
… de-flake the timed-ban face
Adds a face driving `DELETE /Users/{id}` against the last platform
administrator. On `@better-auth/scim` 1.7.2 that verb is not a row
delete: the vendor tombstones the SCIM source, the aggregate turns
inactive, and the deprovision arrives as the same ban write as
`PATCH active: false` — so the break-glass guard refuses it through
`guardBan` on `beforeUpdate`, and `guardDelete` never runs. The
mechanism covered this from the day the reconcile callback landed;
nothing drove it.
Widens the timed-ban face's expiry from 1.5 s to 5 s and waits
`expiry + 500 ms`. The pre-PATCH sign-in has to land while the ban is
still in force, and on a loaded shard wall-clock time is not the test's
progress. Nothing the face proves depends on the number.
Adds the positive control the widened window makes necessary: same
setup, no SCIM deactivation, same wait, sign-in accepted. Without it a
green face is equally well explained by "the expiry never elapsed".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…still describes 1.6.x
`last-admin-guard.ts`'s header listed two things that stopped being true
when the vendor's own ban write was removed in 1.7.0. Shape (1) credited
`@better-auth/scim` with mapping `active: false` onto the admin ban; that
mapping is now `plugin-auth`'s own `identity.reconcileUser`. Shape (2)
filed SCIM `DELETE /Users/{id}` under "deleting the `sys_user` row"; on
1.7.2 the vendor tombstones the SCIM source and the deprovision arrives
as shape (1)'s ban write, so it is refused by `guardBan` on
`beforeUpdate` and never reaches `guardDelete` — measured by ablation,
both directions.
`auth-manager.ts`'s reconcile docblock named `admin-ban-endpoints.ts` as
the home of the platform ban write while the module's own import reads
`./user-ban-write.js`. Both modules exist, so the pointer sent the reader
to the wrong one of two live files rather than to a missing one.
Its DELETE paragraph also left implicit what the PATCH bullet above it
states: the deactivation branch clears `banExpires`, so a DELETE makes an
administrator's timed ban permanent too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…im-lifecycle-pins
… the guard header grew The last-admin-guard header rewrite in this branch added 13 net lines above the `applies to EVERY context, isSystem included` line, moving it from 286 to 299. `check-system-context-census` anchors that line from `content/docs/permissions/system-context.mdx`, and its NON_READ_ANCHORS row finds it by NEEDLE rather than by number — so the ledger row followed the code and the page's hard-coded number did not, reddening the gate in both directions at once (anchor-is-not-a-read-site + ledger-row-unused). Repaired by the gate's own `--fix`, which is the authority on the form: re-anchored .../system-context.mdx:201 `last-admin-guard.ts:286` -> `:299` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 35da8cc60775679ebda2af1255c4cf1972ecdaaa && git checkout 35da8cc60775679ebda2af1255c4cf1972ecdaaa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda f63bc0d22ab6fbfdf63fd6569d0e94d13eb9df79 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff f63bc0d22ab6fbfdf63fd6569d0e94d13eb9df79
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda
|
Fixes #14555
Resumption of a previous agent's branch. That agent pushed two commits and died before opening a PR or reporting, so nothing on the branch had been verified by anyone. This PR verifies the existing work item-by-item against the triage ruling, adds the one gate repair the work had broken, and lands it.
All measurements below were taken at
f63bc0d22.1. The previous agent's work, checked against the ruling
The ruling (
issuecomment-5514107240) scoped five items. The branch carries all five, and each matches.DELETEof the last administrator; add a DELETE face to the existing suiteit(...)atscim-deactivation-reconcile-user.test.ts:487, inside the existingdeactivating the last administrator is refused through SCIMdescribe. Asserts 403, the SCIM error schema,status: "403", adetailnaminglast administratorandADR-0024 D5.2; then that the row survives, is unbanned, still signs in, and that the vendor tombstone rolled back:430, its positive control at:468, and every PATCH face are byte-identicalimport { authIdentityObjects } from './manifest.js'(line 71),const AUTH_OBJECTS = authIdentityObjects(line 102), consumed at line 174. The diff adds no object listlast-admin-guard.tsheader to describe 1.7.2expiry + 500 msTIMED_BAN_MS = 5_000,TIMED_BAN_WAIT_MS = TIMED_BAN_MS + 500. Both faces carry60_000timeouts; the whole face measured 25.84 s wall for the entire 13-test file, so 5.5 s of sleep sits far inside it:640. Identical setup, same timed ban, same wait, no SCIM deactivation, sign-in accepted, row unbannedauth-manager.tsdocblockadmin-ban-endpoints.tsbecomesuser-ban-write.tsItem 4 — proof that
user-ban-write.tsis the live module, and that both modules exist. Both files are present on disk. The live import inauth-manager.tsis:admin-ban-endpoints.tsstill exists and still exportsrunAdminBanUser/runAdminUnbanUser, so the old spelling was a stale attribution, not a dangling reference — the docblock credited the wrong module for the write this path performs.2. Prose changed — before and after
Item 2,
last-admin-guard.tsbullet 1. Before:After: it now says 1.6.x mapped
active: falseonto the admin ban itself, that 1.7.0 removed that write in favour of an optional host callback, and that since #14360 the ban is landed byplugin-auth's ownidentity.reconcileUser— "the same column and the same hook, a different author". The installed dependency is@better-auth/scim1.7.2, so the version claim is current.Item 2, bullet 2. Before:
After, SCIM DELETE is removed from that list and called out as explicitly not a row delete on 1.7.2, arriving instead as bullet 1's ban write, "refused by
guardBanonbeforeUpdateand never reachesguardDelete". The rest of the row-delete half is unchanged.Item 5,
auth-manager.ts. Before:After:
3. Ablations — both pins proved able to fail
Every leg: mutate, prove the mutation reached disk by blob hash and by literal-text counts on both the removed and injected text, run, restore against
HEAD(never a baregit checkout -- path, which restores from a possibly-poisoned index), then prove restoration by blob equality and an emptygit diff HEAD. Each ran under atrap ... EXIT INT TERM. No mutation was proved by a comment — esbuild strips those.Ablation A — the positive control can fail. Inverted its sign-in expectation (
expectSignInAcceptedbecomesexpectSignInBanned).Mutated:
Tests 1 failed | 12 skipped (13), failing precisely on the mechanism the control exists to observe:That is the vendor's
session.createauto-lift admitting a principal whosebanExpireshas passed — exactly the alternative explanation the ruling wanted excluded. Restored: blob back toc27ebf78, emptygit diff HEAD,Tests 1 passed | 12 skipped (13).Ablation B — the DELETE face exercises the guard. The ruling's own prose claims this face is refused through
beforeUpdateand never reachesbeforeDelete, so both legs were run. Predicted directions were fixed before running, and both held.registerHook('beforeUpdate', guardBan, ...)replaced by a no-opTests 1 failed | 12 skipped (13)registerHook('beforeDelete', guardDelete, ...)replaced by a no-opTests 1 passed | 12 skipped (13)B1's failure is the DELETE succeeding and returning an empty body where the 403 SCIM error JSON was expected (
SyntaxError: Unexpected end of JSON inputat theres.json()on line 503). B2 is green because the SCIM DELETE never reachesguardDeleteat all.Together these confirm the measured claim the branch ships in the guard header — "removing the
beforeDeleteregistration leaves the SCIM DELETE face green, removing thebeforeUpdateone reddens it" — is accurate as written, and that the new face is genuinely pinned to the guard rather than to an incidental 403.4. One gate the branch had broken, repaired here
check-system-context-censuswas red on the branch as pushed, and would have reddened CI:Cause: the item-2 header rewrite added 13 net lines above the census's read site, moving the needle line
applies to EVERY context, isSystem includedfrom 286 to 299 (verified against the merge base: 286 there, 299 here). The gate's ledger row finds that line by needle, so it followed the code;content/docs/permissions/system-context.mdxhard-codes the number, so it did not — reddening both directions at once. Repaired with the gate's own--fix, which is the authority on the form:5. Verification
All at
f63bc0d22.Test Files 1 passed (1)/Tests 13 passed (13)@objectstack/plugin-auth, run becausebetter-auth-schema-parity.test.tsreadsauth-manager.tsas source text and a comment-only edit is not self-evidently inert to it):Test Files 91 passed (91)/Tests 1866 passed (1866)pnpm --filter @objectstack/plugin-auth typecheck: exit 0, includingcheck:test-typecheckovertsconfig.test.json. This matters here: the buildtsconfig.jsonexcludes**/*.test.ts, sotsc --noEmitalone says nothing about the new test code; the test-layer program is what covers it, and this file carries notest-typecheck-debt.jsonentry, so it must be at zero errors.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack. The first derivation over the three original paths gave 28 commands; the census repair addedcontent/docs/..., which pulled the count to 52. Re-deriving after that edit was load-bearing — 24 doc gates would otherwise have gone unrun. Union atf63bc0d22: 49 pass, 3PREREQUISITE NOT MET.The three unmeasured, each of which self-declares that it is neither a pass nor a finding:
check-test-completeness.mjsturbo run testlog, which only CI tees. Its own text names the dispatch-gates family as this branch and says to record NOT MEASUREDcheck:dual-build-cjs-loadsdist/. Needs a fullpnpm buildcheck:type-check-debtcheck:skill-examplesinitially exited 1 on an unmet prerequisite (client-reactunbuilt). Rather than record it unmeasured I built the closure and re-ran it: pass, 256 prose examples type-check across 3 surfaces.6. Changeset
None owed. The diff is comment-only in two source files, one test file, and one docs anchor — no exported symbol, no payload key, no behaviour change. Both edited docblocks are off the published surface: the
auth-manager.tsblock documentsprivate async reconcileScimUserLifecycle, and thelast-admin-guard.tsblock is a file-level header before the first import. This ispr-automation.yml's route 2 verbatim ("It releases nothing (.github/,.claude/,skills/,docs/,content/,examples/, tests-only, and the like) -- apply theskip-changesetlabel"), so the label is applied rather than an empty changeset.7. Co-occupancy
auth-manager.tsis co-occupied by open PR feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881) #14775 (claude/issue-13881-sys-user-locale), whose hunks sit at base lines 3029-4714 (the email/SMS locale docblocks). This branch's edits are at 5138 and 5164-5174, inside the SCIM reconcile docblock only. Disjoint. Nothing in 3029-4714 was touched, and the edits were anchored by content rather than by line number.back-channel logout planning failed … no such table: sys_oauth_access_token) #14615 / PR test(plugin-auth): register the production object set in the two SCIM harnesses #14751 is merged and already in the test file; itsauthIdentityObjectsharness registration is untouched here.scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522 (SCIM transaction residual) and [finding] SCIM forces the better-authadminplugin on (pluginConfig.admin ?? scimEffective) and a deployment has no declared way to decline it — split from #13439 by the 2026-08-31 ruling #13816 (admin forcing) are not folded in, and the five stale assertion sites fenced on [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360 are not touched.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code