Uh oh!
There was an error while loading. Please reload this page.
fix(auth): an invitation reads the invitee's own sys_user.locale when the address already holds a row - #15119
Conversation
…en the address already holds a row The two invitation sends kept the deployment rung while the other four auth sends gained a per-recipient one in #14762. The recorded reason — an invitee has no `sys_user` row until acceptance — covers only one of the two populations an invitation reaches, so both sends now take a two-branch shape: 1. an address / phone number that ALREADY carries a row (an existing user invited into a second organization, a re-invitation, or an imported phone-only account) uses that row's `locale`; 2. a genuinely new invitee with no row keeps the deployment default. The inviter direction stays rejected on both branches, now pinned against a manager that HAS the top rung wired rather than one with no rung at all. Reachability of branch 1 is measured, not assumed: better-auth's `create-invitation` rejects only an address already a member of THIS org (`routes/crud-invites.mjs`, installed 1.7.2), and the SMS invite's one in-repo caller creates the account before it sends. 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 44226537f30f7cc46ce5f82834776a55082a0570 && git checkout 44226537f30f7cc46ce5f82834776a55082a0570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f594e70d796cbdd8e751c30105ede566acfec584 914eec5c802f51d741b1d4f60ecc221f87228834 && git checkout -B drift-repro f594e70d796cbdd8e751c30105ede566acfec584 && git merge --no-ff 914eec5c802f51d741b1d4f60ecc221f87228834
node scripts/docs-audit/affected-docs.mjs --json f594e70d796cbdd8e751c30105ede566acfec584
|
… pins their own #14641 describe Two wording items from the Clause-2 contract review, which passed. No behaviour, assertion or fixture changes. 1. The changeset claimed an imported phone-only account's `locale` wins on the SMS invite path. Re-measured: `admin-import-users.ts` contains 0 occurrences of `locale` (positive control: `sendInviteSms` twice in the same file) and `sys_user.locale` declares no column default (positive control: `defaultValue` appears 15 times elsewhere in that object). So on the only in-repo caller the row exists but its column is empty at send time, and that flow still resolves to the deployment default — the pre-change behaviour. The rung is wired and answers for an out-of-repo caller or a future import that populates the column; a changeset becomes release notes, so it now says exactly that. The same overstatement is corrected in the three code comments carrying it (`sendPhoneInviteSms`, `setDefaultSmsLocale`, `phone-sms-texts.ts`). 2. The five SMS invitation pins were nested inside `describe('#14762 ...')`, so their reporter path credited the wrong card. They now sit in their own sibling describe naming #14641. Move only — no re-indentation, and no `expect`/`await`/`const` line in the diff. `content/docs/**` deliberately untouched: the review found the shipped sentence literally accurate as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
hotlong
commented
Sep 4, 2026
Director audit (summon 13, session_01WXyGTWPbbreqXow7Z2pZCk), head Generated by Claude Code |
Landing — contract review PASSED, patch round 2 verified, carriers cleared, armed
CI on head Landing order observed: carriers cleared first (both What patch round 2 corrected, and what it found that nobody orderedThe Clause-② contract review (PASS, verdict adopted at ⭐ The dev extended the fix past the two places it was given. The same overstatement sat in two sibling comments it had authored the round before ( The test move was proved, not asserted. No ablation re-run, deliberately and stated: the round changes no executable line, so round 1's result (predicted 9 red, measured 9, test-for-test) still describes this tree — and the review re-derived the same conclusion independently by mutation, wiring the inviter direction and getting exactly the 2 red it should. Recorded so it does not evaporateOn the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical — no request rung sits between them. Only #14762's existing pin distinguishes them; the #14641 block alone cannot, and cannot in principle. This is a limit of the surface, not a defect in the pins, and it is carried in the PR body as well as here. On MERGED, Generated by Claude Code |
Fixes#14641
What this changes
The four auth sends whose requester is the recipient gained a per-recipient language rung in #14762 (
sys_user.locale, ruled on #13881). The two invitation sends did not, and the recorded reason was structural rather than an oversight: an invitee generally has nosys_userrow until they accept, so there is no stored language to read — and the inviter'sAccept-Languageis the wrong authority, since an English-speaking admin would then silently send English invitations to a Chinese-language workspace's new hires.That reason covers only one of the two populations an invitation actually reaches. Both invitation sends now take a two-branch shape:
sys_userrow whoselocaleis set — an existing platform user invited into a second organization, or a re-invitation → that row'slocalewins;⛔ The inviter direction stays rejected on both branches.#13881's ruling item 3 fixes the chain as recipient locale → deployment default; what opened here is the invitee's own column, never the inviter's header. That abstention is now pinned against a manager that has the top rung wired, which is a stronger pin than the old one against a manager with no rung at all.
The card's terminal state, as implemented
Terminal state C reads 「邀请邮件按被邀请人存储的语言选模板」. Taken literally that cannot hold for every invitee, because most invitees have no row to read. The two-branch shape above is the implementable reading of the ruled state — a clarification, not a re-opened option.
Branch 1 is reachable — measured, not assumed
This was the card's stop-and-report condition, so it was measured first.
create-invitationroute rejects only an address that is already a member of this organization (USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION,plugins/organization/routes/crud-invites.mjs:124-127in the installed 1.7.2). An existing account invited into another org reachessendInvitationEmailnormally, and so does theresendbranch (:150). Accepting an invitation in fact requires a session whose email matches (:269), so an invitee who already holds an account is the mainstream flow, not an edge case.sendPhoneInviteSmsreaches a row by construction: its one in-repo caller, the identity import endpoint'sinvitepolicy, creates the account and only then sends the message.admin-import-users.tsnever writeslocale(0 occurrences; positive control:sendInviteSmstwice in the same file) andsys_user.localedeclares no column default (positive control:defaultValueappears 15 times elsewhere in that object). So on the only in-repo caller the column is empty at send time and the invitation SMS still resolves to the deployment default — unchanged for that flow. What the rung buys there is that an out-of-repo caller, or a future import that populateslocale, is read rather than ignored. The behaviour users see change today is on the invitation email.Scope: both invitation surfaces, per the repo's own record
The card title names the invitation email, but three shipped in-repo comments assign the SMS invite rung to this card by number —
auth-manager.ts(setDefaultSmsLocale,renderPhoneSmsBody),phone-sms-texts.ts:25, and the pinauth-manager.test.tscarried asthe SMS INVITE path is untouched — its rung is #14641's. Same defect class, same gate family, same file. Leaving it would have left the card's own named surface half done.Matching is exact, and that is safe here rather than merely tolerable
better-auth lowercases the invitee address on the invite route and the stored
user.emailon sign-up (api/routes/sign-up.mjs:166), so both sides of the predicate are already in the same case.emailandphone_numberare bothunique: truein theusertablesys_useris backed by (@better-auth/coredb/get-tables.mjs). An address that resolves no row lands on the deployment default — the documented floor, not a failure — and, as everywhere else on this ladder, a failing recipient read never blocks a send.Pins
Both branches are pinned on both surfaces, and every locale pin asserts in both directions — the marker for the locale that should have won present, the marker for the other absent — with the two tags swapped in a second case so neither can be the tag that always wins.
The SMS pins assert on genuinely different rendered text (
账号已开通vsSign in with this phone number, the two built-in invite bodies).Branch 2's default is shown to come from the default path, not from branch 1 silently resolving nothing. Both outcomes are otherwise identical from the payload, so each branch-2 pin drives one engine, one table, two addresses: it answers
zh-CNfor the address that carries a row and nothing for the one that does not, in the same test. That separates "the read ran and found nothing" from "the read never ran" / "this engine answers nothing". The read predicate is asserted separately (where,fields,context.isSystem), including that the address read is the invitee's and not the inviter's — the inviter is given a row with a different language, and it must not be reached at all.Also re-pinned: the
#2766placeholder-address refusal still happens before any recipient read, now that a read sits on this path.Ablation
Both rungs reverted (
emailLocaleArg(undefined, storedLocale)→emailLocaleArg(); the SMSstoredLocaleargument dropped) while keeping both reads, so the ablation isolates the rung rather than the lookup.auth-email-locale.test.ts, 3 inauth-manager.test.ts; with the inviter-direction pin and the read-predicate pins staying green, because they measure the abstention and the read, not the rung.Tests 9 failed | 323 passed (332)— the same nine, test for test. No divergence.The mutation was confirmed on disk before measuring (injected marker count 2, the removed spellings at 0 and 1 — the OTP site legitimately keeps its own
storedLocaleargument — and the blob hash differing from HEAD). The restore leg was proven the same way, not by the trap firing:git diff HEADempty,git hash-objectbyte-identical to the HEAD blob, and zero markers left. No rebuild was needed — the suites import the package's ownsrc/, not itsdist/.Verification
pnpm --filter @objectstack/plugin-auth test— 93 files / 1974 tests passed.pnpm --filter @objectstack/plugin-auth run typecheck— green, includingcheck:test-typecheck(test-typecheck debt ledger held: 10 files / 94 errors / 23 pinned signatures, unchanged).tsc --noEmitprogram excludes*.test.ts(--listFiles→ 0 hits for both edited test files). It ischeck:test-typecheck, undertsconfig.test.json, that compiles them —--listFilesconfirms both are in that program.pnpm --filter @objectstack/plugin-email exec vitest run src/auth-templates-locales.test.ts— 34 passed; it owns the template-rows half and asserts no invitation locale rule.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which takes the change set from the merge base itself): 74 commands, 72 green, pluscheck-adr-anchorsgreen (always-runs tail, excluded from the derived family).d23d9664ef, which is this branch's head.Gates answering NOT MEASURED (exit 3), and whether this diff can move them
Neither is a finding, and neither is a pass:
check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: it reads built output and 41 packages have nodist/in this worktree. This diff cannot move it — it adds no export and edits nopackage.json. CI builds the repo and measures it there.check-test-completeness— exit 3 by construction: it needs a savedturbo run testlog, and the derived family names it with no argument. Its own output says to record it as NOT MEASURED.check:skill-examplesfirst exited 1 for the same prerequisite reason (packages/client-react/distheld no declarations — a prerequisite wearing a finding's exit code, not a red). Buildingclient-reactandclientconverted it into a real reading: 257 prose examples type-check across 3 surfaces.ESLint — a declared narrowing, not a skip
Repo-scale
pnpm lintis CI's run. Locally this is a narrowing with its three pieces of evidence:.mdand.mdxasFile ignored because no matching configuration was supplied— so its population among these changes is the 4 TypeScript files.--format json: 6 results, 0 errors, the only 2 messages being those two "ignored" notices. The 4 linted files are clean.eslint.config.mjsstates, with its own recorded positive control (lines 326-335), that this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not". Every rule is therefore per-file syntactic, and this diff cannot move a verdict on a file it did not touch.The
.md/.mdxare covered instead by the docs gates in the derived family —check:corpus-claim-drift,check:doc-authoring,check:doc-anchors,check:docs-single-h1,check:docs-audit-scope,check:doc-frontmatter,check:docs-section-name— all green.Docs
content/docs/permissions/authentication.mdxsaid "The invitation SMS reads the deployment default alone." This change makes that sentence false, so it is corrected in this PR: both SMS bodies now resolve the same way, with a note that for the invitation the account normally does exist because identity import creates it before sending.Swept
content/docs/**for any other statement of the invitation locale rule. Zero further hits, with a positive control: the terminvitationis live in the corpus (16 occurrences inauthentication.mdxalone, hits across 10 pages), so the zero is a reading rather than a broken search. No shipped page states the invitation email locale rule — the auth email ladder is undocumented as a whole, so nothing there is falsified.content/docs/releases/v17.mdxmentions invitations but states no locale rule for them, and is release-owned in any case — not touched.Clause ②: YES —
needs:contract-reviewapplied, and deliberately not clearedThis diff makes a falsifiable contract-semantics claim rather than an operator-list or spelling change. It extends the #13881 / #14788-ruled resolution ladder onto a send the ruling record had explicitly carved out, and it does so on a two-branch reading that no ruling states verbatim — the reading is a clarification this lane authored to make the ruled terminal state implementable at all. The accept set of "which locale an invitation renders in" changes for a real population (every invitee who already holds an account).
Against that: no
packages/specedit, no new public export, no signature change, and the changeset is apatch. The carrier is applied for the semantics claim, not for a surface widening. Per the dispatch, this lane does not clear it — the seat clears it after review.Changeset
.changeset/invitation-invitee-stored-locale.md—@objectstack/plugin-auth: patch.Patch round 2 — Clause ② contract review PASSED
The review was adversarial and mutation-based: it wired the inviter direction and confirmed the abstention pin fails (2 red, exactly as it should). It confirmed branch 1 reachable on both surfaces, the locale discrimination genuine (present-AND-absent, tags swapped), scope clean (
deliverPhoneOtp, the four #15107 sends andphoneSmsLocaleChainall untouched), the docs replacement accurate including the no-row case, and no stale shipped statement left behind.Two wording items followed, both landed in
914eec5c80— no behaviour, assertion or fixture change:sendPhoneInviteSms,setDefaultSmsLocale,phone-sms-texts.ts).content/docs/**deliberately untouched — the review found the shipped sentence literally accurate as written.describe('#14762 …'), so their reporter path credited the wrong card. They now sit in their own sibling describe naming finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641. A move only: no re-indentation, and noexpect/await/constline in the diff. Verified withvitest list— all five now read… > #14641 — the invitation SMS reads the invitee's own locale > …, and the#14762block retains exactly its 7 OTP tests.Re-verified on
914eec5c80: 332/332 on the two suites (matching the review's baseline exactly),typecheckgreen with the test-typecheck debt ledger unchanged (10 files / 94 errors / 23 pinned signatures), and the changeset / comment-mask / census / keyed-text / nul-byte ratchet gates all green on the committed head.The review also recorded one limit that is not this PR's to fix: on the invitation surface, "branch 2 took the default path" and "branch 1 resolved to nothing" are observationally identical, and only #14762's existing pin distinguishes them — the #14641 block alone cannot, in principle.
⛔
needs:contract-reviewstays applied: the review passed, and the seat clears the carrier as the last step before arming.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code