Skip to content

feat(plugin-auth): a JIT sign-in reads the IdP locale claim into the user language preference - #25303

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-6usz2l
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-6usz2l

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Fixes #25261

Clause-②: no
Metadata-protocol: none

Claim of record: 6351359330 on the card (card thread, domain:services seat 1). Branch claude/project-thread-6usz2l is 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 locale claim (OIDC Core 5.1, a BCP-47 tag) now becomes sys_user.locale: the column the console reads as the signed-in user's UI language (objectui useUserLocale, LocaleSwitcher, ProfilePage) and service-messaging reads per recipient. On a hosted environment the cloud is the IdP (oidcProviders, provider objectstack-cloud), so the environment follows the cloud account language. The cloud half (the control plane's OIDC provider emits locale) is objectstack-ai/cloud#3159.

The rule (packages/plugins/plugin-auth/src/idp-locale-claim.ts, module header):

sign-in what happens to sys_user.locale
first creation, supported claim set to the claim, in the supported spelling
later sign-in, the column still holds what the IdP last wrote (or is unset) follows the claim
later sign-in, the user picked a language in the environment kept: a local choice wins
unknown or unsupported claim left unset (an IdP-written value is cleared), so the deployment default applies
no locale claim unchanged

Closed against the supported list. The claim is matched against the locales the i18n service reports (getLocales(), which the app's declared i18n.supportedLocales narrows) with the platform's own matcher, resolveLocale from @objectstack/core (exact, case-insensitive, base language, variant), and the SUPPORTED spelling is stored (zh-cn stores zh-CN, en-US stores en when en is what is supported). The shape check is service-messaging's normalizeRecipientLocale, 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 at warn): 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_verification row per user (identifier idp-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 host secondaryStorage cache 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 composed user.delete.after. No schema change, no new column, no new authorable key.

Where it runs. better-auth hands the raw IdP profile to the validateUserInfo seam on every action of an external sign-in: source.oauth.profile for generic-OAuth and social providers, source.sso.profile for @better-auth/sso OIDC 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/sso dist/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 (a WeakMap, the usersCreatedInRequest pattern). The session the sign-in mints, new user or returning, settles it in the composed session.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 at warn (a functional degradation, the user visibly sees the default language) and the user keeps the language they had.

auth-manager.ts gains 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:

  • Pure (13): the claim off an oauth and an sso-oidc source, 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.
  • End of the chain (9): a real better-auth pipeline over the in-memory engine (the implicit-account-linking.test.ts harness shape), real OAuth round trips through the platform IdP (/sign-in/social then /callback/objectstack-cloud, token and userinfo stubbed), reading what landed in sys_user and sys_verification. The three acceptance cases of the card:
    • a sign-in with locale: "zh-CN" creates a user whose sys_user.locale is zh-CN;
    • a later sign-in with locale: "en" after the user picked ja-JP locally keeps ja-JP;
    • an unsupported locale (fr-FR) leaves the column unset;
      and following (zh-CN then en-US stores en), 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, one warn).

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 and git diff HEAD empty). The subject is imported from source, so there is no dist/ leg.

leg what is undone file reading
1 the settle call in session.create.after 5 failed / 17 passed: acceptance 1, following, no claim, store refusal, no i18n
2 the local-choice guard in decideIdpLocaleWrite 4 failed / 18 passed: acceptance 2, the pure local-choice cell, unsupported-never-clears-local, pre-existing local value
3 the supported-list closing in resolveIdpLocale (raw tag stored) 5 failed / 17 passed: acceptance 3, the two pure closing cells, following, unsupported-clears

Tests and gates

Read on head 611c99ab (after merging main at f47f7f22). Every test run went through scripts/pm/os-verify-lock.sh; each exit code was captured before any pipe.

  • Build: pnpm build: VERDICT command-exit 0, Tasks 76 successful, 76 total.
  • The package: 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.
  • The derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 109 commands on 611c99ab; all 109 exit 0 (check:type-check-debt under the lock; check-plugin-teardown-shape --self-test after fetching its pinned fixture commit; check:dual-build-cjs-loads after 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.
  • Not run locally, declared to CI: the type-check lanes and the path-scheduled CI jobs dispatch-gates names. 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.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: the new sys_user write is one more application-surface write call site (236 to 237; decidable 160 to 161; decidably elevated 134 to 135; it carries an isSystem context). The generated regions come from node 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, and check-tenant-audit-census is 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

  • Social providers are OAuth sign-ins too, so a Google sign-in that carries locale sets the preference the same way; SAML sign-ins carry no standard locale claim and are not read.
  • A user who picks the very language the IdP asserts is indistinguishable from one who never picked, and keeps following the IdP; clearing the column locally hands it back to the IdP. Both follow from "the IdP owns the column while it holds what the IdP last wrote".
  • The alternative to the sys_verification record is a provenance column on sys_user (a platform-objects change in another lane); the record keeps the change inside plugin-auth with 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

…user.locale (wip)

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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@objectstack-fleet
objectstack-fleet Bot requested a review from os-sales October 22, 2026 22:14
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 28 documentable anchor(s).

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5801a4f2c9df6a10f808063d7c3b001b7d4381c7.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 42 pages)
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5801a4f2c9df6a10f808063d7c3b001b7d4381c7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1328eb5f223de1e586cd13799979e6bd0375aa63 — the merge of head 611c99ab150c82c45bf3def304b730b08ff9c59e into base 5801a4f2c9df6a10f808063d7c3b001b7d4381c7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5801a4f2c9df6a10f808063d7c3b001b7d4381c7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · 2026-10-22T22:42Z

domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · card #25261 (claim 6351359330; os-dev-report 6352490423; card session branch claude/project-thread-6usz2l) · reviewed head 611c99ab150c82c45bf3def304b730b08ff9c59e

  • Shape: a new module, idp-locale-claim.ts, stages the OIDC locale claim in validateUserInfo (keyed by the request's endpoint context, refusing nothing) and settles it on the session the sign-in mints. The value is closed against the i18n service's reported locales with core resolveLocale, and the supported spelling is stored. A local choice wins; what the IdP last wrote lives in one sys_verification row per user, written through the better-auth database adapter (the unlink-record idiom), and is cleared on user delete. auth-manager.ts gains the instance and three call sites only. 7 files, +828 / −29.
  • Read by the seat: stage, settle and forget each never throw (settle and forget catch to warn; stage only reads). The column is written before the record, so a partial failure degrades toward "local choice wins". The new sys_user write is isSystem, targets only the user the minted session names, and writes only the locale column with a value from the supported list. No accept set is narrowed or widened and no boundary is relaxed (lane floor checked). @objectstack/service-messaging was already a declared dependency of plugin-auth.
  • Declarations: feat(plugin-auth), Clause-②: no, Metadata-protocol: none, one patch changeset. No contract-review record or dogfood battery is owed (no !, no narrowing arm).
  • File surface: within the claim, plus three derived ledgers each regenerated by its own tool (engine-double-contract.pinned.json, the tenant-audit census page and its counts file: 236 → 237 call sites for the new write). They are the gates' records of the declared write, not a widening.
  • Pins and ablation: 22 cases (13 pure, 9 real OAuth round trips through the platform IdP over the in-memory engine), with the card's three acceptance cases. Three ablation legs (settle call, local-choice guard, supported-list closing) are red 5 / 4 / 5 and each restore is proven.
  • Gates: dispatch-gates --ran 109 / 109 on this head; plugin-auth 186 files / 3515 pass; typecheck exit 0; every check-run on this head success or an expected skip (41 success, 8 skipped), mergeable clean.
  • Serial: the [maintainer] refactor(plugin-auth): split src/auth-manager.ts (10,338 lines, +1,867 in 30 days) along its AuthManager seams, one move-only slice per PR #25224 move-only split of auth-manager.ts is in flight. This PR's logic is in a new file and its three call sites are small, so whichever lands second merges main and, for the split, re-proves token identity over the merged file.
  • Docs: the drift check (6352477511) is advisory. The seat read the pages that state the sys_user.locale rule (authentication.mdx, email-templates.mdx, the auth API reference): they describe how the column is read, which this PR does not change, so none is falsified.
  • Body: the seat appended the required footer through the relay.
  • Landing: not governed (check-governed-merges --pr 25303: 857 lines). Ready + auto-merge through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 22:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 22:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 829cb77 Oct 22, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-6usz2l branch October 22, 2026 23:11
os-sales pushed a commit that referenced this pull request Oct 22, 2026
…-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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Identity: the JIT user path reads the IdP locale claim into the user language preference (hosted environments follow the cloud account language)

2 participants