From 8ceb712f7415fce939d234080b47ab5687da8dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:36:56 +0000 Subject: [PATCH 1/2] feat(plugin-auth): the single posture's Default Organization takes its first owner's default currency Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 28 ++++++++++ .../src/ensure-default-organization.ts | 19 ++++++- ...anization-created-default-currency.test.ts | 51 ++++++++++++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 05f89542b48..8c554309313 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -76,6 +76,7 @@ import { runResendVerificationEmail } from './send-verification-email.js'; import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js'; import { readJsonRequestBody } from './request-json-body.js'; import { + seedCreatedOrganizationCurrency, seedDevWorkspaceCurrency, withOrganizationDefaultCurrency, type CurrencySettingsDoor, @@ -1422,6 +1423,33 @@ export class AuthPlugin implements Plugin { // own chain, which never rejects. void runBackfillOnDefaultOrg?.('default-org-created'); } + // [#25651] The Default Organization is created at boot with no + // creator, so it never reaches `onOrganizationCreated` (#25495). Its + // first owner stands in for the creator: when the owner is first + // bound (this call bound or promoted them, which the once-gate admits + // once), the organization takes that owner's default currency by the + // same rule and the same write — their stored `sys_user.locale`, else + // the binding request's `Accept-Language`. On a first sign-up this + // call runs inside that sign-up's request (the `sys_user` / + // `sys_user_permission_set` write middleware), so its headers are + // visible. Nothing derivable ⇒ nothing written; a value already set + // is kept. Never throws. + if ((res.memberCreated || res.ownerPromoted === true) && res.defaultOrgId && res.ownerUserId) { + await seedCreatedOrganizationCurrency( + { organizationId: res.defaultOrgId, userId: res.ownerUserId }, + { + dataEngine: ql, + getSettings: () => { + try { + return ctx.getService('settings'); + } catch { + return undefined; + } + }, + logger: ctx.logger, + }, + ); + } } catch (e) { ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', { error: (e as Error).message, diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index b7106451445..2d29798775a 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -330,6 +330,14 @@ export interface EnsureDefaultOrganizationResult { | 'owner_bind_decided'; /** Count of the default org's seeded rows re-owned to the platform admin. */ ownershipClaimed?: number; + /** + * The user this call made the Default Organization's owner — set exactly when + * `memberCreated` or `ownerPromoted` is. The single posture's wiring gives + * that organization its owner's default currency at this moment + * (`organization-default-currency.ts`), the same rule a created + * organization's creator gets. + */ + ownerUserId?: string; } /** @@ -414,7 +422,14 @@ async function promoteReconciledMemberToOwner( logger?.warn?.('[default-org] seed ownership handoff failed', { error: (e as Error).message }); } } - return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, ownerPromoted: true, ownershipClaimed }; + return { + defaultOrgCreated: false, + defaultOrgId, + memberCreated: false, + ownerPromoted: true, + ownershipClaimed, + ownerUserId: adminUserId, + }; } /** @@ -647,5 +662,5 @@ export async function ensureDefaultOrganization( } } - return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed }; + return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed, ownerUserId: adminUserId }; } 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 index 1f363ff5fe1..3fb82b86a4b 100644 --- a/packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts +++ b/packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts @@ -187,11 +187,15 @@ describe('a new organization takes its creator\'s default currency (#25495)', () 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 signUp = async (email: string, acceptLanguage?: string) => { const res = await auth.handleRequest( new Request(`${AUTH}/sign-up/email`, { method: 'POST', - headers: { 'content-type': 'application/json', origin: BASE }, + headers: { + 'content-type': 'application/json', + origin: BASE, + ...(acceptLanguage ? { 'accept-language': acceptLanguage } : {}), + }, body: JSON.stringify({ email, password: 'S3cure!Passw0rd-25495', name: 'Founder' }), }), ); @@ -302,6 +306,49 @@ describe('a new organization takes its creator\'s default currency (#25495)', () }, 30_000); }); + describe("the single posture's Default Organization, from its first owner (#25651)", () => { + // No posture variable: the `single` posture, whose Default Organization is + // created at boot with no creator. The first sign-up becomes its owner. + + it("10. a zh-CN first sign-up leaves the Default Organization with localization.currency = 'CNY'", async () => { + const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot(); + const orgId = await defaultOrganization(); + expect(await currencyOf(orgId), 'no currency before the owner is bound').toMatchObject({ source: 'default' }); + const { userId } = await signUp('first-cn@example.com', 'zh-CN,zh;q=0.9,en;q=0.8'); + const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS }); + expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'CNY', source: 'tenant' }); + expect(currencyWrites()).toEqual([['localization', 'currency', 'CNY', { tenantId: orgId }]]); + }, 30_000); + + it('11. a first sign-up with nothing to derive from (no stored language, no Accept-Language): nothing is stored', async () => { + const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot(); + const orgId = await defaultOrganization(); + const { userId } = await signUp('first-none@example.com'); + const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS }); + expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId); + + const resolved = await currencyOf(orgId); + expect(resolved).toMatchObject({ source: 'default' }); + expect(resolved?.value ?? null).toBeNull(); + expect(currencyWrites()).toEqual([]); + }, 30_000); + + it("12. never over an administrator's value: a currency already stored on the Default Organization is kept", async () => { + const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot({ + extra: [storedCurrencyAppPlugin('USD')], + }); + const orgId = await defaultOrganization(); + const { userId } = await signUp('first-admin-set@example.com', 'zh-CN'); + const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS }); + expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId); + + expect(await currencyOf(orgId)).toMatchObject({ value: 'USD', source: 'tenant' }); + expect(currencyWrites()).toEqual([]); + }, 30_000); + }); + describe('the dev seed, from the host locale', () => { beforeEach(() => { process.env.NODE_ENV = 'development'; From 1ce6154c182a70cf6d12dec7748b0a58bf331e88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:46:17 +0000 Subject: [PATCH 2/2] chore: changeset for the Default Organization owner currency Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB Co-authored-by: Claude --- .changeset/default-org-owner-currency.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/default-org-owner-currency.md diff --git a/.changeset/default-org-owner-currency.md b/.changeset/default-org-owner-currency.md new file mode 100644 index 00000000000..94855ba51b7 --- /dev/null +++ b/.changeset/default-org-owner-currency.md @@ -0,0 +1,8 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Clause-②: no +Metadata-protocol: none + +The single posture's Default Organization now takes its first owner's default currency. That organization is created at boot with no creator, so it never passed through organization creation, and a new self-hosted install showed code-less amounts as plain numbers until an administrator set Setup → Localization → Default currency by hand. When the default-organization bootstrap first binds (or promotes) its owner, the organization gets `localization.currency` by the same rule a new organization's creator gets: the owner's stored `sys_user.locale`, else the binding request's `Accept-Language` (on a first sign-up, the sign-up request), mapped through the CLDR region table. Nothing derivable means nothing is written, and a value already set on any rung is never overwritten. `EnsureDefaultOrganizationResult` gains an optional `ownerUserId`, set when the call bound or promoted the owner.