Skip to content

Commit e8de8a7

Browse files
feat(plugin-auth): a new organization takes its creator's default currency (#25495) (#25621)
Fixes #25495 Clause-②: no Metadata-protocol: none ## What this does A new organization now gets `localization.currency` from its creator's language or region. The maintainer's ruling, as the card quotes it: 「#8345:环境的默认币种,应该在objectstack 仓库吧」「同意乙」. - **Where the value comes from.** First the creator's stored `sys_user.locale`. If that is not set, the creating request's `Accept-Language`, read with the platform's one parser (`preferredLocaleFromHeader`). This is the same order `GET /auth/me/localization` uses for the user's language, minus its third rung (the deployment default). - **The mapping.** The tag's region. A bare language uses the region `Intl.Locale#maximize()` gives it (`zh` maps to CN, `ja` to JP). That region's ISO 4217 currency comes from a checked-in CLDR 48 `currencyData` snapshot (`REGION_CURRENCY`). The provenance block names the package, the file's sha256 and the selection rule. `Intl` has no region-to-currency lookup and no installed dependency carries the table, so nothing was added to the dependency tree. - **Nothing to derive from means nothing is written.** That covers no stored language, no header, a tag with no country (`es-419`), or a malformed tag. There is no fallback to `default_country: 'US'` or `locale: 'en-US'`. - **Never over a set value.** The write happens only while the key still resolves from the default (`source: 'default'`). It is the settings service's own `set`, one `tenant` row, the same row `PUT /api/settings/localization` writes. Setup → Localization shows it and can change it. - **The dev seed** does the same for its admin's workspace, beside the existing time-zone seeding. It reads the host's POSIX locale (`LC_ALL`, then `LC_MONETARY`, then `LANG`). It does not use `Intl`'s default locale, because that answers `en-US` on a host with no locale set. This container is one: `LANG` unset, and `Intl.DateTimeFormat().resolvedOptions().locale` answers `en-US`. - **The manifest comment** on the `currency` key now says where a new organization's value comes from. ## The seam, measured (dispatch assumptions A1–A3) - **A1, holds.** `AuthManagerOptions.onOrganizationCreated` exists, and `afterCreateOrganization` calls it with `{ organizationId, userId, name, slug }`. No production code supplied it. AuthPlugin now wraps it after `auth:configure`, so a host's own callback still runs, and runs first. Which of the two sources the seam can see: **both**. - The stored locale: through `userId`, read from `sys_user` as a system read. - The request's `Accept-Language`: better-auth runs `afterCreateOrganization` inside its endpoint-context `AsyncLocalStorage`, so `tryGetCurrentAuthEndpointContext()` carries the headers. The same accessor `auth-manager-database-secret.ts` already uses. Case 1 below proves it end to end: no stored locale, only the header, and `CNY` lands. - **A2, holds.** It is an ordinary tenant-scoped `SettingsService.set`. The tests read `getNamespace('localization', { tenantId })`, which is what the Setup → Localization route returns. They see `{ value: 'CNY', source: 'tenant' }`. - **A3, NOT MEASURED.** `objectstack-ai/cloud` is not reachable from this session: `add_repo` refused it with "you don't have access". In-repo evidence: `onOrganizationCreated`'s docblock says the cloud control plane supplies it to provision an organization's born-with environment. So cloud's organization creation goes through better-auth `createOrganization`, and the host callback is preserved by the wrapping. Whether a cloud environment runtime's first organization is created through this seam, and not by provisioning, is still unknown. Triage owns that read. The `organization` plugin factory was not edited. PR #25594 landed on `main` during this run. This branch merged it, and `auth-manager.ts` and `auth-manager-organization-plugin.ts` are untouched by this diff. ## Tests All results are at head `8bce0422cd`; the reader consolidation `40b9af0e49` re-ran the package suite (3667 passed | 10 skipped) and the four targeted files (115/115). They follow the build closure `pnpm turbo run build --filter='@objectstack/plugin-auth...'` (29/29 tasks). - `organization-created-default-currency.test.ts`, 9 cases. It boots a real `ObjectKernel` with ObjectQL on SQLite and the real `AuthPlugin`, `SecurityPlugin` and `SettingsServicePlugin`. Organizations are created through the real `POST /organization/create` under the `group` posture, with a fixture `org-scoping` provider. - 1: a zh-CN request gives `CNY` (`source: 'tenant'`). - 2 and 3: no derivation gives nothing stored and zero currency writes. - 4: stored `ja-JP` outranks a zh-CN header and gives `JPY`. - 5: `OS_LOCALIZATION_CURRENCY=EUR` is kept, with no write. - 6: a host callback runs first and its `GBP` is kept. - 7: the dev seed with `LANG=zh_CN.UTF-8` gives `CNY`. - 8: the dev seed with `LANG=C.UTF-8` stores nothing. - 9: the dev seed leaves an existing tenant value alone. - `organization-default-currency.test.ts`, 32 cases. The table is all in the `iso_4217_currency` domain. It also covers the mapping, the POSIX env reading, the never-overwrite rule, creator-locale precedence and wrapping order. - `dev-admin-seed-workspace-timezone.test.ts`: `LC_ALL` / `LC_MONETARY` / `LANG` added to its cleared env, so its "no localization write" cases cannot pick up the new currency write from the machine running them. - `pnpm --filter @objectstack/plugin-auth test`: Test Files 193 passed (193), Tests 3667 passed and 10 skipped (3677). `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (`check:test-typecheck: OK`). - **Ablation**, one run per leg, through `scripts/ablation-replace.mjs` with a trap restore. The tests import `AuthPlugin` from source, so no rebuild is involved. - Leg A: the request's `Accept-Language` is never read (anchor x1 to x0, blob `2033a70a` to `56a7862a`). Case 1 goes red: 1 failed, 8 passed. - Leg B: the `source !== 'default'` guard is removed (blob to `6fd0c02e`). Cases 5, 6 and 9 go red: 3 failed, 6 passed. - Restore: blob equals HEAD `2033a70a`, and `git diff HEAD` is empty after each leg. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `40b9af0e49` (and earlier at `8bce0422cd`) derived 74 commands. All 74 ran with exit 0. The `--ran` reconciliation answers: "74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3)". - `eslint --no-inline-config` on the six touched files: exit 0. - The repo-wide farm runs in CI on this PR. ## Console check **Measured** (the dev's addendum on #25495, comment 6383481471; recorded here by the seat). Full workspace build (`pnpm build`, 76/76), and the console at the pinned objectui SHA `2958f17e9c6a` (`pnpm objectui:build`). A fresh showcase stack: `OS_TENANCY_POSTURE=group OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai OS_AUTH_MEMBERSHIP_POLICY=invite-only pnpm dev -- --fresh -p 38425`, with the host `LANG` unset. Playwright Chromium, locale `zh-CN`, signed in as the dev admin: - `POST /api/v1/auth/organization/create` with `Accept-Language: zh-CN,zh;q=0.9` created 上海分公司. `GET /api/settings/localization` there answers `currency {value: CNY, source: tenant}`; the Default Organization (no creator) answers `{value: null, source: default}`. - In 上海分公司, `/_console/apps/com.example.showcase/showcase_product`: a new product's `unit_price` (dynamic mode, no code) renders `¥1,234.50`, and every seeded price renders with `¥`. The same list in the Default Organization renders plain `29.99`, `99.00` …. - Setup → Localization (`/_console/apps/setup/system/settings/localization`) shows 默认货币 = CNY with the 租户 (tenant) source badge. ## Acceptance notes - **The `single` posture's Default Organization gets no currency.** It is created at boot by `ensureDefaultOrganizationExists`, which has no creator. The first admin is bound to it later and never passes through `organization/create`. So on the most common self-host path (`os serve`, first sign-up), the organization still has no currency. Only the dev seed fills it. That is outside the seam this card names. Carrier: none. Raised for triage as a product question: should the Default Organization take its first owner's currency when the owner is bound? - `service-settings` changes only a code comment, so it has no changeset entry. `plugin-auth` takes `patch`. - CLDR 48 maps `BG` to `EUR` (from 2026-01-01). The snapshot records that as CLDR has it. - Precedence follows the card: a stored `sys_user.locale` of `en` gives `USD`, even when the browser sends `zh-CN`. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42d9fe8 commit e8de8a7

8 files changed

Lines changed: 983 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
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.
9+
10+
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.
11+
12+
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.
13+
14+
The region-to-currency table is a checked-in snapshot of CLDR 48 `currencyData`.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,8 +1320,12 @@ export function authEmailLocaleFromRequest(source: unknown): string | undefined
13201320
* context, or anything carrying either. Never throws: a vendor changing the
13211321
* shape it hands a callback must degrade to the deployment default, never fail
13221322
* the send.
1323+
*
1324+
* Exported for `organization-default-currency.ts`, which reads the creating
1325+
* request's language off the endpoint context the same way (#25495): one
1326+
* reader, not two.
13231327
*/
1324-
function acceptLanguageHeader(source: unknown): string | undefined {
1328+
export function acceptLanguageHeader(source: unknown): string | undefined {
13251329
if (!source || typeof source !== 'object') return undefined;
13261330
const nested = (source as { request?: unknown }).request;
13271331
const bags = [

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runReq
7575
import { runResendVerificationEmail } from './send-verification-email.js';
7676
import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js';
7777
import { readJsonRequestBody } from './request-json-body.js';
78+
import {
79+
seedDevWorkspaceCurrency,
80+
withOrganizationDefaultCurrency,
81+
type CurrencySettingsDoor,
82+
} from './organization-default-currency.js';
7883
import type { CounterStore } from './rate-limit-storage.js';
7984
import { authIdentityManifest } from './manifest.js';
8085
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
@@ -680,6 +685,26 @@ export class AuthPlugin implements Plugin {
680685
}
681686
};
682687

688+
// [#25495] A new organization takes its creator's default currency
689+
// (`localization.currency`), derived from their stored language or the
690+
// creating request's `Accept-Language`; nothing derivable ⇒ nothing
691+
// written, a value already set ⇒ left alone. Wrapped AFTER
692+
// `auth:configure` so a host's own `onOrganizationCreated`, from the
693+
// plugin options or from a configure handler, still runs, and runs first.
694+
// The settings service is resolved when an organization is created, not
695+
// here: it starts after this plugin.
696+
authConfig.onOrganizationCreated = withOrganizationDefaultCurrency(authConfig.onOrganizationCreated, {
697+
dataEngine,
698+
getSettings: () => {
699+
try {
700+
return ctx.getService<CurrencySettingsDoor>('settings');
701+
} catch {
702+
return undefined;
703+
}
704+
},
705+
logger: ctx.logger,
706+
});
707+
683708
// Initialize auth manager with data engine
684709
this.authManager = new AuthManager(authConfig);
685710
// ADR-0092 D6 — remember the storage better-auth will actually use so the
@@ -2487,6 +2512,17 @@ export class AuthPlugin implements Plugin {
24872512
// creator's — see the method. Only on the seeding boot: a later boot
24882513
// takes the database as it finds it.
24892514
if (seededId) await this.seedDevWorkspaceTimezone(ctx, ql, seededId);
2515+
// [#25495] And with the host's currency, by the rule a new
2516+
// organization's creator gets (`organization-default-currency.ts`).
2517+
if (seededId) {
2518+
let settings: CurrencySettingsDoor | undefined;
2519+
try {
2520+
settings = ctx.getService<CurrencySettingsDoor>('settings');
2521+
} catch {
2522+
settings = undefined;
2523+
}
2524+
await seedDevWorkspaceCurrency({ settings, ql, userId: seededId, logger: ctx.logger });
2525+
}
24902526
ctx.logger.info(`🔑 Dev admin seeded: ${email} / ${password}`);
24912527
// Surface the credentials in the `serve` startup banner. The
24922528
// ctx.logger line above is swallowed by serve's boot-quiet window

‎packages/plugins/plugin-auth/src/dev-admin-seed-workspace-timezone.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ const ENV_KEYS = [
6969
'OS_MULTI_ORG_ENABLED',
7070
'OS_PLATFORM_OWNER_EMAIL',
7171
'OS_LOCALIZATION_TIMEZONE',
72+
// [#25495] The same seed writes `localization.currency` from the host's
73+
// POSIX locale; cleared so the "no localization write" cases below cannot
74+
// pick up a currency write from the machine running them.
75+
'LC_ALL',
76+
'LC_MONETARY',
77+
'LANG',
7278
] as const;
7379

7480
/** Publishes an in-memory SQLite driver the way a datasource plugin does. */

0 commit comments

Comments
 (0)