From 8d383bc82af32f0d8161d4d459b50915b3749028 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:12:38 +0000 Subject: [PATCH 1/3] feat(plugin-auth): a JIT sign-in reads the IdP locale claim into sys_user.locale (wip) Claude-Session: https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 36 +- .../plugin-auth/src/idp-locale-claim.test.ts | 438 ++++++++++++++++++ .../plugin-auth/src/idp-locale-claim.ts | 300 ++++++++++++ 3 files changed, 771 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/idp-locale-claim.test.ts create mode 100644 packages/plugins/plugin-auth/src/idp-locale-claim.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index b8a59e1aad1..ed07af7256c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -39,6 +39,7 @@ import { type LinkingAuthContext, refuseImplicitAccountLink, } from './implicit-account-linking.js'; +import { IdpLocaleSync } from './idp-locale-claim.js'; import type { IDataEngine } from '@objectstack/core'; import { API_KEY_DEFAULT_EXPIRY_DAYS, @@ -1885,12 +1886,18 @@ export class AuthManager { // implicit link is written — see `implicit-account-linking.ts`. The // audience gate judges only `create-user`, so the two never answer // the same call. - validateUserInfo: async (data: any, ctx: any) => - (await refuseImplicitAccountLink(data, ctx, { + // + // It is also where better-auth hands over the IdP's raw profile, so + // the profile's `locale` claim is STAGED here, refusing nothing; the + // session the sign-in mints settles it (`idp-locale-claim.ts`). + validateUserInfo: async (data: any, ctx: any) => { + this.idpLocale.stage(data, ctx); + return (await refuseImplicitAccountLink(data, ctx, { requireLocalEmailVerified: this.implicitLinkRequiresLocalEmailVerified(), resolveContext: () => this.linkingContext(), logInfo: (m, meta) => this.config.logger?.info?.(m, meta), - })) ?? this.validateAudienceAdmission(data, ctx), + })) ?? this.validateAudienceAdmission(data, ctx); + }, }, account: { ...AUTH_ACCOUNT_CONFIG, @@ -5050,6 +5057,25 @@ export class AuthManager { } } + /** + * An external sign-in's `locale` claim → `sys_user.locale`, staged by the + * `validateUserInfo` seam and settled on the session the sign-in mints. + * The rule and its record: `idp-locale-claim.ts`. + */ + private readonly idpLocale = new IdpLocaleSync({ + getEngine: () => this.getDataEngine() as never, + getSupportedLocales: () => { + try { + const locales = this.config.getI18n?.()?.getLocales?.(); + return Array.isArray(locales) ? locales : undefined; + } catch { + return undefined; + } + }, + resolveAuth: () => this.linkingContext(), + warn: (message, meta) => this.config.logger?.warn?.(message, meta), + }); + /** * [ADR-0093 D7] The users created in each in-flight better-auth request, * keyed by that request's endpoint context — the object better-auth hands @@ -8448,6 +8474,7 @@ export class AuthManager { const userDeleteAfter = async (user: any, ctx: any) => { const result = hostUserDeleteAfter ? await hostUserDeleteAfter(user, ctx) : undefined; await clearUserUnlinks(user, ctx); + await this.idpLocale.forget(user, ctx); return result; }; @@ -8589,6 +8616,9 @@ export class AuthManager { this.config.getAuditSink?.(), loginEventFor(session, typeof ctx?.path === 'string' ? ctx.path : undefined), ); + // An external sign-in's staged `locale` claim lands on the user the + // session names, new or returning (`idp-locale-claim.ts`). Never throws. + await this.idpLocale.settle(session, ctx); return result; }; // [#23772] A minted principal session is NEVER renewed. better-auth's diff --git a/packages/plugins/plugin-auth/src/idp-locale-claim.test.ts b/packages/plugins/plugin-auth/src/idp-locale-claim.test.ts new file mode 100644 index 00000000000..14545a8474f --- /dev/null +++ b/packages/plugins/plugin-auth/src/idp-locale-claim.test.ts @@ -0,0 +1,438 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The IdP's `locale` claim → `sys_user.locale` — see `idp-locale-claim.ts` +// for the rule this file pins. +// +// Two layers: +// +// 1. PURE — reading the claim off a `validateUserInfo` source, closing it +// against the supported locales, and the write decision. +// 2. END of the chain — a real better-auth pipeline over the in-memory +// engine, driving real OAuth round trips (`/sign-in/social` → +// `/callback/:id`) through the platform IdP, a generic-OAuth provider +// whose token and userinfo endpoints are stubbed. The assertions read +// what landed: the `sys_user.locale` column and the IdP record beside it. + +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; +import { PLATFORM_IDP_PROVIDER_ID } from './implicit-account-linking'; +import { + IdpLocaleSync, + decideIdpLocaleWrite, + idpLocaleRecordIdentifier, + readIdpLocaleClaim, + resolveIdpLocale, +} from './idp-locale-claim'; +// better-auth's first module load is paid at module load, not inside the first +// clocked case (AGENTS.md, Build & Test: clocked windows measure behaviour, +// never loading). `AuthManager` reaches these through a lazy `await import()`. +import 'better-auth'; +import 'better-auth/api'; +import 'better-auth/plugins'; +import 'better-auth/plugins/bearer'; +import 'better-auth/plugins/custom-session'; +import 'better-auth/plugins/generic-oauth'; +import 'better-auth/plugins/organization'; +import 'better-auth/plugins/organization/access'; +import '@better-auth/oauth-provider'; + +const SUPPORTED = ['en', 'zh-CN', 'ja-JP']; + +// ── PURE ───────────────────────────────────────────────────────────────────── + +describe('reading the claim off a validateUserInfo source', () => { + it('reads the profile of a generic-OAuth / social sign-in and of an SSO OIDC sign-in', () => { + expect(readIdpLocaleClaim({ method: 'oauth', oauth: { providerId: 'p1', profile: { locale: 'zh-CN' } } })).toEqual({ + providerId: 'p1', + value: 'zh-CN', + }); + expect(readIdpLocaleClaim({ method: 'sso-oidc', sso: { providerId: 'p2', profile: { locale: 'ja' } } })).toEqual({ + providerId: 'p2', + value: 'ja', + }); + }); + + it('a present-but-unusable value is still a claim; an absent key is none', () => { + expect(readIdpLocaleClaim({ method: 'oauth', oauth: { providerId: 'p1', profile: { locale: null } } })).toEqual({ + providerId: 'p1', + value: null, + }); + expect(readIdpLocaleClaim({ method: 'oauth', oauth: { providerId: 'p1', profile: { email: 'a@b.c' } } })).toBeUndefined(); + expect(readIdpLocaleClaim({ method: 'oauth', oauth: { providerId: 'p1' } })).toBeUndefined(); + }); + + it('reads no claim from a sign-in whose profile is not an OIDC claim set', () => { + for (const method of ['sso-saml', 'email-password', 'scim', undefined]) { + expect(readIdpLocaleClaim({ method, sso: { profile: { locale: 'zh-CN' } }, oauth: { profile: { locale: 'zh-CN' } } })).toBeUndefined(); + } + expect(readIdpLocaleClaim(undefined)).toBeUndefined(); + }); +}); + +describe('closing the claim against the supported locales', () => { + it('answers the SUPPORTED spelling: exact, case, base language, variant', () => { + expect(resolveIdpLocale('zh-CN', SUPPORTED)).toBe('zh-CN'); + expect(resolveIdpLocale('zh-cn', SUPPORTED)).toBe('zh-CN'); + expect(resolveIdpLocale('en-US', SUPPORTED)).toBe('en'); + expect(resolveIdpLocale('ja', SUPPORTED)).toBe('ja-JP'); + }); + + it('reads the underscore spelling OIDC Core §5.1 notes as the tag it stands for', () => { + expect(resolveIdpLocale('zh_CN', SUPPORTED)).toBe('zh-CN'); + }); + + it('an unsupported or unknown claim names no locale', () => { + for (const claim of ['fr', 'de-DE', 'not a tag', '', ' ', 'undefined', null, 42, {}]) { + expect(resolveIdpLocale(claim, SUPPORTED)).toBeUndefined(); + } + }); +}); + +describe('the write decision', () => { + it('first creation: the claim sets the preference', () => { + expect(decideIdpLocaleWrite({ resolved: 'zh-CN', stored: undefined, recorded: undefined })).toEqual({ + locale: 'zh-CN', + record: 'zh-CN', + }); + }); + + it('a later sign-in follows the claim while the column still holds what the IdP wrote', () => { + expect(decideIdpLocaleWrite({ resolved: 'en', stored: 'zh-CN', recorded: 'zh-CN' })).toEqual({ locale: 'en', record: 'en' }); + }); + + it('a local choice wins: a value the IdP did not write is never overwritten', () => { + expect(decideIdpLocaleWrite({ resolved: 'en', stored: 'ja-JP', recorded: 'zh-CN' })).toBeNull(); + // A value set before any IdP record existed is a local choice too. + expect(decideIdpLocaleWrite({ resolved: 'en', stored: 'ja-JP', recorded: undefined })).toBeNull(); + // ... even when the claim is unsupported: it does not clear a local choice. + expect(decideIdpLocaleWrite({ resolved: undefined, stored: 'ja-JP', recorded: 'zh-CN' })).toBeNull(); + }); + + it('an unsupported claim clears an IdP-written value, so the deployment default applies', () => { + expect(decideIdpLocaleWrite({ resolved: undefined, stored: 'zh-CN', recorded: 'zh-CN' })).toEqual({ locale: null, record: null }); + expect(decideIdpLocaleWrite({ resolved: undefined, stored: undefined, recorded: undefined })).toBeNull(); + }); + + it('a column cleared locally is handed back to the IdP', () => { + expect(decideIdpLocaleWrite({ resolved: 'en', stored: undefined, recorded: 'zh-CN' })).toEqual({ locale: 'en', record: 'en' }); + }); + + it('nothing to change, nothing written', () => { + expect(decideIdpLocaleWrite({ resolved: 'en', stored: 'en', recorded: 'en' })).toBeNull(); + }); +}); + +describe('the deleted-user half', () => { + it('drops only that user’s record, and never throws', async () => { + const deleteMany = vi.fn(async () => 1); + const adapter = { findOne: vi.fn(), create: vi.fn(), deleteMany }; + const warn = vi.fn(); + const sync = new IdpLocaleSync({ + getEngine: () => undefined, + getSupportedLocales: () => SUPPORTED, + resolveAuth: async () => ({ adapter }), + warn, + }); + await sync.forget({ id: 'usr_1' }, undefined); + expect(deleteMany).toHaveBeenCalledWith({ + model: 'verification', + where: [{ field: 'identifier', value: idpLocaleRecordIdentifier('usr_1') }], + }); + + deleteMany.mockRejectedValueOnce(new Error('store down')); + await expect(sync.forget({ id: 'usr_1' }, undefined)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +// ── END of the chain ───────────────────────────────────────────────────────── + +/** An in-memory IDataEngine (the implicit-account-linking harness shape). */ +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date ? new Date(a as any).getTime() === new Date(b as any).getTime() : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + if ('$startsWith' in v) return typeof actual === 'string' && actual.startsWith(v.$startsWith); + } + return eq(actual, v); + }); + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + /** Objects whose updates fail — the store-fault seam. */ + const failUpdates = new Set(); + return { + tables, + failUpdates, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + assertEngineFindOnePredicate(name, q); + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + if (q.offset) out = out.slice(q.offset); + if (typeof q.limit === 'number') out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any, options?: any) { + assertEngineUpdateDispatch(patch, options); + if (failUpdates.has(name)) throw new Error(`fake driver: update of ${name} refused`); + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const BASE = 'http://localhost:3000'; +const AFTER = `${BASE}/after-sign-in`; +const IDP = 'https://idp.example.test'; +const EMAIL = 'person@example.test'; + +const makeManager = (engine: any, config: Record = {}) => + new AuthManager({ + secret: SECRET, + baseUrl: BASE, + dataEngine: engine, + oidcProviders: [ + { + providerId: PLATFORM_IDP_PROVIDER_ID, + clientId: 'cloud-client', + clientSecret: 'cloud-secret', + authorizationUrl: `${IDP}/${PLATFORM_IDP_PROVIDER_ID}/authorize`, + tokenUrl: `${IDP}/${PLATFORM_IDP_PROVIDER_ID}/token`, + userInfoUrl: `${IDP}/${PLATFORM_IDP_PROVIDER_ID}/userinfo`, + pkce: false, + }, + ], + getI18n: () => ({ getLocales: () => SUPPORTED }), + ...config, + } as any); + +/** What the stubbed IdP asserts about the person signing in. `locale` absent = no claim. */ +let idpProfile: Record = {}; + +const realFetch = globalThis.fetch; +beforeEach(() => { + idpProfile = {}; + vi.stubGlobal('fetch', async (input: any, init?: any) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.startsWith(IDP)) return realFetch(input, init); + if (url.endsWith('/token')) { + return new Response(JSON.stringify({ access_token: 'at-cloud', token_type: 'Bearer', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/userinfo')) { + return new Response( + JSON.stringify({ sub: 'cloud-sub-1', id: 'cloud-sub-1', email: EMAIL, email_verified: true, name: 'Synthetic Person', ...idpProfile }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response('not found', { status: 404 }); + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const cookiesFrom = (response: Response): string[] => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]!) + .filter(Boolean); + +/** One implicit sign-in through the platform IdP; answers the callback's redirect target. */ +const signInWithIdp = async (manager: AuthManager): Promise => { + const begin = await manager.handleRequest( + new Request(`${BASE}/api/v1/auth/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: PLATFORM_IDP_PROVIDER_ID, callbackURL: AFTER, disableRedirect: true }), + }), + ); + expect(begin.status).toBe(200); + const { url } = (await begin.json()) as { url: string }; + const state = new URL(url).searchParams.get('state'); + const callback = await manager.handleRequest( + new Request(`${BASE}/api/v1/auth/callback/${PLATFORM_IDP_PROVIDER_ID}?code=code-1&state=${encodeURIComponent(state!)}`, { + headers: { cookie: cookiesFrom(begin).join('; ') }, + }), + ); + expect(callback.status).toBe(302); + return new URL(callback.headers.get('location')!, BASE); +}; + +const userRow = (engine: any) => (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === EMAIL); +const recordOf = (engine: any, userId: string) => { + const row = (engine.tables.get('sys_verification') ?? []).find( + (v: any) => v.identifier === idpLocaleRecordIdentifier(userId), + ); + return row ? (JSON.parse(row.value).locale as string) : undefined; +}; +/** The user picks a language in the environment itself (the profile page's write). */ +const pickLocally = (engine: any, locale: string) => { + userRow(engine).locale = locale; +}; + +describe('a JIT sign-in through the platform IdP sets the user language preference', () => { + it('⭐ a sign-in with `locale: "zh-CN"` creates a user whose language is zh-CN', async () => { + const engine = createMemoryEngine(); + idpProfile = { locale: 'zh-CN' }; + + const landed = await signInWithIdp(makeManager(engine)); + + expect(landed.href).toBe(AFTER); + expect(userRow(engine).locale).toBe('zh-CN'); + expect(recordOf(engine, userRow(engine).id)).toBe('zh-CN'); + }); + + it('⭐ a later sign-in with `locale: "en"` after the user picked ja locally keeps ja', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + idpProfile = { locale: 'zh-CN' }; + await signInWithIdp(manager); + pickLocally(engine, 'ja-JP'); + + idpProfile = { locale: 'en' }; + const landed = await signInWithIdp(manager); + + expect(landed.href).toBe(AFTER); + expect(userRow(engine).locale).toBe('ja-JP'); + }); + + it('⭐ an unsupported locale falls back to the default: the column stays unset', async () => { + const engine = createMemoryEngine(); + idpProfile = { locale: 'fr-FR' }; + + const landed = await signInWithIdp(makeManager(engine)); + + expect(landed.href).toBe(AFTER); + expect(userRow(engine).locale ?? null).toBeNull(); + expect(recordOf(engine, userRow(engine).id)).toBeUndefined(); + }); + + it('a later sign-in follows the cloud account language while the user has not picked one', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + idpProfile = { locale: 'zh-CN' }; + await signInWithIdp(manager); + + idpProfile = { locale: 'en-US' }; + await signInWithIdp(manager); + + expect(userRow(engine).locale).toBe('en'); + expect(recordOf(engine, userRow(engine).id)).toBe('en'); + }); + + it('a later unsupported claim clears an IdP-written value, never a local one', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + idpProfile = { locale: 'zh-CN' }; + await signInWithIdp(manager); + + idpProfile = { locale: 'fr' }; + await signInWithIdp(manager); + expect(userRow(engine).locale ?? null).toBeNull(); + expect(recordOf(engine, userRow(engine).id)).toBeUndefined(); + + pickLocally(engine, 'ja-JP'); + await signInWithIdp(manager); + expect(userRow(engine).locale).toBe('ja-JP'); + }); + + it('an IdP that sends no `locale` changes nothing', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + idpProfile = { locale: 'zh-CN' }; + await signInWithIdp(manager); + + idpProfile = {}; + await signInWithIdp(manager); + + expect(userRow(engine).locale).toBe('zh-CN'); + expect(recordOf(engine, userRow(engine).id)).toBe('zh-CN'); + }); + + it('a user who held a language before any IdP record keeps it', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + idpProfile = {}; + await signInWithIdp(manager); + pickLocally(engine, 'ja-JP'); + + idpProfile = { locale: 'zh-CN' }; + await signInWithIdp(manager); + + expect(userRow(engine).locale).toBe('ja-JP'); + }); + + it('a user store that refuses the write never fails the sign-in, and says so at warn', async () => { + const engine = createMemoryEngine(); + const warn = vi.fn(); + const manager = makeManager(engine, { logger: { warn, info: () => {}, error: () => {}, debug: () => {} } }); + engine.failUpdates.add('sys_user'); + idpProfile = { locale: 'zh-CN' }; + + const landed = await signInWithIdp(manager); + + expect(landed.href).toBe(AFTER); + expect(userRow(engine).locale ?? null).toBeNull(); + expect(recordOf(engine, userRow(engine).id)).toBeUndefined(); + expect(warn.mock.calls.some(([m]) => String(m).includes('IdP locale claim was not applied'))).toBe(true); + }); + + it('with no i18n service to close the claim against, nothing is written, once said at warn', async () => { + const engine = createMemoryEngine(); + const warn = vi.fn(); + const manager = makeManager(engine, { + getI18n: () => undefined, + logger: { warn, info: () => {}, error: () => {}, debug: () => {} }, + }); + idpProfile = { locale: 'zh-CN' }; + + await signInWithIdp(manager); + await signInWithIdp(manager); + + expect(userRow(engine).locale ?? null).toBeNull(); + expect(warn.mock.calls.filter(([m]) => String(m).includes('no i18n service reports a supported locale'))).toHaveLength(1); + }); +}); diff --git a/packages/plugins/plugin-auth/src/idp-locale-claim.ts b/packages/plugins/plugin-auth/src/idp-locale-claim.ts new file mode 100644 index 00000000000..485af40c65e --- /dev/null +++ b/packages/plugins/plugin-auth/src/idp-locale-claim.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The IdP's `locale` claim → the user's language preference (`sys_user.locale`). + * + * When an environment creates or refreshes a user just-in-time from an + * external IdP sign-in — the cloud as IdP on a hosted environment, or any + * OIDC IdP — the standard OIDC `locale` claim (OIDC Core §5.1, a BCP-47 tag) + * becomes the user's language preference, so a hosted environment follows the + * cloud account's language. The column is the one the console reads as the + * signed-in user's UI language and service-messaging reads per recipient. + * + * ## The rule + * + * 1. **First creation** — the claim sets the preference. + * 2. **A later sign-in** — the claim updates it only while the user has not + * set a preference in the environment itself: a local choice wins. + * 3. **An unknown or unsupported claim** — the column is left unset (an + * IdP-written value is cleared), so the deployment default applies, which + * is what an unset `sys_user.locale` means everywhere it is read. The + * claim is closed against the locales the i18n service reports + * (`getLocales()`, narrowed to the app's declared `i18n.supportedLocales`) + * with the platform's own matcher (`resolveLocale`: exact → case → + * base language → variant), and the SUPPORTED spelling is what is stored. + * 4. **No claim at all** — the IdP said nothing, so nothing changes. + * + * ## How "a local choice" is told apart 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 + * ({@link idpLocaleRecordIdentifier}), written through better-auth's DATABASE + * adapter — the implicit-linking unlink record's idiom, and for the same + * reason (`implicit-account-linking.ts`, {@link LinkingDbAdapter}: a host + * `secondaryStorage` cache must never be the only home of it). The IdP owns + * the column while it is unset, or while it still holds exactly the recorded + * value; any other value is a local choice and is never overwritten. + * + * Every partial failure degrades toward "local choice wins": the column is + * written before the record, so a record that cannot be written leaves the + * column differing from it, and the next sign-in keeps the value rather than + * overwriting it. A user who picks the very language the IdP asserts is + * indistinguishable from one who never picked, and keeps following the IdP — + * the only reading under which the two cannot disagree about what they see. + * Clearing the column locally hands it back to the IdP. + * + * ## Where it runs + * + * better-auth hands the raw IdP profile to the `validateUserInfo` seam on + * every action of an external sign-in (`create-user`, `sign-in`, + * `link-account`): `source.oauth.profile` for generic-OAuth and social + * providers (the cloud IdP is a generic-OAuth provider), `source.sso.profile` + * for `@better-auth/sso` OIDC providers. That seam may still refuse the + * sign-in and runs before the user row exists, so it only STAGES the claim, + * scoped to the request ({@link IdpLocaleSync.stage}). The session a + * successful sign-in mints — new user or returning — is where the user id is + * final, so `session.create.after` SETTLES it ({@link IdpLocaleSync.settle}). + * A sign-in that is refused mints no session, and the staged claim dies with + * its request. + * + * A failure here never fails a sign-in. It is a functional degradation — the + * user sees the deployment default instead of the IdP's language, visibly — + * so it is reported at `warn`. + */ + +import { resolveLocale } from '@objectstack/core'; +import { normalizeRecipientLocale } from '@objectstack/service-messaging'; +import { authContextOf, type LinkingDbAdapter } from './implicit-account-linking.js'; + +/** The `sys_verification.identifier` namespace for the IdP-written locale record. */ +const IDP_LOCALE_RECORD_PREFIX = 'idp-locale'; + +/** + * The record's lifetime. It must outlive any realistic gap between two + * sign-ins; better-auth's verification sweep deletes rows by `expiresAt`, so + * the value has to be finite (the unlink record's reasoning). + */ +const IDP_LOCALE_RECORD_TTL_MS = 100 * 365 * 24 * 60 * 60 * 1000; + +const VERIFICATION_MODEL = 'verification'; + +/** The sign-in methods whose profile is an OIDC claim set. */ +const OIDC_PROFILE_SOURCES: Readonly> = { + oauth: 'oauth', + 'sso-oidc': 'sso', +}; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +export function idpLocaleRecordIdentifier(userId: string): string { + return `${IDP_LOCALE_RECORD_PREFIX}:${userId}`; +} + +/** What one IdP sign-in asserted about the user's language. */ +export interface IdpLocaleClaim { + /** The provider that asserted it, for the record and the logs. */ + providerId?: string; + /** The raw claim value — anything, as the IdP sent it. */ + value: unknown; +} + +/** + * The `locale` claim of the IdP profile a `validateUserInfo` source carries, + * or `undefined` when the source is not an OIDC sign-in or its profile has no + * `locale` key. A present-but-unusable value IS a claim (rule 3). + */ +export function readIdpLocaleClaim(source: unknown): IdpLocaleClaim | undefined { + const s = source as { method?: unknown; oauth?: unknown; sso?: unknown } | null | undefined; + const branch = typeof s?.method === 'string' ? OIDC_PROFILE_SOURCES[s.method] : undefined; + if (!branch) return undefined; + const carrier = s?.[branch] as { providerId?: unknown; profile?: unknown } | undefined; + const profile = carrier?.profile; + if (!profile || typeof profile !== 'object' || !('locale' in profile)) return undefined; + return { + providerId: typeof carrier?.providerId === 'string' ? carrier.providerId : undefined, + value: (profile as { locale?: unknown }).locale, + }; +} + +/** + * The supported locale a claim names, in the SUPPORTED spelling, or + * `undefined` for an unknown or unsupported claim. OIDC Core §5.1 notes that + * some implementations write the region with an underscore (`en_US`); that + * spelling is read as the BCP-47 tag it stands for. + */ +export function resolveIdpLocale(claim: unknown, supported: readonly string[]): string | undefined { + const tag = normalizeRecipientLocale(typeof claim === 'string' ? claim.replace(/_/g, '-') : claim); + if (!tag) return undefined; + return resolveLocale(tag, [...supported]); +} + +/** The state the decision reads. */ +export interface IdpLocaleState { + /** The supported locale the claim names (`undefined`: unknown or unsupported). */ + resolved: string | undefined; + /** `sys_user.locale` as stored (`undefined`: unset). */ + stored: string | undefined; + /** The value the IdP path last wrote (`undefined`: no record). */ + recorded: string | undefined; +} + +/** + * What to write, or `null` to leave both the column and the record as they + * are. `locale: null` clears the column; `record: null` drops the record. + */ +export type IdpLocaleWrite = { locale: string | null; record: string | null } | null; + +/** The rule (module header), as a pure decision. */ +export function decideIdpLocaleWrite(state: IdpLocaleState): IdpLocaleWrite { + const { resolved, stored, recorded } = state; + const idpOwnsColumn = stored === undefined || (recorded !== undefined && stored === recorded); + if (!idpOwnsColumn) return null; + const target = resolved ?? null; + const columnChanges = target !== (stored ?? null); + const recordChanges = target !== (recorded ?? null); + if (!columnChanges && !recordChanges) return null; + return { locale: target, record: target }; +} + +/** The slice of the data engine the settle step uses. */ +export interface IdpLocaleEngine { + findOne(object: string, query: Record): Promise | null>; + update(object: string, data: Record, options?: Record): Promise; +} + +export interface IdpLocaleSyncDeps { + getEngine(): IdpLocaleEngine | undefined; + /** The i18n service's reported locales; `undefined` when there is no service to ask. */ + getSupportedLocales(): readonly string[] | undefined; + /** better-auth's auth context, for a call whose endpoint context carries none. */ + resolveAuth(): Promise<{ adapter: LinkingDbAdapter } | undefined>; + warn(message: string, meta?: Record): void; +} + +const storedLocaleOf = (value: unknown): string | undefined => + typeof value === 'string' && value.trim() ? value.trim() : undefined; + +/** One per auth manager: stages a sign-in's claim, then settles it on the session. */ +export class IdpLocaleSync { + /** Scoped to the request: the entry dies with the endpoint context that staged it. */ + private readonly staged = new WeakMap(); + private warnedNoLocales = false; + + constructor(private readonly deps: IdpLocaleSyncDeps) {} + + /** `validateUserInfo` half: stage the claim. Never throws, never refuses. */ + stage(data: unknown, ctx: unknown): void { + if (!ctx || typeof ctx !== 'object') return; + const claim = readIdpLocaleClaim((data as { source?: unknown } | null)?.source); + if (claim) this.staged.set(ctx, claim); + } + + /** `session.create.after` half: apply the rule for the session's user. Never throws. */ + async settle(session: unknown, ctx: unknown): Promise { + if (!ctx || typeof ctx !== 'object') return; + const claim = this.staged.get(ctx); + if (!claim) return; + this.staged.delete(ctx); + const userId = (session as { userId?: unknown } | null)?.userId; + if (typeof userId !== 'string' || !userId) return; + try { + const supported = this.deps.getSupportedLocales(); + if (!supported || supported.length === 0) { + // Without the list nothing can be judged supported, and clearing an + // IdP-written value on that ground would be judging anyway. + if (!this.warnedNoLocales) { + this.warnedNoLocales = true; + this.deps.warn( + '[auth] the IdP locale claim was not applied: no i18n service reports a supported locale, ' + + "so a user's language does not follow their IdP. Register an i18n service (or declare " + + 'i18n.supportedLocales) to have external sign-ins set the user language preference.', + ); + } + return; + } + const engine = this.deps.getEngine(); + if (!engine) return; + const auth = authContextOf(ctx) ?? (await this.deps.resolveAuth()); + if (!auth) throw new Error('the auth store is unavailable'); + const row = await engine.findOne('sys_user', { + where: { id: userId }, + fields: ['id', 'locale'], + context: SYSTEM_CTX, + }); + if (!row) return; + const record = await auth.adapter.findOne({ + model: VERIFICATION_MODEL, + where: [{ field: 'identifier', value: idpLocaleRecordIdentifier(userId) }], + }); + const write = decideIdpLocaleWrite({ + resolved: resolveIdpLocale(claim.value, supported), + stored: storedLocaleOf(row.locale), + recorded: recordedLocaleOf(record), + }); + if (!write) return; + if (write.locale !== (storedLocaleOf(row.locale) ?? null)) { + await engine.update('sys_user', { id: userId, locale: write.locale }, { context: SYSTEM_CTX }); + } + await writeRecord(auth.adapter, userId, write.record, claim.providerId); + } catch (e) { + this.deps.warn( + '[auth] the IdP locale claim was not applied: the sign-in succeeded, and the user keeps the ' + + 'language preference they had. It is applied again on their next external sign-in once the ' + + 'user store and the auth store are writable.', + { userId, providerId: claim.providerId, error: (e as Error)?.message }, + ); + } + } + + /** `user.delete.after` half: a deleted user leaves no record behind. Never throws. */ + async forget(user: unknown, ctx: unknown): Promise { + const id = (user as { id?: unknown } | null)?.id; + if (typeof id !== 'string' || !id) return; + try { + const auth = authContextOf(ctx) ?? (await this.deps.resolveAuth()); + if (!auth) return; + await auth.adapter.deleteMany({ + model: VERIFICATION_MODEL, + where: [{ field: 'identifier', value: idpLocaleRecordIdentifier(id) }], + }); + } catch (e) { + // Inert once its user is gone: a new user never has the deleted id. + this.deps.warn('[auth] could not clear the IdP locale record of a deleted user', { + error: (e as Error)?.message, + }); + } + } +} + +function recordedLocaleOf(record: unknown): string | undefined { + const value = (record as { value?: unknown } | null)?.value; + if (typeof value !== 'string') return undefined; + try { + return storedLocaleOf((JSON.parse(value) as { locale?: unknown } | null)?.locale); + } catch { + return undefined; + } +} + +/** Replace the user's record with `locale`, or drop it when `locale` is `null`. */ +async function writeRecord( + adapter: LinkingDbAdapter, + userId: string, + locale: string | null, + providerId: string | undefined, +): Promise { + const identifier = idpLocaleRecordIdentifier(userId); + await adapter.deleteMany({ model: VERIFICATION_MODEL, where: [{ field: 'identifier', value: identifier }] }); + if (locale === null) return; + const now = new Date(); + await adapter.create({ + model: VERIFICATION_MODEL, + data: { + identifier, + value: JSON.stringify({ locale, providerId: providerId ?? null, writtenAt: now.toISOString() }), + expiresAt: new Date(now.getTime() + IDP_LOCALE_RECORD_TTL_MS), + createdAt: now, + updatedAt: now, + }, + }); +} From 4d4e2390cce07cec5b3f2cc93801f7e4c0291459 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:18:58 +0000 Subject: [PATCH 2/3] feat(plugin-auth): changeset for the IdP locale claim Claude-Session: https://claude.ai/code/session_019PQiYTjCGrkiAcRc8e5mrU Co-authored-by: Claude --- .changeset/25261-jit-idp-locale-claim.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/25261-jit-idp-locale-claim.md diff --git a/.changeset/25261-jit-idp-locale-claim.md b/.changeset/25261-jit-idp-locale-claim.md new file mode 100644 index 00000000000..984cf70df39 --- /dev/null +++ b/.changeset/25261-jit-idp-locale-claim.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +--- + +feat(plugin-auth): a JIT sign-in reads the IdP `locale` claim into the user's language preference, so a hosted environment follows the cloud account language + +Clause-②: no +Metadata-protocol: none + +When an external IdP sign-in creates or refreshes a user just-in-time (the cloud as IdP on a hosted environment through `oidcProviders`, any generic-OAuth or social provider, or a `@better-auth/sso` OIDC provider), the standard OIDC `locale` claim now becomes `sys_user.locale`, the column the console reads as the signed-in user's language and messaging reads per recipient. + +- **First creation:** the claim sets the preference. +- **A later sign-in:** the claim updates it only while the user has not picked a language in the environment itself. A local choice wins and is never overwritten; clearing it hands the column back to the IdP. +- **An unknown or unsupported claim:** nothing is stored (an IdP-written value is cleared), so the deployment default applies. The claim is closed against the locales the i18n service reports (`i18n.supportedLocales` when the app declares them), matched exact, then case-insensitive, then by base language (`en-US` → `en`, `ja` → `ja-JP`), and the supported spelling is stored. The underscore spelling (`zh_CN`) reads as the tag it stands for. +- **No claim:** nothing changes. + +What the IdP last wrote is recorded per user in `sys_verification` (identifier `idp-locale:` plus the user id), so an IdP-written value can be told apart from a local choice; no schema changes. Applying the claim never fails a sign-in: a store that refuses the write is reported at `warn` and the user keeps the language they had. From d63d6066ca4443fc19e9d0e8039787906558d52b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:39:13 +0000 Subject: [PATCH 3/3] fix(plugin-auth): type the IdP locale engine as an IDataEngine slice; 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 --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 19 ++++++----- .../plugin-auth/src/idp-locale-claim.ts | 12 +++---- scripts/engine-double-contract.pinned.json | 15 ++++++++ 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1466e4757de..ec64188d4ce 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -124,7 +124,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 236 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 237 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -189,10 +189,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **236** | +| 175 write call sites | quoted in the merged changeset | **237** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 236** decidable, **76** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 134 decidably elevated, 0 decidably not, 94 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **161 of 237** decidable, **76** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 135 decidably elevated, 0 decidably not, 94 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -209,11 +209,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -134 of 236 (57%) as decidably elevated, with 94 more whose elevation is a +135 of 237 (57%) as decidably elevated, with 94 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 237`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -225,28 +225,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **236** | -| …whose object name is statically decidable | 160 | +| write call sites on the application surface | **237** | +| …whose object name is statically decidable | 161 | | …whose object name is chosen at run time | 76 | -| …against an object with tenancy ENABLED | 159 | +| …against an object with tenancy ENABLED | 160 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 177 | +| threading a tenant context | 178 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 134 | +| threading a decidably ELEVATED (`isSystem`) context | 135 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 94 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 195 | +| receiver carried a readable engine type | 196 | | receiver erased, placed by the object NAME | 31 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 109 | +| object name spelled inline | 110 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 69 | @@ -299,13 +299,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-22 at `78b1ed16a`. +Measured on 2026-10-22 at `4d4e2390c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 700 | -| engine-shaped types recognised | 86 | +| tracked non-test sources scanned | 706 | +| engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 169 | +| same-named calls subtracted as non-engine | 171 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9d1fddbcbba..ec1ad430e6d 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 236 | -| Object name statically decidable | 160 | +| Write call sites | 237 | +| Object name statically decidable | 161 | | Object name chosen at run time | 76 | -| Against a tenancy-enabled object | 159 | +| Against a tenancy-enabled object | 160 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 177 | +| Threading a tenant context | 178 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 134 | +| Threading a decidably elevated context | 135 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 94 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-22 at `78b1ed16a`. +Measured on 2026-10-22 at `4d4e2390c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 700 | -| engine-shaped types recognised | 86 | +| tracked non-test sources scanned | 706 | +| engine-shaped types recognised | 87 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 169 | +| same-named calls subtracted as non-engine | 171 | ## Every site @@ -145,6 +145,7 @@ Measured on 2026-10-22 at `78b1ed16a`. | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.API_KEY` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/forced-reset-revocation.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/idp-locale-claim.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | diff --git a/packages/plugins/plugin-auth/src/idp-locale-claim.ts b/packages/plugins/plugin-auth/src/idp-locale-claim.ts index 485af40c65e..473c70a0eae 100644 --- a/packages/plugins/plugin-auth/src/idp-locale-claim.ts +++ b/packages/plugins/plugin-auth/src/idp-locale-claim.ts @@ -63,6 +63,7 @@ */ import { resolveLocale } from '@objectstack/core'; +import type { IDataEngine } from '@objectstack/core'; import { normalizeRecipientLocale } from '@objectstack/service-messaging'; import { authContextOf, type LinkingDbAdapter } from './implicit-account-linking.js'; @@ -157,10 +158,7 @@ export function decideIdpLocaleWrite(state: IdpLocaleState): IdpLocaleWrite { } /** The slice of the data engine the settle step uses. */ -export interface IdpLocaleEngine { - findOne(object: string, query: Record): Promise | null>; - update(object: string, data: Record, options?: Record): Promise; -} +export type IdpLocaleEngine = Pick; export interface IdpLocaleSyncDeps { getEngine(): IdpLocaleEngine | undefined; @@ -212,7 +210,7 @@ export class IdpLocaleSync { } return; } - const engine = this.deps.getEngine(); + const engine: IdpLocaleEngine | undefined = this.deps.getEngine(); if (!engine) return; const auth = authContextOf(ctx) ?? (await this.deps.resolveAuth()); if (!auth) throw new Error('the auth store is unavailable'); @@ -235,7 +233,7 @@ export class IdpLocaleSync { if (write.locale !== (storedLocaleOf(row.locale) ?? null)) { await engine.update('sys_user', { id: userId, locale: write.locale }, { context: SYSTEM_CTX }); } - await writeRecord(auth.adapter, userId, write.record, claim.providerId); + await replaceIdpLocaleRecord(auth.adapter, userId, write.record, claim.providerId); } catch (e) { this.deps.warn( '[auth] the IdP locale claim was not applied: the sign-in succeeded, and the user keeps the ' + @@ -277,7 +275,7 @@ function recordedLocaleOf(record: unknown): string | undefined { } /** Replace the user's record with `locale`, or drop it when `locale` is `null`. */ -async function writeRecord( +async function replaceIdpLocaleRecord( adapter: LinkingDbAdapter, userId: string, locale: string | null, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7243fe78663..d121f6c48d1 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3101,6 +3101,21 @@ "verb": "findOne", "pinned": 2 }, + { + "file": "packages/plugins/plugin-auth/src/idp-locale-claim.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/idp-locale-claim.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/idp-locale-claim.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/impersonation-bearer-rotation.test.ts", "verb": "delete",