Repository navigation
feat(plugin-auth): a JIT sign-in reads the IdP locale claim into the user language preference - #25303
Conversation
…user.locale (wip) Claude-Session: https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU Co-authored-by: Claude <noreply@anthropic.com>
… ledgers learn the new site and doubles The tenant-audit census and the engine-double ledger record the new sys_user.locale write and the new test file's engine double. Claude-Session: https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 19 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 1328eb5f223de1e586cd13799979e6bd0375aa63 && git checkout 1328eb5f223de1e586cd13799979e6bd0375aa63
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5801a4f2c9df6a10f808063d7c3b001b7d4381c7 611c99ab150c82c45bf3def304b730b08ff9c59e && git checkout -B drift-repro 5801a4f2c9df6a10f808063d7c3b001b7d4381c7 && git merge --no-ff 611c99ab150c82c45bf3def304b730b08ff9c59e
node scripts/docs-audit/affected-docs.mjs --json 5801a4f2c9df6a10f808063d7c3b001b7d4381c7
|
席位核对 · ACCEPT · 2026-10-22T22:42Z
Generated by Claude Code |
…-lg3wrj The two tenant-audit census documents conflicted with #25303's one new sys_user write. Both were taken from main and regenerated with node scripts/tenant-audit-census.mjs --write; the page's two hand-written erased-receiver counts were set to the census's figure (44). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
Requested by Sales · project thread
Fixes #25261
Clause-②: no
Metadata-protocol: none
Claim of record:
6351359330on the card (card thread,domain:servicesseat 1). Branchclaude/project-thread-6usz2lis the harness-pinned branch for this session; it names no issue, so the Duplicate Fix Guard's branch-name hint will not match, and the claim is the identity record.What changes
When an external IdP sign-in creates or refreshes a user just-in-time, the standard OIDC
localeclaim (OIDC Core 5.1, a BCP-47 tag) now becomessys_user.locale: the column the console reads as the signed-in user's UI language (objectuiuseUserLocale,LocaleSwitcher,ProfilePage) and service-messaging reads per recipient. On a hosted environment the cloud is the IdP (oidcProviders, providerobjectstack-cloud), so the environment follows the cloud account language. The cloud half (the control plane's OIDC provider emitslocale) is objectstack-ai/cloud#3159.The rule (
packages/plugins/plugin-auth/src/idp-locale-claim.ts, module header):sys_user.localelocaleclaimClosed against the supported list. The claim is matched against the locales the i18n service reports (
getLocales(), which the app's declaredi18n.supportedLocalesnarrows) with the platform's own matcher,resolveLocalefrom@objectstack/core(exact, case-insensitive, base language, variant), and the SUPPORTED spelling is stored (zh-cnstoreszh-CN,en-USstoresenwhenenis what is supported). The shape check is service-messaging'snormalizeRecipientLocale, the one reader of a value at rest in the column, so nothing the IdP sends can store a value the readers discard. The underscore spelling OIDC Core 5.1 notes (zh_CN) reads as the tag it stands for. With no i18n service to ask, nothing is applied (said once atwarn): judging without the list would be judging anyway.Telling a local choice from an IdP-written value. The column alone cannot say who wrote it, so the value the IdP path last wrote is recorded beside it: one
sys_verificationrow per user (identifieridp-locale:plus the user id), written through better-auth's DATABASE adapter, the implicit-linking unlink record's idiom in the same package and for the same reason (a hostsecondaryStoragecache must never be its only home). The IdP owns the column while it is unset or still holds exactly the recorded value. Every partial failure degrades toward "a local choice wins" (the column is written before the record). A deleted user's record is cleared in the composeduser.delete.after. No schema change, no new column, no new authorable key.Where it runs. better-auth hands the raw IdP profile to the
validateUserInfoseam on every action of an external sign-in:source.oauth.profilefor generic-OAuth and social providers,source.sso.profilefor@better-auth/ssoOIDC providers (measured in the installed better-auth 1.7.12:api/routes/callback.mjs,oauth2/link-account.mjs,db/internal-adapter.mjs, and@better-auth/ssodist/index.mjs). That seam may still refuse and runs before the user row exists, so it only STAGES the claim, keyed by the request's endpoint context (aWeakMap, theusersCreatedInRequestpattern). The session the sign-in mints, new user or returning, settles it in the composedsession.create.after. A refused sign-in mints no session and the staged claim dies with its request. Applying the claim never fails a sign-in: a store that refuses the write is reported atwarn(a functional degradation, the user visibly sees the default language) and the user keeps the language they had.auth-manager.tsgains only the three call sites and the instance; the logic is in the new file, so the in-flight move-only split of that file (#25224) and this change merge in either order.Pins
packages/plugins/plugin-auth/src/idp-locale-claim.test.ts, 22 cases:oauthand ansso-oidcsource, a present-but-unusable value, no claim for SAML / password / SCIM sources; the supported spelling (exact, case, base, variant), the underscore spelling, unsupported and malformed claims; the decision (creation, following, a local choice wins three ways, an unsupported claim clears only an IdP-written value, a locally cleared column goes back to the IdP, no-op); the deleted-user half.implicit-account-linking.test.tsharness shape), real OAuth round trips through the platform IdP (/sign-in/socialthen/callback/objectstack-cloud, token and userinfo stubbed), reading what landed insys_userandsys_verification. The three acceptance cases of the card:locale: "zh-CN"creates a user whosesys_user.localeiszh-CN;locale: "en"after the user pickedja-JPlocally keepsja-JP;locale(fr-FR) leaves the column unset;and following (
zh-CNthenen-USstoresen), an unsupported claim clearing an IdP-written value but never a local one, no claim changing nothing, a pre-existing local value kept, a user store that refuses the write (sign-in still lands,warn), and no i18n service (nothing written, onewarn).One-time proofs (not left as tests), each in
scripts/ablation-replace.mjs(anchor must hit once, blob must move, restore proven: blob equal to HEAD andgit diff HEADempty). The subject is imported from source, so there is nodist/leg.settlecall insession.create.afterdecideIdpLocaleWriteresolveIdpLocale(raw tag stored)Tests and gates
Read on head
611c99ab(after mergingmainatf47f7f22). Every test run went throughscripts/pm/os-verify-lock.sh; each exit code was captured before any pipe.pnpm build:VERDICT command-exit 0,Tasks 76 successful, 76 total.pnpm --filter @objectstack/plugin-auth test:Test Files 186 passed (186),Tests 3515 passed | 10 skipped (3525).pnpm --filter @objectstack/plugin-auth typecheck(src, examples and the test layer): exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 109 commands on611c99ab; all 109 exit 0 (check:type-check-debtunder the lock;check-plugin-teardown-shape --self-testafter fetching its pinned fixture commit;check:dual-build-cjs-loadsafter the full build).--ran:109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. The four roster gates whose roster sits under a directory this diff touches (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity): exit 0.dispatch-gatesnames. No dogfood battery is owed (no!in the title, no(narrowing)arm).Derived artefacts this diff moves (mechanical, each regenerated by its own tool):
scripts/engine-double-contract.pinned.json: the new test file's engine double (node scripts/check-engine-double-contract.mjs --write).content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md: the newsys_userwrite is one more application-surface write call site (236 to 237; decidable 160 to 161; decidably elevated 134 to 135; it carries anisSystemcontext). The generated regions come fromnode scripts/tenant-audit-census.mjs --write; the hand-written figures the gate holds to the census (six places on the page) were moved by hand to the census's new figures, andcheck-tenant-audit-censusis green.These three paths sit outside the claim's declared file surface; they are the gates' own ledgers for the declared change, not a widening.
Acceptance notes
localesets the preference the same way; SAML sign-ins carry no standard locale claim and are not read.sys_verificationrecord is a provenance column onsys_user(aplatform-objectschange in another lane); the record keeps the change insideplugin-authwith no schema change. Either way the behaviour above is the same.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU