Repository navigation
fix(plugin-auth): the self-registration grant settles on the registrant's first membership (ADR-0131 D3, C8 8b-0f) - #24453
Conversation
…embership (WIP) Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
…rship Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
…ganization; changeset Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
…grant settles with Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
…xt; tenant-audit census regenerated Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
…0f-grant-on-first-membership
… double Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 10 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 0beeeb170030eacc3869aca88930a60c96fc54fa && git checkout 0beeeb170030eacc3869aca88930a60c96fc54fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa26308bb0332152a359335be27ba1a7f18574de 29b223ed33d3a3e7181d9c3fb87e75e7392d661d && git checkout -B drift-repro fa26308bb0332152a359335be27ba1a7f18574de && git merge --no-ff 29b223ed33d3a3e7181d9c3fb87e75e7392d661d
node scripts/docs-audit/affected-docs.mjs --json fa26308bb0332152a359335be27ba1a7f18574de
|
Part of #24343
Part of #15212
Clause-②: no
C8 stage 8b-0f. It carries ruling Q1 A on #24377 (record 6257510973, item 1): the self-registration grant settles on the first membership. The card stays open because Q2 (stage 8b-0d) still lands on it.
What changed
The declared self-registration set (
auth.audience.selfRegistrationPermissionSet) is an assignment, and ADR-0131 D3 makes an assignment an organization row. So the grant is now written with the registrant's first membership and owned by that membership's organization, under every posture. Nothing is written tosys_user_permission_setwithout an organization.All runtime changes are in
packages/plugins/plugin-auth/src/auth-manager.ts:settleSelfRegistrationGrant(user.create.after). It consumes the admission stage as before, then reads the registrant's membership.single, the reconciler bound the Default Organization a moment earlier in the same hook; or a host hook provisioned an organization first). The grant lands now, owned by that organization.sys_verificationrow. Itsidentifierisself-registration-grant:plus the user id, itsvalueis the set name, and it waits 365 days.sys_verificationis the auth layer's existing store of one-shot claims keyed on an identifier with an expiry. It survives a restart, and it has no organization column (ADR-0131 D1 fate 1, inventorynoColumn).observeFirstMembershipsis registered once per engine, from the constructor. It is a middleware onsys_memberinserts, the same seam plugin-security'sorganization_adminauto-grant uses. Every membership writer inserts through the engine: better-auth's organization create (the creator as owner), invitation acceptance and server-side add-member, the membership reconciler, the Default Organization bootstrap, and host hooks. One seam therefore covers all of them, with no hook per path.settleDeferredSelfRegistrationGrantconsumes the claim first (read, delete by id), then writes the grant owned by that membership's organization. A later membership finds no claim and writes no second grant. The existing by-name check also holds.errorat sign-up, and no claim is parked.derivableWriteOrganizationIdno longer lists the self-registration grant among its readers, because the grant does not read it any more. Its code andauthMailOrganizationArgare unchanged, and no identity-mail send site was touched.Measured (A1–A5)
sys_memberrow on this tree are: better-authorganization/create(owner) andaccept-invitation, better-auth add-member,reconcileMembership(user.create.after,session.create.before, the one-time backfill),ensureDefaultOrganization's owner bind, and hostuser.create.afterhooks. All of them write through the data engine, so thesys_memberinsert middleware sees each one (plugin-auth 的databaseHooks文档注释断言「better-auth 的适配器绕过 ObjectQL 中间件链」——已过时,今天 dataEngine 与 ql 是同一个实例 #4802's corrected mechanism, quoted inauth-manager.ts). The walled dogfood pin drives the real better-auth organization create through it. Undersingle, the census pin shows the sign-up still ends with exactly one grant, owned by the Default Organization.sys_user.sourceseparates onlyidp_provisionedfromenv_native, andsys_account.provider_idiscredentialfor admin-created users too. So no existing user or account field marks a self-registrant, and a persisted claim is needed.sys_verificationis the narrowest existing store that fits: deployment-level, durable, and with an expiry better-auth already sweeps (findVerificationValuedeletes expired rows). Thevaluecolumn isinternal, so it is read back throughrecoverInternalFieldsForSystemRead.no. No spec, schema, export or route changes.AudienceConfigSchema.selfRegistrationPermissionSetdescribes "the permission set granted to each self-registrant", and the settings manifest and its translations say the same. None of them promises the grant at sign-up time, and the generated reference page carries only thatdescribe. Nopackages/specbyte moved.Tests (head
29b223ed3)plugin-auth/src/self-registration-grant-first-membership.test.ts, 9 cases.single: one grant, owned by the Default Organization, no claim.error, nothing parked.dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts, 3 cases, real stack,isolated(posture-only), open audience. The sign-up writes no grant.POST /auth/organization/createlands exactly one, owned by the new organization. A second organization lands none.audience-posture.test.ts: the four end-to-end "grant lands" cases now runsingle(a tenancy service), so the reconciler binds a membership, and one of them asserts the owner.find-envelope-limb-removal.test.ts: the D4 grant-by-name case binds the membership first.producers-name-organization.test.ts: its two grant cases are removed. Thesingleone is re-pinned in the new file. The walled one asserted the behaviour this stage retires (a grant with no organization), and the new walled pins assert the opposite.scripts/ablation-replace.mjs, EXIT trap). The claim write was mutated back to a sign-up grant with no organization (markerups_ABLATED_, anchor 1→0, blobdebc7651→624afb11). After the rebuild,ablation-dist-preflightfound the marker indist/index.jsanddist/index.mjs. The DTS step failed on TS6133 (the now-unused claim method), but the JS that the suite consumes was emitted.null.git diff HEADempty, rebuild, preflight--absentexit 0, dogfood 3/3 green.29b223ed3:@objectstack/plugin-authtest, 165 files, 3165 passed, 10 skipped; typecheck exit 0. Dogfood, after a plugin-auth rebuild: the walled pin,signup-organization-census(single) andorg-create-default-team(walled), 3 files / 7 passed.29b223ed3:dispatch-gates.mjs --commands(no paths), 108 families plus the 51-row artifact roster, each exit code captured before any pipe.--ranreads: 108 derived, 108 run, 0 NOT-MEASURED.check-tenant-audit-census(the riders below),check:engine-double-contract(ledger row), andcheck:where-matcher(the new double now refuses WHERE combinators).check-closing-target-claim,check-single-claim-paths, andcheck-governed-queue-guard(no event payload, exit 1). CI's reading counts for those.check-partof-closing-keyword, run with this body asPR_BODY, exits 0.Riders
content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md. These were regenerated (node scripts/tenant-audit-census.mjs --write) and the page's prose figures updated, becausecheck-tenant-audit-censusholds them to the census and this stage moves the write-call-site population from 229 to 231. The three plugin-auth writes state{ context: { isSystem: true } }at the call, so they count as elevated. The "cite2 / N" provable-yield figure stays 2.scripts/engine-double-contract.pinned.json: one row for the new test'sdeletedouble (check-engine-double-contract --write).Acceptance notes
isDefaultbaseline, so nothing changes there.singlewithmembershipPolicy: 'invite-only', a self-registrant gets no membership at sign-up, so the grant now waits for the first membership there too, as the ruling's "under every posture" says. Underauto(the default),singleis unchanged.Generated by Claude Code