Skip to content

Commit da7bcc8

Browse files
feat(plugin-auth): a sign-in that skips the OIDC seams applies the IdP locale rule through AuthManager.applyIdpLocaleClaim (#27674)
domain:spec#3 · os-support-ai · session_01WGufzgrxXwu9nECtirwHEK <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBBw7sfgcdYpSr4xrGUrivAR)_ Fixes #27334 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches the IdP locale claim, `IdpLocaleSync` or `sys_user.locale` sync (`git grep -n -i "idp.\?locale\|locale claim" -- skills` answers 0 lines on HEAD); the change is a new method on an exported class and one new type export, with no metadata key or value. Consumer: objectstack-ai/cloud#3159 (the cloud sso-exchange door wires the call; the cloud half stays there). ## 维护者速读 - 改了什么:`@objectstack/plugin-auth` 的 `AuthManager` 新增公开方法 `applyIdpLocaleClaim(userId, claim)`,入口再导出 `IdpLocaleClaim` 类型。它调用的就是 OIDC 登录本来就在用的那段规则(`IdpLocaleSync.apply`,从 `settle` 里拆出来),规则只有一份。 - 为什么改:cloud 控制台的「打开」走 sso-open → sso-exchange 交接,不经过 OIDC,所以拿不到「语言跟随 cloud 账号」的规则;包入口此前没导出任何可调用的东西(cloud#3159 实测)。 - 风险与回滚:只是加宽公开面,规则、`idp-locale:` 记录标识和 OIDC 路径行为都没变(OIDC 端到端用例原样全绿,另有 spy 钉住 OIDC 走同一方法)。方法从不抛错。回滚即还原本 PR。 - 先看:`packages/plugins/plugin-auth/src/idp-locale-claim.ts`(`settle` / `apply` 的拆分),`packages/plugins/plugin-auth/src/auth-manager.ts`(`applyIdpLocaleClaim`),`packages/plugins/plugin-auth/src/index.ts`(类型导出)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/idp-locale-claim.test.ts`,应 28/28 全绿。 - 需要你做的:无,席位落地(落地前须有契约复审记录)。 ## What changes ### Measured first, on `origin/main` `efa2915e` - **What the entry exports today.** `packages/plugins/plugin-auth/src/index.ts` lists auth-manager's published names by name (`DEFAULT_AUTH_BASE_PATH`, `readMcpServerEnabledEnv`, `resolveOidcProviderEnabled`, `resolveDcrEnabled`, `resolveScimEnabled`, `assertScimAdminCoherence`, `isOAuthEligibleBaseUrl`, `InvitationPlacementServiceLike`, `readInvitationPlacementIntent`, `AuthManagerOptions`, `ipMatchesRange`, `normalizeAuthEmailLocale`, `authEmailLocaleFromRequest`, `AuthManager`) and carries 31 `export * from` lines for its other modules. `idp-locale-claim.ts` is not among them: `grep -c "idp-locale-claim" src/index.ts` answers 0, and the built `dist/index.d.ts` names none of `IdpLocaleSync`, `readIdpLocaleClaim`, `resolveIdpLocale`, `decideIdpLocaleWrite`, `idpLocaleRecordIdentifier`, `IdpLocaleClaim`. The card's premise holds. - **`settle`'s apply half** (`idp-locale-claim.ts` on `efa2915e`): after taking the staged claim off the endpoint context and checking the session's `userId`, it ran, inside one `try`: the supported-locale guard (no locales ⇒ one `warn`, nothing written), `getEngine()`, `authContextOf(ctx) ?? resolveAuth()`, the `sys_user.locale` read, the `idp-locale:USERID` record read through better-auth's database adapter, `decideIdpLocaleWrite(...)`, the column write, `replaceIdpLocaleRecord(...)`, and the never-throw `warn` in the `catch`. - **How code outside the package reaches the running auth manager.** `AuthPlugin` registers the `AuthManager` instance itself as the kernel service `auth` (`auth-plugin.ts:776`, `ctx.registerService('auth', this.authManager)`); hosts read it with `ctx.getService('auth')` (`packages/runtime/src/dispatcher-plugin.ts:2453`, `packages/cloud-connection/src/cloud-connection-plugin.ts:470`), and this package's own tests take it as `kernel.getService('auth')` typed as `AuthManager`. `AuthManager` is already an entry export. The `IdpLocaleSync` the OIDC path uses is a private field of that instance, wired to the manager's data engine, the i18n service's locales and better-auth's auth context. ### The change - **`IdpLocaleSync.apply(userId, claim, ctx?)`** (new, public) holds the apply half above, moved verbatim. `settle` now takes the staged claim and the session's `userId` exactly as before, then calls `apply(userId, claim, ctx)`. The only added lines in `apply` are its own input guard (`userId` a non-empty string and a claim present, else return), so a JavaScript caller cannot make it throw before the `try`. - **`AuthManager.applyIdpLocaleClaim(userId, claim)`** (new, public) returns `this.idpLocale.apply(userId, claim)`. It is the accessor the order prefers: the caller reaches it on the instance the kernel already exposes, and no second `IdpLocaleSync` is constructed, so the once-only "no i18n service" warning, the engine, the locale list and the auth store are the manager's own. Without an endpoint context the auth store resolves through the manager's `linkingContext()` (the same fallback the OIDC path uses when a call carries no context). - **Entry export: `type IdpLocaleClaim`** from `idp-locale-claim.js`, the argument the method takes (`{ providerId?, value }`). - Each published name carries a docblock with the rule in one sentence (written while `sys_user.locale` is unset or still the value an IdP sign-in last wrote; a local choice is kept) and says it never throws. ### Exported names, and why each | Name | Exported | Why | |:---|:---|:---| | `AuthManager.applyIdpLocaleClaim` | yes (a method of the already-exported class) | The one call cloud#3159 needs: the user id it signs in and the cloud account's language. | | `IdpLocaleClaim` | yes (type only) | The method's argument type; a TypeScript caller names it. | | `IdpLocaleSync` | no | The caller reaches its `apply` through the manager; exporting the class invites a second instance with its own deps. | | `readIdpLocaleClaim` | no | It reads a better-auth `validateUserInfo` source (`method` + `oauth`/`sso` profile); the sso-exchange door holds a cloud account, not that shape, and passes `{ providerId, value }` directly. | | `resolveIdpLocale`, `decideIdpLocaleWrite`, `idpLocaleRecordIdentifier` | no | Internal steps of the rule; the method applies them. Publishing them would let a caller rebuild the rule beside it, which is the second owner the ruling forbids. | No change to the rule, to the record identifier (`idp-locale:USERID`), or to the OIDC path's observable behaviour: the end-of-chain OIDC cases in `idp-locale-claim.test.ts` are untouched and green. ### Changeset level: `patch`, not the order's `minor` The order asked for a `minor` changeset. AGENTS.md (Post-Task Checklist step 3) grades the level by the `Metadata-protocol:` kind alone: "a kind takes at least `minor` on a package the diff moves, `none` takes `patch`; a non-protocol change, widening or breaking, ships `patch`". This change is `Metadata-protocol: none`, so the changeset is `'@objectstack/plugin-auth': patch`, and `check:changeset-no-major` reads it so (Verification). Raised with the seat as a conflict between the order and AGENTS.md. ### Generated ledgers None to regenerate. `@objectstack/plugin-auth` keeps no api-surface snapshot (the `api-surface/` ledger is `packages/spec`'s), and `git grep -l "authEmailLocaleFromRequest"` outside the package's `src/` finds only `docs/qa/platform-checklist/areas/identity-auth.json`, a hand-kept test checklist, not an export ledger. ## Tests - **The package entry.** `idp-locale-claim.test.ts` imports `./index` (the module tsup builds as the package entry, `dist/index.*`) and runs the three rule cases through `entry.AuthManager#applyIdpLocaleClaim` over the file's in-memory `IDataEngine` (already a pinned double; no new double) and the real better-auth database adapter the manager resolves: unset ⇒ `zh-CN` written with the `idp-locale:` record; still the recorded value ⇒ `en-US` overwrites both (stored `en`); a local `ja-JP` ⇒ kept, `engine.update` never called and the `sys_verification` rows byte-identical. A fourth case shows it never throws (a refusing user store, an empty user id, no claim: all resolve, one `warn`). - **One method.** A spy on `IdpLocaleSync.prototype.apply` pins that a real OAuth sign-in through the platform IdP settles through it (called once with the user id, `{ providerId, value: 'zh-CN' }` and the endpoint context), and that `applyIdpLocaleClaim` calls the same method. - **The package entry is imported as `./index`, not as `@objectstack/plugin-auth`.** The package cannot import its own name in vitest without a new self-alias in `vitest.config.ts`, which is outside the declared file surface; `./index` is the same module the published entry is built from. ## Acceptance notes - `applyIdpLocaleClaim` is a method of `AuthManager`, not of the `IAuthService` contract in `packages/spec`: adding it there would move `packages/spec`, outside the declared file surface. A host that types the `auth` service as `IAuthService` casts to `AuthManager` to reach it, as this package's own tests do. - The method runs with no endpoint context, so the auth store is resolved from the auth instance (`linkingContext()`); a host that calls it before the auth instance is built triggers that build, as any other `AuthManager` call that needs the auth context does. ## Verification All readings on head `fd9a0e60` (branch base `efa2915e`; `origin/main` has since moved to `9d5ebfde` with no commit under `packages/plugins/plugin-auth`, so no merge was taken before opening). <details><summary>plugin-auth tests and typecheck (under os-verify-lock)</summary> - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: Test Files 202 passed (202), Tests 3849 passed, 10 skipped (3859). - `pnpm --filter @objectstack/plugin-auth build && pnpm --filter @objectstack/plugin-auth typecheck`: `VERDICT command-exit 0`; `check:test-typecheck: OK` (10 files / 88 errors / 23 pinned signatures held in `test-typecheck-debt.json`, unchanged). - `src/idp-locale-claim.test.ts` + `src/oidc-userinfo-locale-claim.test.ts`: 2 files, 44 tests passed. - Built entry: `dist/index.d.ts` declares `applyIdpLocaleClaim(userId: string, claim: IdpLocaleClaim)` returning a void promise on `AuthManager` and exports `type IdpLocaleClaim`; `grep -c "readIdpLocaleClaim\|decideIdpLocaleWrite" dist/index.d.ts` answers 0. </details> <details><summary>Ablation: the OIDC path goes through IdpLocaleSync.apply</summary> `node scripts/ablation-replace.mjs --file packages/plugins/plugin-auth/src/idp-locale-claim.ts --anchor ' await this.apply(userId, claim, ctx);' --replacement ' void claim;' -- … vitest run src/idp-locale-claim.test.ts` (the test imports source, no dist leg): - landed: anchor 1 → 0, blob `f27b07c0e4da` → `42ee4fbd0c0b`; - result: Tests 6 failed | 22 passed (28): the spy pin "a sign-in through the platform IdP settles its claim through IdpLocaleSync.apply" and five OIDC end-of-chain cases; - restored: blob after restore == HEAD (`f27b07c0e4da`), `git diff HEAD` empty. </details> <details><summary>Derived gates: 63 derived, 63 run, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands from 5 changed paths (191 changed lines). Two first exited 3 on prerequisites and were re-run green: `check-plugin-teardown-shape --self-test` after `git fetch --depth=1 origin 621a487` (its pinned fixture, absent from the shallow clone; 48 cases pass), and `check:dual-build-cjs-loads` after a full `pnpm build` (114/114 tasks; 112 require entry points across 69 packages load). `dispatch-gates --ran`: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED". Separately, `check-changeset-no-major --base origin/main --event` on this body: exit 0, "LEVEL AXIS … a non-protocol change at `patch`". ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:auth-mount-ledger :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzgrxXwu9nECtirwHEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6cf3e0b commit da7bcc8

5 files changed

Lines changed: 188 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): a sign-in that skips the OIDC seams can apply the IdP locale rule through `AuthManager.applyIdpLocaleClaim`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
An external OIDC sign-in already sets the user's language preference (`sys_user.locale`) from the IdP's `locale` claim. A host whose own door signs a user in without passing through better-auth's OIDC seams, such as the cloud's sso-exchange hand-off, had no way to apply the same rule, because the package entry exported none of it. Now it can, through the running auth manager (`ctx.getService('auth')`):
11+
12+
- **`AuthManager.applyIdpLocaleClaim(userId, claim)`** applies a claim (`{ providerId?, value }`) under the same rule and the same `idp-locale:` record as the OIDC path: the column is written only while it is unset or still holds the value an IdP sign-in last wrote, so a language the user chose in the environment is kept. The claim is closed against the i18n service's locales, and an unsupported one clears only an IdP-written value. It never throws: a failure is reported at `warn` and the user keeps the preference they had.
13+
- **`IdpLocaleClaim`** is exported as a type from the package entry.
14+
- **One owner for the rule.** The OIDC JIT path now settles its staged claim through the same method, so its behaviour is unchanged.
15+
16+
Nothing to do for existing hosts.

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import {
2929
type LinkingAuthContext,
3030
} from './implicit-account-linking.js';
31-
import { IdpLocaleSync } from './idp-locale-claim.js';
31+
import { IdpLocaleSync, type IdpLocaleClaim } from './idp-locale-claim.js';
3232
import type { IDataEngine } from '@objectstack/core';
3333
import {
3434
API_KEY_DEFAULT_EXPIRY_DAYS,
@@ -2169,6 +2169,21 @@ export class AuthManager {
21692169
warn: (message, meta) => this.config.logger?.warn?.(message, meta),
21702170
});
21712171

2172+
/**
2173+
* Apply an IdP's `locale` claim to a user, for a sign-in that does not run
2174+
* through the OIDC seams (a host's own session hand-off): the user's
2175+
* language preference (`sys_user.locale`) is written only while it is unset
2176+
* or still holds the value an IdP sign-in last wrote, so a language the user
2177+
* chose in the environment is kept. The same call the OIDC JIT path makes,
2178+
* with the same `idp-locale:` record; the claim is closed against the i18n
2179+
* service's locales, and an unsupported one clears only an IdP-written
2180+
* value. Never throws: a failure is reported at `warn` and the user keeps
2181+
* the preference they had. The rule and its record: `idp-locale-claim.ts`.
2182+
*/
2183+
public applyIdpLocaleClaim(userId: string, claim: IdpLocaleClaim): Promise<void> {
2184+
return this.idpLocale.apply(userId, claim);
2185+
}
2186+
21722187
/**
21732188
* [ADR-0093 D7] The users created in each in-flight better-auth request,
21742189
* keyed by that request's endpoint context — the object better-auth hands

‎packages/plugins/plugin-auth/src/idp-locale-claim.test.ts‎

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// The IdP's `locale` claim → `sys_user.locale` — see `idp-locale-claim.ts`
44
// for the rule this file pins.
55
//
6-
// Two layers:
6+
// Three layers:
77
//
88
// 1. PURE — reading the claim off a `validateUserInfo` source, closing it
99
// against the supported locales, and the write decision.
@@ -12,10 +12,16 @@
1212
// `/callback/:id`) through the platform IdP, a generic-OAuth provider
1313
// whose token and userinfo endpoints are stubbed. The assertions read
1414
// what landed: the `sys_user.locale` column and the IdP record beside it.
15+
// 3. The PACKAGE ENTRY — `AuthManager.applyIdpLocaleClaim`, the call a host
16+
// whose sign-in skips the OIDC seams makes (the cloud's sso-exchange
17+
// hand-off), imported from `./index` and run over the same engine; and
18+
// the pin that it and the OIDC settle step are one method.
1519

1620
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
1721
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql';
1822
import { AuthManager } from './auth-manager';
23+
// The package entry, as a host outside the package imports it.
24+
import * as entry from './index';
1925
import { PLATFORM_IDP_PROVIDER_ID } from './implicit-account-linking';
2026
import {
2127
IdpLocaleSync,
@@ -436,3 +442,118 @@ describe('a JIT sign-in through the platform IdP sets the user language preferen
436442
expect(warn.mock.calls.filter(([m]) => String(m).includes('no i18n service reports a supported locale'))).toHaveLength(1);
437443
});
438444
});
445+
446+
describe('the OIDC JIT path and the entry call apply the rule through one method', () => {
447+
it('a sign-in through the platform IdP settles its claim through IdpLocaleSync.apply', async () => {
448+
const apply = vi.spyOn(IdpLocaleSync.prototype, 'apply');
449+
try {
450+
const engine = createMemoryEngine();
451+
idpProfile = { locale: 'zh-CN' };
452+
453+
await signInWithIdp(makeManager(engine));
454+
455+
expect(apply).toHaveBeenCalledTimes(1);
456+
expect(apply).toHaveBeenCalledWith(
457+
userRow(engine).id,
458+
{ providerId: PLATFORM_IDP_PROVIDER_ID, value: 'zh-CN' },
459+
expect.anything(),
460+
);
461+
expect(userRow(engine).locale).toBe('zh-CN');
462+
} finally {
463+
apply.mockRestore();
464+
}
465+
});
466+
});
467+
468+
describe('the package entry applies the rule for a sign-in that skips the OIDC seams', () => {
469+
const USER_ID = 'usr_entry_1';
470+
const CLOUD = 'objectos-cloud';
471+
472+
/** A user the host's own hand-off already holds: created, never signed in through OIDC. */
473+
const seedUser = async (engine: ReturnType<typeof createMemoryEngine>) => {
474+
await engine.insert('sys_user', { id: USER_ID, email: EMAIL, name: 'Synthetic Person' });
475+
};
476+
const entryManager = (engine: unknown, config: Record<string, unknown> = {}) =>
477+
new entry.AuthManager({
478+
secret: SECRET,
479+
baseUrl: BASE,
480+
dataEngine: engine,
481+
getI18n: () => ({ getLocales: () => SUPPORTED }),
482+
...config,
483+
} as any);
484+
const claim = (value: unknown): entry.IdpLocaleClaim => ({ providerId: CLOUD, value });
485+
const userOf = (engine: ReturnType<typeof createMemoryEngine>) =>
486+
(engine.tables.get('sys_user') ?? []).find((u: any) => u.id === USER_ID);
487+
const verificationRows = (engine: ReturnType<typeof createMemoryEngine>) =>
488+
JSON.stringify(engine.tables.get('sys_verification') ?? []);
489+
490+
it('⭐ unset: the claim is written, with the idp-locale record beside it', async () => {
491+
const engine = createMemoryEngine();
492+
await seedUser(engine);
493+
const manager = entryManager(engine);
494+
495+
await expect(manager.applyIdpLocaleClaim(USER_ID, claim('zh-CN'))).resolves.toBeUndefined();
496+
497+
expect(userOf(engine).locale).toBe('zh-CN');
498+
expect(recordOf(engine, USER_ID)).toBe('zh-CN');
499+
});
500+
501+
it('⭐ still the recorded value: a later claim overwrites it, record and column alike', async () => {
502+
const engine = createMemoryEngine();
503+
await seedUser(engine);
504+
const manager = entryManager(engine);
505+
await manager.applyIdpLocaleClaim(USER_ID, claim('zh-CN'));
506+
507+
await manager.applyIdpLocaleClaim(USER_ID, claim('en-US'));
508+
509+
expect(userOf(engine).locale).toBe('en');
510+
expect(recordOf(engine, USER_ID)).toBe('en');
511+
});
512+
513+
it('⭐ a local choice: kept, and neither the column nor the record is written', async () => {
514+
const engine = createMemoryEngine();
515+
await seedUser(engine);
516+
const manager = entryManager(engine);
517+
await manager.applyIdpLocaleClaim(USER_ID, claim('zh-CN'));
518+
userOf(engine).locale = 'ja-JP';
519+
const update = vi.spyOn(engine, 'update');
520+
const recordsBefore = verificationRows(engine);
521+
522+
await manager.applyIdpLocaleClaim(USER_ID, claim('en'));
523+
524+
expect(userOf(engine).locale).toBe('ja-JP');
525+
expect(update).not.toHaveBeenCalled();
526+
expect(verificationRows(engine)).toBe(recordsBefore);
527+
});
528+
529+
it('runs through the same IdpLocaleSync.apply the OIDC settle step calls', async () => {
530+
const apply = vi.spyOn(IdpLocaleSync.prototype, 'apply');
531+
try {
532+
const engine = createMemoryEngine();
533+
await seedUser(engine);
534+
535+
await entryManager(engine).applyIdpLocaleClaim(USER_ID, claim('zh-CN'));
536+
537+
expect(apply).toHaveBeenCalledTimes(1);
538+
expect(apply).toHaveBeenCalledWith(USER_ID, claim('zh-CN'));
539+
} finally {
540+
apply.mockRestore();
541+
}
542+
});
543+
544+
it('never throws: a user store that refuses the write is reported at warn', async () => {
545+
const engine = createMemoryEngine();
546+
await seedUser(engine);
547+
const warn = vi.fn();
548+
const manager = entryManager(engine, { logger: { warn, info: () => {}, error: () => {}, debug: () => {} } });
549+
engine.failUpdates.add('sys_user');
550+
551+
await expect(manager.applyIdpLocaleClaim(USER_ID, claim('zh-CN'))).resolves.toBeUndefined();
552+
await expect(manager.applyIdpLocaleClaim('', claim('zh-CN'))).resolves.toBeUndefined();
553+
await expect(manager.applyIdpLocaleClaim(USER_ID, undefined as never)).resolves.toBeUndefined();
554+
555+
expect(userOf(engine).locale ?? null).toBeNull();
556+
expect(recordOf(engine, USER_ID)).toBeUndefined();
557+
expect(warn.mock.calls.filter(([m]) => String(m).includes('IdP locale claim was not applied'))).toHaveLength(1);
558+
});
559+
});

‎packages/plugins/plugin-auth/src/idp-locale-claim.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
* A sign-in that is refused mints no session, and the staged claim dies with
5858
* its request.
5959
*
60+
* A sign-in that never passes through those seams (a host's own session
61+
* hand-off, such as the cloud's) applies the same rule through
62+
* `AuthManager.applyIdpLocaleClaim`, which calls {@link IdpLocaleSync.apply}:
63+
* the very method the settle step calls, so the rule has one owner.
64+
*
6065
* A failure here never fails a sign-in. It is a functional degradation — the
6166
* user sees the deployment default instead of the IdP's language, visibly —
6267
* so it is reported at `warn`.
@@ -91,7 +96,12 @@ export function idpLocaleRecordIdentifier(userId: string): string {
9196
return `${IDP_LOCALE_RECORD_PREFIX}:${userId}`;
9297
}
9398

94-
/** What one IdP sign-in asserted about the user's language. */
99+
/**
100+
* What one IdP sign-in asserted about the user's language. Applying it
101+
* ({@link IdpLocaleSync.apply}, published as `AuthManager.applyIdpLocaleClaim`)
102+
* writes `sys_user.locale` only while it is unset or still the value an IdP
103+
* sign-in last wrote, so a local choice is kept; applying it never throws.
104+
*/
95105
export interface IdpLocaleClaim {
96106
/** The provider that asserted it, for the record and the logs. */
97107
providerId?: string;
@@ -195,6 +205,22 @@ export class IdpLocaleSync {
195205
this.staged.delete(ctx);
196206
const userId = (session as { userId?: unknown } | null)?.userId;
197207
if (typeof userId !== 'string' || !userId) return;
208+
await this.apply(userId, claim, ctx);
209+
}
210+
211+
/**
212+
* Apply one IdP sign-in's locale claim to a user, under the rule in the
213+
* module header: `sys_user.locale` is written while it is unset or still
214+
* holds the value the IdP path last wrote, and a local choice is kept. The
215+
* `idp-locale:` record beside it is written in the same step. The OIDC JIT
216+
* path reaches it through {@link settle}; a sign-in that never runs through
217+
* better-auth's OIDC seams calls it with the claim it holds. `ctx` is the
218+
* better-auth endpoint context when there is one; without it the auth store
219+
* is resolved off the auth instance. Never throws: a failure is reported at
220+
* `warn` and the user keeps the language preference they had.
221+
*/
222+
async apply(userId: string, claim: IdpLocaleClaim, ctx?: unknown): Promise<void> {
223+
if (typeof userId !== 'string' || !userId || !claim) return;
198224
try {
199225
const supported = this.deps.getSupportedLocales();
200226
if (!supported || supported.length === 0) {

‎packages/plugins/plugin-auth/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export {
3333
authEmailLocaleFromRequest,
3434
AuthManager,
3535
} from './auth-manager.js';
36+
// The IdP locale rule, for a host whose sign-in does not run through the OIDC
37+
// seams (the cloud's sso-exchange hand-off): `AuthManager.applyIdpLocaleClaim`
38+
// takes this claim. A user's language preference is written only while it is
39+
// unset or still the value an IdP sign-in last wrote, so a local choice is
40+
// kept; the call never throws. The pure steps of the rule stay unexported, so
41+
// the rule keeps one owner.
42+
export type { IdpLocaleClaim } from './idp-locale-claim.js';
3643
export * from './ensure-default-organization.js';
3744
// [ADR-0131 D3 / D10 fate 3] The Default Organization's boot invariant, for the
3845
// v18 organization-ownership ceremony (`os migrate organization-ownership

0 commit comments

Comments
 (0)