From fca941095c338c6498618be9b7d5b222af9f8d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:56:42 +0000 Subject: [PATCH 1/4] feat(plugin-auth): a new organization takes its creator's default currency Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 36 ++ .../src/organization-default-currency.ts | 398 ++++++++++++++++++ .../src/manifests/localization.manifest.ts | 9 + 3 files changed, 443 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/organization-default-currency.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 2935b563dd6..05f89542b48 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -75,6 +75,11 @@ import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runReq import { runResendVerificationEmail } from './send-verification-email.js'; import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js'; import { readJsonRequestBody } from './request-json-body.js'; +import { + seedDevWorkspaceCurrency, + withOrganizationDefaultCurrency, + type CurrencySettingsDoor, +} from './organization-default-currency.js'; import type { CounterStore } from './rate-limit-storage.js'; import { authIdentityManifest } from './manifest.js'; import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js'; @@ -680,6 +685,26 @@ export class AuthPlugin implements Plugin { } }; + // [#25495] A new organization takes its creator's default currency + // (`localization.currency`), derived from their stored language or the + // creating request's `Accept-Language`; nothing derivable β‡’ nothing + // written, a value already set β‡’ left alone. Wrapped AFTER + // `auth:configure` so a host's own `onOrganizationCreated`, from the + // plugin options or from a configure handler, still runs, and runs first. + // The settings service is resolved when an organization is created, not + // here: it starts after this plugin. + authConfig.onOrganizationCreated = withOrganizationDefaultCurrency(authConfig.onOrganizationCreated, { + dataEngine, + getSettings: () => { + try { + return ctx.getService('settings'); + } catch { + return undefined; + } + }, + logger: ctx.logger, + }); + // Initialize auth manager with data engine this.authManager = new AuthManager(authConfig); // ADR-0092 D6 β€” remember the storage better-auth will actually use so the @@ -2487,6 +2512,17 @@ export class AuthPlugin implements Plugin { // creator's β€” see the method. Only on the seeding boot: a later boot // takes the database as it finds it. if (seededId) await this.seedDevWorkspaceTimezone(ctx, ql, seededId); + // [#25495] And with the host's currency, by the rule a new + // organization's creator gets (`organization-default-currency.ts`). + if (seededId) { + let settings: CurrencySettingsDoor | undefined; + try { + settings = ctx.getService('settings'); + } catch { + settings = undefined; + } + await seedDevWorkspaceCurrency({ settings, ql, userId: seededId, logger: ctx.logger }); + } ctx.logger.info(`πŸ”‘ Dev admin seeded: ${email} / ${password}`); // Surface the credentials in the `serve` startup banner. The // ctx.logger line above is swallowed by serve's boot-quiet window diff --git a/packages/plugins/plugin-auth/src/organization-default-currency.ts b/packages/plugins/plugin-auth/src/organization-default-currency.ts new file mode 100644 index 00000000000..2033a70a304 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-default-currency.ts @@ -0,0 +1,398 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25495] A new organization's default currency, derived from its creator's + * language or region (maintainer ruling δΉ™). + * + * `localization.currency` (service-settings, `scope: 'tenant'`) has no + * platform default, on purpose: a guessed default put a "$" on every + * code-less amount. So a new organization showed `currencyMode: 'dynamic'` + * amounts as plain numbers until an administrator picked a currency. This + * module fills that value in when the organization is created, from what the + * server knows about the person creating it, and leaves it unset when it + * knows nothing. + * + * - **Where the language comes from** ({@link creatorLocaleForNewOrganization}): + * the creator's stored `sys_user.locale` when it is set, else the creating + * request's `Accept-Language` preference, read with the platform's one + * parser (`preferredLocaleFromHeader`). This is the order `GET + * /auth/me/localization` uses for the user's language, without its third + * rung, the deployment default. The seam can see both: the callback names + * the creator's id, and better-auth's endpoint context (an + * `AsyncLocalStorage` that `afterCreateOrganization` runs inside) carries + * the request's headers. + * - **The dev seed** ({@link hostLocaleFromEnv}) reads the host's locale the + * POSIX way, `LC_ALL`, then `LC_MONETARY`, then `LANG`. It does not use + * `Intl`'s default locale, because that answers `en-US` on a host that sets + * no locale at all, and that `en-US` is not anybody's choice. + * - **The mapping** ({@link currencyForLocale}): the tag's region, or, for a + * bare language, the region `Intl.Locale#maximize()` gives it (`zh` β†’ `CN`, + * `ja` β†’ `JP`, `en` β†’ `US`), then that region's currency from + * {@link REGION_CURRENCY}, a checked-in CLDR snapshot. A tag with no country + * region (`es-419`, `en-150`) or a region with no current currency maps to + * nothing. + * - **Nothing to derive from β‡’ nothing is written.** There is no fallback to + * the manifest's `default_country` / `locale` defaults or to any other + * platform value. + * - **The write** is the settings service's own `set`, one `tenant` row for + * the organization. That is the row `PUT /api/settings/localization` writes, + * so Setup β†’ Localization shows the value and changes it. It is made only + * while the key still resolves from the default (`source: 'default'`). A + * value on any rung (this organization's own, a `global` row, an env + * override) is an administrator's choice and is left as it is. + * + * Best-effort: an organization is created whether or not this lands. A miss + * costs the pre-filled currency, nothing else, and is logged with where to set + * it by hand. + */ + +import { tryGetCurrentAuthEndpointContext } from '@better-auth/core/context'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { preferredLocaleFromHeader, SystemObjectName } from '@objectstack/spec/system'; + +/** + * Region (ISO 3166-1 alpha-2 and the CLDR territory codes beside it) β†’ the + * region's current ISO 4217 currency. + * + * ## Provenance: CLDR `currencyData`, checked in, not hand-written + * + * Generated on 2026-10-24 from CLDR 48 `supplemental/currencyData.json` + * (`cldr-core@48.0.0` on npm, sha256 of that file + * `ab007c5bb654a11601d6328f73a9b5b504334c556f3edb760020d6e2cc408472`), the + * same CLDR release as `CURRENCY_FRACTION_DIGITS` in `@objectstack/spec`. + * For each two-letter region the value is the FIRST entry of its + * `currencyData.region` list that has no `_to` date and is not + * `_tender: "false"`. CLDR orders that list so the first current tender is the + * region's primary currency (`PS` β†’ `ILS` ahead of `JOD`). Entries are kept + * only when the code is in the `iso_4217_currency` value domain, the set the + * settings key accepts. Eleven regions have no current tender and are absent: + * `AQ` and `ZZ` (`XXX`), and the retired `BU` `CP` `CS` `DD` `SU` `TP` `YD` + * `YU` `ZR`. + * + * `Intl` has no region β†’ currency lookup, which is why this is a table. To + * refresh it for a later CLDR, regenerate it by the rule above and update this + * provenance block. Do not edit entries by hand. + */ +export const REGION_CURRENCY: Readonly> = { + AC: 'SHP', AD: 'EUR', AE: 'AED', AF: 'AFN', AG: 'XCD', AI: 'XCD', AL: 'ALL', AM: 'AMD', + AO: 'AOA', AR: 'ARS', AS: 'USD', AT: 'EUR', AU: 'AUD', AW: 'AWG', AX: 'EUR', AZ: 'AZN', + BA: 'BAM', BB: 'BBD', BD: 'BDT', BE: 'EUR', BF: 'XOF', BG: 'EUR', BH: 'BHD', BI: 'BIF', + BJ: 'XOF', BL: 'EUR', BM: 'BMD', BN: 'BND', BO: 'BOB', BQ: 'USD', BR: 'BRL', BS: 'BSD', + BT: 'BTN', BV: 'NOK', BW: 'BWP', BY: 'BYN', BZ: 'BZD', CA: 'CAD', CC: 'AUD', CD: 'CDF', + CF: 'XAF', CG: 'XAF', CH: 'CHF', CI: 'XOF', CK: 'NZD', CL: 'CLP', CM: 'XAF', CN: 'CNY', + CO: 'COP', CR: 'CRC', CU: 'CUP', CV: 'CVE', CW: 'XCG', CX: 'AUD', CY: 'EUR', CZ: 'CZK', + DE: 'EUR', DG: 'USD', DJ: 'DJF', DK: 'DKK', DM: 'XCD', DO: 'DOP', DZ: 'DZD', EA: 'EUR', + EC: 'USD', EE: 'EUR', EG: 'EGP', EH: 'MAD', ER: 'ERN', ES: 'EUR', ET: 'ETB', EU: 'EUR', + FI: 'EUR', FJ: 'FJD', FK: 'FKP', FM: 'USD', FO: 'DKK', FR: 'EUR', GA: 'XAF', GB: 'GBP', + GD: 'XCD', GE: 'GEL', GF: 'EUR', GG: 'GBP', GH: 'GHS', GI: 'GIP', GL: 'DKK', GM: 'GMD', + GN: 'GNF', GP: 'EUR', GQ: 'XAF', GR: 'EUR', GS: 'GBP', GT: 'GTQ', GU: 'USD', GW: 'XOF', + GY: 'GYD', HK: 'HKD', HM: 'AUD', HN: 'HNL', HR: 'EUR', HT: 'HTG', HU: 'HUF', IC: 'EUR', + ID: 'IDR', IE: 'EUR', IL: 'ILS', IM: 'GBP', IN: 'INR', IO: 'USD', IQ: 'IQD', IR: 'IRR', + IS: 'ISK', IT: 'EUR', JE: 'GBP', JM: 'JMD', JO: 'JOD', JP: 'JPY', KE: 'KES', KG: 'KGS', + KH: 'KHR', KI: 'AUD', KM: 'KMF', KN: 'XCD', KP: 'KPW', KR: 'KRW', KW: 'KWD', KY: 'KYD', + KZ: 'KZT', LA: 'LAK', LB: 'LBP', LC: 'XCD', LI: 'CHF', LK: 'LKR', LR: 'LRD', LS: 'ZAR', + LT: 'EUR', LU: 'EUR', LV: 'EUR', LY: 'LYD', MA: 'MAD', MC: 'EUR', MD: 'MDL', ME: 'EUR', + MF: 'EUR', MG: 'MGA', MH: 'USD', MK: 'MKD', ML: 'XOF', MM: 'MMK', MN: 'MNT', MO: 'MOP', + MP: 'USD', MQ: 'EUR', MR: 'MRU', MS: 'XCD', MT: 'EUR', MU: 'MUR', MV: 'MVR', MW: 'MWK', + MX: 'MXN', MY: 'MYR', MZ: 'MZN', NA: 'NAD', NC: 'XPF', NE: 'XOF', NF: 'AUD', NG: 'NGN', + NI: 'NIO', NL: 'EUR', NO: 'NOK', NP: 'NPR', NR: 'AUD', NU: 'NZD', NZ: 'NZD', OM: 'OMR', + PA: 'PAB', PE: 'PEN', PF: 'XPF', PG: 'PGK', PH: 'PHP', PK: 'PKR', PL: 'PLN', PM: 'EUR', + PN: 'NZD', PR: 'USD', PS: 'ILS', PT: 'EUR', PW: 'USD', PY: 'PYG', QA: 'QAR', RE: 'EUR', + RO: 'RON', RS: 'RSD', RU: 'RUB', RW: 'RWF', SA: 'SAR', SB: 'SBD', SC: 'SCR', SD: 'SDG', + SE: 'SEK', SG: 'SGD', SH: 'SHP', SI: 'EUR', SJ: 'NOK', SK: 'EUR', SL: 'SLE', SM: 'EUR', + SN: 'XOF', SO: 'SOS', SR: 'SRD', SS: 'SSP', ST: 'STN', SV: 'USD', SX: 'XCG', SY: 'SYP', + SZ: 'SZL', TA: 'GBP', TC: 'USD', TD: 'XAF', TF: 'EUR', TG: 'XOF', TH: 'THB', TJ: 'TJS', + TK: 'NZD', TL: 'USD', TM: 'TMT', TN: 'TND', TO: 'TOP', TR: 'TRY', TT: 'TTD', TV: 'AUD', + TW: 'TWD', TZ: 'TZS', UA: 'UAH', UG: 'UGX', UM: 'USD', US: 'USD', UY: 'UYU', UZ: 'UZS', + VA: 'EUR', VC: 'XCD', VE: 'VES', VG: 'USD', VI: 'USD', VN: 'VND', VU: 'VUV', WF: 'XPF', + WS: 'WST', XK: 'EUR', YE: 'YER', YT: 'EUR', ZA: 'ZAR', ZM: 'ZMW', ZW: 'ZWG', +}; + +/** + * The ISO 4217 currency of a BCP 47 tag's region, or `undefined`. + * + * The region is the tag's own (`zh-CN` β†’ `CN`), or for a tag without one the + * likely region `Intl.Locale#maximize()` gives (`zh` β†’ `CN`, `ja` β†’ `JP`, + * `zh-Hant` β†’ `TW`). A malformed tag, a tag whose region is not a country + * (`es-419`, `en-150`), and a region with no current currency all answer + * `undefined`: no guess is made. + */ +export function currencyForLocale(tag: unknown): string | undefined { + if (typeof tag !== 'string') return undefined; + const value = tag.trim(); + if (!value) return undefined; + let region: string | undefined; + try { + region = new Intl.Locale(value).maximize().region; + } catch { + return undefined; + } + if (!region || !Object.prototype.hasOwnProperty.call(REGION_CURRENCY, region)) return undefined; + return REGION_CURRENCY[region]; +} + +/** + * The host's locale as a BCP 47 tag, read from the POSIX environment, or + * `undefined`. + * + * The first of `LC_ALL`, `LC_MONETARY` and `LANG` that is set and not empty + * decides, which is the order POSIX gives these for monetary formatting. Its + * codeset and modifier are dropped (`zh_CN.UTF-8` β†’ `zh-CN`). `C` and `POSIX` + * name no language and answer `undefined`, and so does a host that sets none + * of the three. + */ +export function hostLocaleFromEnv(env: Record = process.env): string | undefined { + const raw = [env.LC_ALL, env.LC_MONETARY, env.LANG].find( + (v): v is string => typeof v === 'string' && v.trim() !== '', + ); + if (!raw) return undefined; + const base = raw.trim().split('.')[0]!.split('@')[0]!; + if (!base || base === 'C' || base === 'POSIX') return undefined; + return base.replace(/_/g, '-'); +} + +/** The part of the `settings` service this module calls. Structural, as in `auth-plugin.ts`. */ +export interface CurrencySettingsDoor { + get(namespace: string, key: string, ctx?: Record): Promise<{ source?: string } | undefined>; + set(namespace: string, key: string, value: unknown, ctx?: Record): Promise; +} + +/** What {@link seedOrganizationCurrency} did for one organization. */ +export type OrganizationCurrencyOutcome = + /** The derived currency was written as the organization's `tenant` value. */ + | { kind: 'written'; currency: string } + /** The key already resolves from a rung above the default (`source` names it); nothing written. */ + | { kind: 'kept'; source: string } + /** The locale maps to no currency (absent, malformed or no country region); nothing written. */ + | { kind: 'underivable' }; + +/** + * Give one organization the currency `locale` maps to, unless the key already + * has a value. Throws what the settings door throws, so the caller can log it. + */ +export async function seedOrganizationCurrency( + settings: CurrencySettingsDoor, + organizationId: string, + locale: string | undefined, +): Promise { + const currency = currencyForLocale(locale); + if (!currency) return { kind: 'underivable' }; + const scope = { tenantId: organizationId }; + const current = await settings.get('localization', 'currency', scope); + if (current?.source !== 'default') return { kind: 'kept', source: String(current?.source) }; + await settings.set('localization', 'currency', currency, scope); + return { kind: 'written', currency }; +} + +/** The logger surface this module writes to. */ +export interface CurrencySeedLogger { + info(message: string): void; + warn(message: string): void; + debug(message: string): void; +} + +/** The payload `AuthManagerOptions.onOrganizationCreated` receives. */ +export interface OrganizationCreatedEvent { + organizationId: string; + userId?: string; + name?: string; + slug?: string; +} + +/** Where a creator's language was read from, for the log line. */ +export type CreatorLocaleSource = 'the creator\'s stored language' | 'the creating request\'s Accept-Language'; + +/** The `accept-language` value on the better-auth endpoint context this call runs inside, if any. */ +function acceptLanguageOfCurrentRequest(): string | undefined { + let endpoint: unknown; + try { + endpoint = tryGetCurrentAuthEndpointContext(); + } catch { + return undefined; + } + if (!endpoint || typeof endpoint !== 'object') return undefined; + const request = (endpoint as { request?: { headers?: unknown } }).request; + for (const bag of [(endpoint as { headers?: unknown }).headers, request?.headers]) { + const get = (bag as { get?: unknown } | undefined)?.get; + if (typeof get !== 'function') continue; + try { + const value = (bag as Headers).get('accept-language'); + if (typeof value === 'string' && value) return value; + } catch { + // A header bag that throws on read leaves the request's language unknown. + } + } + return undefined; +} + +/** + * The language of the person creating an organization, and where it was read + * from: their stored `sys_user.locale` when set, else the creating request's + * preferred `Accept-Language` tag, else `undefined`. + * + * Throws when the stored locale cannot be read. The caller then writes + * nothing: the request's language must not stand in for a stored one that + * may exist. + */ +export async function creatorLocaleForNewOrganization( + ql: IDataEngine | undefined, + userId: string | undefined, + acceptLanguage: string | undefined, +): Promise<{ locale: string; from: CreatorLocaleSource } | undefined> { + if (ql && userId) { + const rows = await ql.find( + SystemObjectName.USER, + { where: { id: userId }, fields: ['id', 'locale'], limit: 1 }, + { context: { isSystem: true } }, + ); + const stored = (Array.isArray(rows) ? rows[0] : undefined)?.locale; + if (typeof stored === 'string' && stored.trim() !== '') { + return { locale: stored.trim(), from: 'the creator\'s stored language' }; + } + } + const requested = preferredLocaleFromHeader(acceptLanguage); + return requested ? { locale: requested, from: 'the creating request\'s Accept-Language' } : undefined; +} + +/** What {@link withOrganizationDefaultCurrency} needs, resolved when an organization is created. */ +export interface OrganizationCurrencyDeps { + dataEngine?: IDataEngine; + getSettings: () => CurrencySettingsDoor | undefined; + logger: CurrencySeedLogger; +} + +const SET_IT_BY_HAND = + 'An administrator can set it under Setup β†’ Localization β†’ Default currency; until then code-less amounts show as plain numbers.'; + +/** + * Give a newly created organization its creator's currency. Never throws: + * every miss is logged, and organization creation is not affected. + */ +export async function seedCreatedOrganizationCurrency( + data: OrganizationCreatedEvent, + deps: OrganizationCurrencyDeps, +): Promise { + const { logger } = deps; + const organizationId = data?.organizationId; + if (typeof organizationId !== 'string' || organizationId === '') return; + const acceptLanguage = acceptLanguageOfCurrentRequest(); + try { + const settings = deps.getSettings(); + if (!settings || typeof settings.get !== 'function' || typeof settings.set !== 'function') { + logger.debug(`[auth] organization ${organizationId}: no settings service, so no default currency was set`); + return; + } + const creator = await creatorLocaleForNewOrganization(deps.dataEngine, data.userId, acceptLanguage); + const outcome = await seedOrganizationCurrency(settings, organizationId, creator?.locale); + if (outcome.kind === 'written') { + logger.info( + `[auth] organization ${organizationId} takes ${outcome.currency} as its default currency, from ` + + `${creator!.from} (${creator!.locale}); Setup β†’ Localization changes it`, + ); + } else if (outcome.kind === 'kept') { + logger.debug( + `[auth] organization ${organizationId}: localization.currency already resolves from '${outcome.source}', so it was left as it is`, + ); + } else { + logger.debug( + `[auth] organization ${organizationId}: its creator's language (${creator ? creator.locale : 'none known'}) ` + + 'names no country, so no default currency was set', + ); + } + } catch (err: any) { + logger.warn( + `[auth] organization ${organizationId} was not given its creator's default currency: ` + + `${err?.message ?? err}. ${SET_IT_BY_HAND}`, + ); + } +} + +/** + * Wrap a host's `onOrganizationCreated` (or none) so a new organization also + * gets its creator's default currency. + * + * The host's callback runs first and is awaited, so a currency it sets is + * already there and is kept. A host callback that throws still throws, after + * the currency step, so `AuthManager` reports it as before. + */ +export function withOrganizationDefaultCurrency( + host: ((data: OrganizationCreatedEvent) => void | Promise) | undefined, + deps: OrganizationCurrencyDeps, +): (data: OrganizationCreatedEvent) => Promise { + return async (data) => { + let hostFailure: { error: unknown } | undefined; + if (typeof host === 'function') { + try { + await host(data); + } catch (error) { + hostFailure = { error }; + } + } + await seedCreatedOrganizationCurrency(data, deps); + if (hostFailure) throw hostFailure.error; + }; +} + +/** + * The dev seed's half: give each organization the seeded admin belongs to the + * currency of the host's locale ({@link hostLocaleFromEnv}), beside the + * time-zone seeding in `AuthPlugin`. Same rules as an organization created + * through the API: nothing derivable β‡’ nothing written, a value already set + * β‡’ left alone. Never throws. + */ +export async function seedDevWorkspaceCurrency(input: { + settings: CurrencySettingsDoor | undefined; + ql: IDataEngine; + userId: string; + logger: CurrencySeedLogger; + env?: Record; +}): Promise { + const { settings, ql, userId, logger } = input; + const locale = hostLocaleFromEnv(input.env); + if (!currencyForLocale(locale)) { + logger.debug( + `[auth] dev seed: this host's locale (${locale ?? 'none set'}) names no country, so the workspace keeps no default currency`, + ); + return; + } + if (!settings || typeof settings.get !== 'function' || typeof settings.set !== 'function') return; + + let organizationIds: string[]; + try { + const memberships = await ql.find( + SystemObjectName.MEMBER, + { where: { user_id: userId }, limit: 50 }, + { context: { isSystem: true } }, + ); + organizationIds = [ + ...new Set( + (Array.isArray(memberships) ? memberships : []) + .map((m: any) => m?.organization_id) + .filter((id: unknown): id is string => typeof id === 'string' && id !== ''), + ), + ]; + } catch (err: any) { + logger.warn( + `[auth] dev seed: the seeded admin's workspace could not be read (${err?.message ?? err}), so it was ` + + `not given this host's currency. ${SET_IT_BY_HAND}`, + ); + return; + } + + for (const organizationId of organizationIds) { + try { + const outcome = await seedOrganizationCurrency(settings, organizationId, locale); + if (outcome.kind === 'written') { + logger.info( + `[auth] dev seed: workspace ${organizationId} takes this host's currency, ${outcome.currency} ` + + `(from ${locale}; Setup β†’ Localization changes it)`, + ); + } + } catch (err: any) { + logger.warn( + `[auth] dev seed: workspace ${organizationId} was not given this host's currency: ` + + `${err?.message ?? err}. ${SET_IT_BY_HAND}`, + ); + } + } +} diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index 170aa12095d..1c2c9455a37 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -108,6 +108,15 @@ export const localizationSettingsManifest: SettingsManifest = { // rather than inheriting a guessed symbol (previously hard-defaulted to // 'USD', which surfaced an unwanted "$"/"US$" on every code-less amount). // A workspace can still pick a default to apply org-wide. + // [#25495] Where a new organization's value comes from: when an + // organization is created, plugin-auth writes this key as an ordinary + // `tenant` row for it, derived from the creator's language or region + // (their stored `sys_user.locale`, else the creating request's + // `Accept-Language`) through a CLDR region β†’ currency table; the dev + // seed does the same from the host's POSIX locale. Nothing derivable β‡’ + // nothing is written (there is no fallback to `default_country` or + // `locale`), and a value already set on any rung is never overwritten. + // `organization-default-currency.ts` in plugin-auth is the authority. description: 'ISO 4217 code applied when a currency field omits its own. Leave unset to render code-less amounts as plain numbers.', // As with `timezone`: the description promises ISO 4217, and since #5712 // the declaration delivers it β€” any ISO 4217 code is accepted, the From 3184ba4f7647a2de9a24c6dc072c40294cea2535 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:14:04 +0000 Subject: [PATCH 2/4] test(plugin-auth): pin a new organization's default currency through the real create route and the dev seed Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../dev-admin-seed-workspace-timezone.test.ts | 6 + ...anization-created-default-currency.test.ts | 337 ++++++++++++++++++ .../src/organization-default-currency.test.ts | 191 ++++++++++ 3 files changed, 534 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts create mode 100644 packages/plugins/plugin-auth/src/organization-default-currency.test.ts diff --git a/packages/plugins/plugin-auth/src/dev-admin-seed-workspace-timezone.test.ts b/packages/plugins/plugin-auth/src/dev-admin-seed-workspace-timezone.test.ts index 987684ad309..22d5439f704 100644 --- a/packages/plugins/plugin-auth/src/dev-admin-seed-workspace-timezone.test.ts +++ b/packages/plugins/plugin-auth/src/dev-admin-seed-workspace-timezone.test.ts @@ -69,6 +69,12 @@ const ENV_KEYS = [ 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL', 'OS_LOCALIZATION_TIMEZONE', + // [#25495] The same seed writes `localization.currency` from the host's + // POSIX locale; cleared so the "no localization write" cases below cannot + // pick up a currency write from the machine running them. + 'LC_ALL', + 'LC_MONETARY', + 'LANG', ] as const; /** Publishes an in-memory SQLite driver the way a datasource plugin does. */ diff --git a/packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts b/packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts new file mode 100644 index 00000000000..1f363ff5fe1 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25495] A new organization gets its default currency from its creator's + * language or region; with nothing to derive from it stays unset, and a value + * an administrator has set is never overwritten. The dev seed follows the same + * rule from the host's locale. + * + * ## What is read + * + * Setup β†’ Localization reads `GET /api/settings/localization`, which answers + * `SettingsService.getNamespace('localization', { tenantId })` for the + * session's active organization. Every case reads exactly that for the new + * organization: `values.currency` with its `source`. + * + * ## The composition + * + * A real `ObjectKernel`, a real ObjectQL engine on in-memory SQLite, the real + * `AuthPlugin` (which wraps `onOrganizationCreated`), the real `SecurityPlugin` + * and the real `SettingsServicePlugin`. Organizations are created through the + * real better-auth `POST /organization/create`, through + * `AuthManager.handleRequest`, under the `group` posture (which permits + * creating organizations without the enterprise runtime), so the creating + * request's `Accept-Language` reaches the callback the way it does in a + * deployment: through better-auth's endpoint context, not a test argument. + * + * ## Resolution + * + * `AuthPlugin` is imported from SOURCE; `@objectstack/service-settings` and + * `@objectstack/plugin-security` are aliased to their `src/` in this package's + * `vitest.config.ts`. `ObjectKernel`, `ObjectQLPlugin` and `SqlDriver` are the + * fixed instruments, read from `dist/` as the dev-seed time-zone test reads them. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { + InMemoryCryptoProvider, + SettingsService, + SettingsServicePlugin, +} from '@objectstack/service-settings'; +import { AuthPlugin } from './auth-plugin.js'; +import type { AuthManager } from './auth-manager.js'; + +const SYS = { isSystem: true } as const; +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long'; +const SEED_EMAIL = 'admin@objectos.ai'; + +/** Env the seed, the posture or the localization cascade reads; cleared per case. */ +const ENV_KEYS = [ + 'NODE_ENV', + 'OS_SEED_ADMIN', + 'OS_SEED_ADMIN_EMAIL', + 'OS_SEED_ADMIN_PASSWORD', + 'OS_SEED_ADMIN_NAME', + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + 'OS_PLATFORM_OWNER_EMAIL', + 'OS_LOCALIZATION_CURRENCY', + 'LC_ALL', + 'LC_MONETARY', + 'LANG', +] as const; + +/** Publishes an in-memory SQLite driver the way a datasource plugin does. */ +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +/** + * A fixture `org-scoping` provider, so the `group` posture is in force rather + * than degraded to `single` (which refuses `organization/create`). The open + * engine enforces `group`; the service's presence is what the tenancy + * service probes. Same fixture shape as plugin-security's + * `platform-global-no-organization-column.test.ts`. + */ +function orgScopingFixture(): Plugin { + return { + name: 'test.org-scoping-fixture', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + providesServices: ['org-scoping'], + async init(ctx: PluginContext) { + ctx.registerService('org-scoping', { name: 'test.org-scoping', supportedPostures: ['group', 'isolated'] }); + }, + } as Plugin; +} + +/** An app plugin that leaves the Default Organization a currency an administrator stored earlier. */ +function storedCurrencyAppPlugin(currency: string): Plugin { + return { + name: 'com.example.stored-currency', + type: 'app', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init() {}, + async start(ctx: PluginContext) { + const ql = ctx.getService('objectql'); + const org = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(org?.id, 'the Default Organization must exist before the seed for this case to mean anything').toBeTruthy(); + await ql.insert( + 'sys_setting', + { + namespace: 'localization', + key: 'currency', + scope: 'tenant', + organization_id: org!.id, + user_id: null, + value: currency, + encrypted: false, + locked: false, + }, + { context: SYS }, + ); + }, + }; +} + +const cookiesFrom = (res: Response): string => + (res.headers.getSetCookie?.() ?? [res.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]!.trim()) + .filter(Boolean) + .join('; '); + +describe('a new organization takes its creator\'s default currency (#25495)', () => { + let kernel: ObjectKernel | undefined; + const savedEnv: Partial> = {}; + + beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + vi.restoreAllMocks(); + }); + + async function boot(options: { authOptions?: Record; extra?: Plugin[] } = {}) { + const walled = process.env.OS_TENANCY_POSTURE === 'group'; + const settingsWrites = vi.spyOn(SettingsService.prototype, 'set'); + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + if (walled) await kernel.use(orgScopingFixture()); + await kernel.use(new AuthPlugin({ secret: SECRET, baseUrl: BASE, ...(options.authOptions ?? {}) } as never)); + await kernel.use(new SecurityPlugin()); + await kernel.use(new SettingsServicePlugin({ registerRoutes: false, cryptoProvider: new InMemoryCryptoProvider() })); + for (const p of options.extra ?? []) await kernel.use(p); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + const settings = kernel.getService('settings'); + const auth = kernel.getService('auth'); + + /** What Setup β†’ Localization reads for one organization. */ + const currencyOf = async (organizationId: string) => + (await settings.getNamespace('localization', { tenantId: organizationId })).values.currency; + + const currencyWrites = () => + settingsWrites.mock.calls.filter(([namespace, key]) => namespace === 'localization' && key === 'currency'); + + /** Sign the first user up (the bootstrap user, so no invitation is needed) and return the session cookie. */ + const signUp = async (email: string) => { + const res = await auth.handleRequest( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE }, + body: JSON.stringify({ email, password: 'S3cure!Passw0rd-25495', name: 'Founder' }), + }), + ); + expect(res.status, `sign-up failed: ${await res.clone().text()}`).toBeLessThan(400); + const user = await ql.findOne('sys_user', { where: { email }, context: SYS }); + return { cookie: cookiesFrom(res), userId: String(user!.id) }; + }; + + /** The console's "Create workspace" call, with the browser's Accept-Language when given. */ + const createOrganization = async (cookie: string, slug: string, acceptLanguage?: string) => { + const res = await auth.handleRequest( + new Request(`${AUTH}/organization/create`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: BASE, + cookie, + ...(acceptLanguage ? { 'accept-language': acceptLanguage } : {}), + }, + body: JSON.stringify({ name: `Org ${slug}`, slug }), + }), + ); + expect(res.status, `organization/create failed: ${await res.clone().text()}`).toBeLessThan(400); + const body = (await res.json()) as { id?: string }; + expect(body.id, 'organization/create must return the new organization').toBeTruthy(); + return String(body.id); + }; + + const defaultOrganization = async (): Promise => { + const org = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(org?.id, 'the single posture boots with a Default Organization').toBeTruthy(); + return String(org!.id); + }; + + return { ql, currencyOf, currencyWrites, signUp, createOrganization, defaultOrganization }; + } + + describe('through POST /organization/create', () => { + beforeEach(() => { + process.env.OS_TENANCY_POSTURE = 'group'; + // A walled posture refuses to boot without a declared platform owner. + process.env.OS_PLATFORM_OWNER_EMAIL = 'owner@example.com'; + }); + + it("1. a zh-CN creator's new organization stores localization.currency = 'CNY', as a value Setup β†’ Localization shows", async () => { + const { currencyOf, currencyWrites, signUp, createOrganization } = await boot(); + const { cookie } = await signUp('founder-cn@example.com'); + const orgId = await createOrganization(cookie, 'acme-cn', 'zh-CN,zh;q=0.9,en;q=0.8'); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'CNY', source: 'tenant' }); + expect(currencyWrites()).toEqual([['localization', 'currency', 'CNY', { tenantId: orgId }]]); + }, 30_000); + + it('2. nothing to derive from (no stored language, no Accept-Language): nothing is stored', async () => { + const { currencyOf, currencyWrites, signUp, createOrganization } = await boot(); + const { cookie } = await signUp('founder-none@example.com'); + const orgId = await createOrganization(cookie, 'acme-none'); + + const resolved = await currencyOf(orgId); + expect(resolved).toMatchObject({ source: 'default' }); + expect(resolved?.value ?? null).toBeNull(); + expect(currencyWrites()).toEqual([]); + }, 30_000); + + it('3. a language with no country (es-419) is nothing to derive from: nothing is stored', async () => { + const { currencyOf, currencyWrites, signUp, createOrganization } = await boot(); + const { cookie } = await signUp('founder-latam@example.com'); + const orgId = await createOrganization(cookie, 'acme-latam', 'es-419,es;q=0.9'); + + expect(await currencyOf(orgId)).toMatchObject({ source: 'default' }); + expect(currencyWrites()).toEqual([]); + }, 30_000); + + it("4. the creator's stored sys_user.locale outranks the request: ja-JP stored, zh-CN browser β‡’ JPY", async () => { + const { ql, currencyOf, signUp, createOrganization } = await boot(); + const { cookie, userId } = await signUp('founder-jp@example.com'); + await ql.update('sys_user', { id: userId, locale: 'ja-JP' }, { context: SYS }); + const orgId = await createOrganization(cookie, 'acme-jp', 'zh-CN'); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'JPY', source: 'tenant' }); + }, 30_000); + + it('5. never over an administrator\'s value: a deployment-wide OS_LOCALIZATION_CURRENCY is kept, nothing is written', async () => { + process.env.OS_LOCALIZATION_CURRENCY = 'EUR'; + const { currencyOf, currencyWrites, signUp, createOrganization } = await boot(); + const { cookie } = await signUp('founder-env@example.com'); + const orgId = await createOrganization(cookie, 'acme-env', 'zh-CN'); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'EUR', source: 'env' }); + expect(currencyWrites()).toEqual([]); + }, 30_000); + + it("6. a host's own onOrganizationCreated still runs first, and a currency it sets is kept", async () => { + let settingsRef: SettingsService | undefined; + const host = vi.fn(async ({ organizationId }: { organizationId: string }) => { + await settingsRef!.set('localization', 'currency', 'GBP', { tenantId: organizationId }); + }); + const { currencyOf, currencyWrites, signUp, createOrganization } = await boot({ + authOptions: { onOrganizationCreated: host }, + }); + settingsRef = kernel!.getService('settings'); + const { cookie, userId } = await signUp('founder-host@example.com'); + const orgId = await createOrganization(cookie, 'acme-host', 'zh-CN'); + + expect(host).toHaveBeenCalledWith(expect.objectContaining({ organizationId: orgId, userId })); + expect(await currencyOf(orgId)).toMatchObject({ value: 'GBP', source: 'tenant' }); + expect(currencyWrites().map(([, , value]) => value)).toEqual(['GBP']); + }, 30_000); + }); + + describe('the dev seed, from the host locale', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + it("7. a zh_CN host: the seeded admin's workspace stores CNY", async () => { + process.env.LANG = 'zh_CN.UTF-8'; + const { currencyOf, defaultOrganization, currencyWrites } = await boot(); + const orgId = await defaultOrganization(); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'CNY', source: 'tenant' }); + expect(currencyWrites()).toEqual([['localization', 'currency', 'CNY', { tenantId: orgId }]]); + }, 30_000); + + it('8. a host that sets no locale (or C): nothing is stored β€” never the en-US Intl reports for it', async () => { + process.env.LANG = 'C.UTF-8'; + const { currencyOf, defaultOrganization, currencyWrites, ql } = await boot(); + const admin = await ql.findOne('sys_user', { where: { email: SEED_EMAIL }, context: SYS }); + expect(admin?.id, 'the dev seed must have run for this case to mean anything').toBeTruthy(); + + expect(await currencyOf(await defaultOrganization())).toMatchObject({ source: 'default' }); + expect(currencyWrites()).toEqual([]); + }, 30_000); + + it('9. never over a set currency: a workspace that already has one keeps it', async () => { + process.env.LANG = 'zh_CN.UTF-8'; + const { currencyOf, defaultOrganization, currencyWrites } = await boot({ extra: [storedCurrencyAppPlugin('USD')] }); + + expect(await currencyOf(await defaultOrganization())).toMatchObject({ value: 'USD', source: 'tenant' }); + expect(currencyWrites()).toEqual([]); + }, 30_000); + }); +}); diff --git a/packages/plugins/plugin-auth/src/organization-default-currency.test.ts b/packages/plugins/plugin-auth/src/organization-default-currency.test.ts new file mode 100644 index 00000000000..7ca0ff19199 --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-default-currency.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25495] The derivation and composition halves of a new organization's + * default currency, in isolation: the CLDR table, the locale β†’ currency + * mapping, the host-locale reading, the never-overwrite rule and the wrapping + * of a host's `onOrganizationCreated`. The composed behaviour, through a real + * `POST /organization/create` and the real settings service, is + * `organization-created-default-currency.test.ts`. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { + REGION_CURRENCY, + creatorLocaleForNewOrganization, + currencyForLocale, + hostLocaleFromEnv, + seedOrganizationCurrency, + withOrganizationDefaultCurrency, + type CurrencySettingsDoor, +} from './organization-default-currency.js'; + +/** A settings door whose `localization.currency` resolves from `source` until something is written. */ +function door(source: string) { + const writes: Array<[string, string, unknown, Record | undefined]> = []; + const settings: CurrencySettingsDoor = { + get: vi.fn(async () => ({ source })), + set: vi.fn(async (namespace: string, key: string, value: unknown, ctx?: Record) => { + writes.push([namespace, key, value, ctx]); + return undefined; + }), + }; + return { settings, writes }; +} + +const silent = { info: () => {}, warn: () => {}, debug: () => {} }; + +describe('REGION_CURRENCY β€” the checked-in CLDR snapshot', () => { + it('holds only codes the settings key accepts (the iso_4217_currency value domain)', () => { + const outside = Object.entries(REGION_CURRENCY).filter(([, code]) => !isValueDomainMember('iso_4217_currency', code)); + expect(outside).toEqual([]); + }); + + it('carries the regions the card names, and none of the CLDR regions with no current tender', () => { + expect(REGION_CURRENCY).toMatchObject({ CN: 'CNY', JP: 'JPY', GB: 'GBP', DE: 'EUR', FR: 'EUR', US: 'USD' }); + for (const region of ['AQ', 'ZZ', 'SU', 'YU']) expect(REGION_CURRENCY).not.toHaveProperty(region); + expect(Object.keys(REGION_CURRENCY)).toHaveLength(255); + }); +}); + +describe('currencyForLocale', () => { + it.each([ + ['zh-CN', 'CNY'], + ['zh', 'CNY'], + ['ja', 'JPY'], + ['ja-JP', 'JPY'], + ['en-GB', 'GBP'], + ['de', 'EUR'], + ['fr-FR', 'EUR'], + ['en', 'USD'], + ['zh-Hant', 'TWD'], + [' zh-CN ', 'CNY'], + ])('%s β†’ %s', (tag, currency) => { + expect(currencyForLocale(tag)).toBe(currency); + }); + + it.each([ + ['a region that is not a country', 'es-419'], + ['another region that is not a country', 'en-150'], + ['a malformed tag', 'not a tag!'], + ['an empty string', ''], + ['undefined', undefined], + ['a non-string', 42], + ])('%s maps to nothing', (_label, tag) => { + expect(currencyForLocale(tag)).toBeUndefined(); + }); +}); + +describe('hostLocaleFromEnv', () => { + it('reads LANG the POSIX way, dropping the codeset and modifier', () => { + expect(hostLocaleFromEnv({ LANG: 'zh_CN.UTF-8' })).toBe('zh-CN'); + expect(hostLocaleFromEnv({ LANG: 'de_DE@euro' })).toBe('de-DE'); + }); + + it('LC_ALL outranks LC_MONETARY, which outranks LANG', () => { + expect(hostLocaleFromEnv({ LC_ALL: 'ja_JP.UTF-8', LC_MONETARY: 'en_GB.UTF-8', LANG: 'zh_CN.UTF-8' })).toBe('ja-JP'); + expect(hostLocaleFromEnv({ LC_MONETARY: 'en_GB.UTF-8', LANG: 'zh_CN.UTF-8' })).toBe('en-GB'); + expect(hostLocaleFromEnv({ LC_ALL: '', LANG: 'zh_CN.UTF-8' })).toBe('zh-CN'); + }); + + it('C, POSIX and an unset environment name no language β€” never the en-US Intl answers for them', () => { + expect(hostLocaleFromEnv({})).toBeUndefined(); + expect(hostLocaleFromEnv({ LANG: 'C' })).toBeUndefined(); + expect(hostLocaleFromEnv({ LANG: 'C.UTF-8' })).toBeUndefined(); + expect(hostLocaleFromEnv({ LC_ALL: 'POSIX', LANG: 'zh_CN.UTF-8' })).toBeUndefined(); + }); +}); + +describe('seedOrganizationCurrency', () => { + it('writes the derived currency as the organization\'s tenant value while the key resolves from the default', async () => { + const { settings, writes } = door('default'); + await expect(seedOrganizationCurrency(settings, 'org_1', 'zh-CN')).resolves.toEqual({ kind: 'written', currency: 'CNY' }); + expect(writes).toEqual([['localization', 'currency', 'CNY', { tenantId: 'org_1' }]]); + }); + + it.each(['tenant', 'global', 'env'])('never over a value on the %s rung', async (source) => { + const { settings, writes } = door(source); + await expect(seedOrganizationCurrency(settings, 'org_1', 'zh-CN')).resolves.toEqual({ kind: 'kept', source }); + expect(writes).toEqual([]); + }); + + it('nothing to derive from: no read, no write', async () => { + const { settings, writes } = door('default'); + await expect(seedOrganizationCurrency(settings, 'org_1', undefined)).resolves.toEqual({ kind: 'underivable' }); + await expect(seedOrganizationCurrency(settings, 'org_1', 'es-419')).resolves.toEqual({ kind: 'underivable' }); + expect(settings.get).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + }); +}); + +describe('creatorLocaleForNewOrganization', () => { + const engineWith = (row: Record | undefined) => + ({ find: vi.fn(async () => (row ? [row] : [])) }) as any; + + it('the stored sys_user.locale outranks the request', async () => { + await expect( + creatorLocaleForNewOrganization(engineWith({ id: 'u1', locale: 'ja-JP' }), 'u1', 'zh-CN,zh;q=0.9'), + ).resolves.toEqual({ locale: 'ja-JP', from: "the creator's stored language" }); + }); + + it('no stored locale: the request\'s preferred Accept-Language tag', async () => { + await expect( + creatorLocaleForNewOrganization(engineWith({ id: 'u1', locale: null }), 'u1', 'zh-CN,zh;q=0.9,en;q=0.8'), + ).resolves.toEqual({ locale: 'zh-CN', from: "the creating request's Accept-Language" }); + }); + + it('neither: undefined, and `*` is no preference', async () => { + await expect(creatorLocaleForNewOrganization(engineWith({ id: 'u1' }), 'u1', undefined)).resolves.toBeUndefined(); + await expect(creatorLocaleForNewOrganization(engineWith(undefined), 'u1', '*')).resolves.toBeUndefined(); + }); +}); + +describe('withOrganizationDefaultCurrency', () => { + it('runs the host callback first, so a currency the host sets is kept', async () => { + const order: string[] = []; + let hostWrote = false; + const settings: CurrencySettingsDoor = { + get: vi.fn(async () => { + order.push('currency'); + return { source: hostWrote ? 'tenant' : 'default' }; + }), + set: vi.fn(async () => undefined), + }; + const host = vi.fn(async () => { + order.push('host'); + hostWrote = true; + }); + const engine = { find: vi.fn(async () => [{ id: 'u1', locale: 'zh-CN' }]) } as any; + const wrapped = withOrganizationDefaultCurrency(host, { dataEngine: engine, getSettings: () => settings, logger: silent }); + + await wrapped({ organizationId: 'org_1', userId: 'u1' }); + expect(order).toEqual(['host', 'currency']); + expect(host).toHaveBeenCalledWith({ organizationId: 'org_1', userId: 'u1' }); + expect(settings.set).not.toHaveBeenCalled(); + }); + + it('a host callback that throws still throws, after the currency step ran', async () => { + const { settings, writes } = door('default'); + const engine = { find: vi.fn(async () => [{ id: 'u1', locale: 'zh-CN' }]) } as any; + const failure = new Error('provisioning down'); + const wrapped = withOrganizationDefaultCurrency( + async () => { + throw failure; + }, + { dataEngine: engine, getSettings: () => settings, logger: silent }, + ); + await expect(wrapped({ organizationId: 'org_1', userId: 'u1' })).rejects.toBe(failure); + expect(writes).toEqual([['localization', 'currency', 'CNY', { tenantId: 'org_1' }]]); + }); + + it('a creator whose row cannot be read gets nothing written, and the miss is logged once at warn', async () => { + const { settings, writes } = door('default'); + const engine = { find: vi.fn(async () => { throw new Error('db down'); }) } as any; + const logger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const wrapped = withOrganizationDefaultCurrency(undefined, { dataEngine: engine, getSettings: () => settings, logger }); + await expect(wrapped({ organizationId: 'org_1', userId: 'u1' })).resolves.toBeUndefined(); + expect(writes).toEqual([]); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); From 907b7ef76c380804e3bdc929e210ad4d9d030154 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:23:41 +0000 Subject: [PATCH 3/4] chore(changeset): plugin-auth patch for a new organization's default currency Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .changeset/25495-org-default-currency.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/25495-org-default-currency.md diff --git a/.changeset/25495-org-default-currency.md b/.changeset/25495-org-default-currency.md new file mode 100644 index 00000000000..27e24af2ecb --- /dev/null +++ b/.changeset/25495-org-default-currency.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Clause-β‘‘: no +Metadata-protocol: none + +A new organization now gets its default currency from its creator's language or region. When an organization is created through `POST /api/v1/auth/organization/create`, the server reads the creator's stored language (`sys_user.locale`), or else the creating request's `Accept-Language`. It writes that region's ISO 4217 currency as the organization's `localization.currency`: `zh-CN` gives `CNY`, `ja` gives `JPY`, `en-GB` gives `GBP`. The value is an ordinary tenant setting, so Setup β†’ Localization shows it and can change it. Amounts in `currencyMode: 'dynamic'` fields then show that currency instead of a plain number. + +When there is nothing to derive from (no stored language, no `Accept-Language`, or a tag with no country such as `es-419`), nothing is written and the setting stays unset, as before. There is no fallback to the `default_country` or `locale` platform defaults. A value already set on any rung (the organization's own, a global row, or an `OS_LOCALIZATION_CURRENCY` override) is never overwritten. A host's own `onOrganizationCreated` callback still runs, before this step. + +The `os dev` seed does the same for its admin's workspace from the host's POSIX locale (`LC_ALL`, then `LC_MONETARY`, then `LANG`), beside its time-zone seeding. `C`, `POSIX`, and a host with no locale set write nothing. + +The region-to-currency table is a checked-in snapshot of CLDR 48 `currencyData`. From 40b9af0e491b4618d7c6b30f1ebe187781d69846 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:32:29 +0000 Subject: [PATCH 4/4] refactor(plugin-auth): read the creating request's Accept-Language through auth-manager's one reader Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 6 +++++- .../src/organization-default-currency.ts | 17 ++--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 9acae45c5b2..86261acedec 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1320,8 +1320,12 @@ export function authEmailLocaleFromRequest(source: unknown): string | undefined * context, or anything carrying either. Never throws: a vendor changing the * shape it hands a callback must degrade to the deployment default, never fail * the send. + * + * Exported for `organization-default-currency.ts`, which reads the creating + * request's language off the endpoint context the same way (#25495): one + * reader, not two. */ -function acceptLanguageHeader(source: unknown): string | undefined { +export function acceptLanguageHeader(source: unknown): string | undefined { if (!source || typeof source !== 'object') return undefined; const nested = (source as { request?: unknown }).request; const bags = [ diff --git a/packages/plugins/plugin-auth/src/organization-default-currency.ts b/packages/plugins/plugin-auth/src/organization-default-currency.ts index 2033a70a304..3bf15663832 100644 --- a/packages/plugins/plugin-auth/src/organization-default-currency.ts +++ b/packages/plugins/plugin-auth/src/organization-default-currency.ts @@ -49,6 +49,7 @@ import { tryGetCurrentAuthEndpointContext } from '@better-auth/core/context'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { preferredLocaleFromHeader, SystemObjectName } from '@objectstack/spec/system'; +import { acceptLanguageHeader } from './auth-manager.js'; /** * Region (ISO 3166-1 alpha-2 and the CLDR territory codes beside it) β†’ the @@ -204,25 +205,11 @@ export type CreatorLocaleSource = 'the creator\'s stored language' | 'the creati /** The `accept-language` value on the better-auth endpoint context this call runs inside, if any. */ function acceptLanguageOfCurrentRequest(): string | undefined { - let endpoint: unknown; try { - endpoint = tryGetCurrentAuthEndpointContext(); + return acceptLanguageHeader(tryGetCurrentAuthEndpointContext()); } catch { return undefined; } - if (!endpoint || typeof endpoint !== 'object') return undefined; - const request = (endpoint as { request?: { headers?: unknown } }).request; - for (const bag of [(endpoint as { headers?: unknown }).headers, request?.headers]) { - const get = (bag as { get?: unknown } | undefined)?.get; - if (typeof get !== 'function') continue; - try { - const value = (bag as Headers).get('accept-language'); - if (typeof value === 'string' && value) return value; - } catch { - // A header bag that throws on read leaves the request's language unknown. - } - } - return undefined; } /**