Skip to content

Commit 5cf1b9d

Browse files
feat(plugin-auth): the single posture's Default Organization takes its first owner's default currency (#25706)
Fixes #25651 Clause-②: no Metadata-protocol: none ## What The single posture's Default Organization now takes its first owner's default currency (`localization.currency`, one `tenant` row), by the same rule and the same write a new organization's creator gets (the #25495 helper, `organization-default-currency.ts`, reused, no second copy of the rule): the owner's stored `sys_user.locale`, else the binding request's `Accept-Language`, through the CLDR region table. Nothing derivable means nothing is written; a value already set on any rung is kept. No new setting, key or door. - `ensure-default-organization.ts`: `EnsureDefaultOrganizationResult` gains an optional `ownerUserId`, set exactly when the call bound (`memberCreated`) or promoted (`ownerPromoted`) the owner. - `auth-plugin.ts`: the single-posture `runEnsure` (the once-gated default-organization bootstrap) calls `seedCreatedOrganizationCurrency` for the Default Organization and that owner when, and only when, the gate reports the owner first bound. Outside the `organization` plugin factory region of `auth-manager.ts`. ## Where the owner is first bound, and what is visible there (measured) - `ensureDefaultOrganizationExists` creates the Default Organization at boot with no creator, so it never reaches `onOrganizationCreated`. - On the first sign-up, the owner bind runs inside that sign-up's request: `POST /sign-up/email` inserts the `sys_user`; plugin-security's first-user promotion writes the `sys_user_permission_set` grant from its write middleware; plugin-auth's bootstrap middleware fires on that grant insert and binds the owner. A temporary probe on the composed kernel (case 10 below) printed the gate's answers in order: `no_admin`, `no_admin`, `admin_already_in_org` (the re-entrant call while the decision is in flight), then `memberCreated: true` with `ownerUserId` set. The probe was mutation-and-restore through `scripts/ablation-replace.mjs` (blob restored to HEAD `8c554309`), not committed. - The request is visible: better-auth's endpoint context (AsyncLocalStorage) is still current there, so `Accept-Language` is read. Case 10 asserts CNY with no stored locale, which is only possible from the request. - An email sign-up stores no `sys_user.locale` (it stays unset; only an IdP `locale` claim writes it, `idp-locale-claim.ts`). So on a first email sign-up the sign-up request's `Accept-Language` is the input. A bind at `kernel:ready` (no request) reads the stored locale only. ## Tests `organization-created-default-currency.test.ts`, a new describe block on the real composition (ObjectKernel, ObjectQL on in-memory SQLite, AuthPlugin, SecurityPlugin, SettingsServicePlugin) under the default `single` posture, the first sign-up through `AuthManager.handleRequest`: - 10: a zh-CN first sign-up leaves the Default Organization with `localization.currency` `{ value: 'CNY', source: 'tenant' }`, exactly one settings write. - 11: no stored language and no `Accept-Language`: `source: 'default'`, no value, zero writes. - 12: a currency already stored on the Default Organization (USD) is kept, zero writes. Each case first asserts the sign-up was bound as the owner, so none passes vacuously. Ablation (one leg): the new call's guard replaced by `if (false as boolean)` via `scripts/ablation-replace.mjs` ⇒ case 10 red (`expected ... to match object { value: 'CNY', source: 'tenant' }`), 11 and 12 green; restored, blob == HEAD `8c554309` and `git diff HEAD` empty. AuthPlugin is imported from source, so no build was in the loop. Runs at head 1ce6154: - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: Test Files 193 passed (193), Tests 3670 passed, 10 skipped. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (tsc, examples, `check:test-typecheck` OK). - eslint `--no-inline-config` on the three touched source files: exit 0. - `dispatch-gates --commands`: 74 derived, 73 run exit 0, 1 NOT MEASURED: `check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (it needs a whole-workspace build; CI runs it). ## Acceptance notes - The log lines `seedCreatedOrganizationCurrency` prints name the "creator" (for example "its creator's language"); for the Default Organization the first owner stands in for the creator. Wording only, left as is to keep one copy of the rule. - The walled posture's default-organization bootstrap (`@objectstack/organizations`) is not wired here; the card and ruling cover the single posture. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e2b40a2 commit 5cf1b9d

4 files changed

Lines changed: 102 additions & 4 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
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.

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { runResendVerificationEmail } from './send-verification-email.js';
7676
import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js';
7777
import { readJsonRequestBody } from './request-json-body.js';
7878
import {
79+
seedCreatedOrganizationCurrency,
7980
seedDevWorkspaceCurrency,
8081
withOrganizationDefaultCurrency,
8182
type CurrencySettingsDoor,
@@ -1422,6 +1423,33 @@ export class AuthPlugin implements Plugin {
14221423
// own chain, which never rejects.
14231424
void runBackfillOnDefaultOrg?.('default-org-created');
14241425
}
1426+
// [#25651] The Default Organization is created at boot with no
1427+
// creator, so it never reaches `onOrganizationCreated` (#25495). Its
1428+
// first owner stands in for the creator: when the owner is first
1429+
// bound (this call bound or promoted them, which the once-gate admits
1430+
// once), the organization takes that owner's default currency by the
1431+
// same rule and the same write — their stored `sys_user.locale`, else
1432+
// the binding request's `Accept-Language`. On a first sign-up this
1433+
// call runs inside that sign-up's request (the `sys_user` /
1434+
// `sys_user_permission_set` write middleware), so its headers are
1435+
// visible. Nothing derivable ⇒ nothing written; a value already set
1436+
// is kept. Never throws.
1437+
if ((res.memberCreated || res.ownerPromoted === true) && res.defaultOrgId && res.ownerUserId) {
1438+
await seedCreatedOrganizationCurrency(
1439+
{ organizationId: res.defaultOrgId, userId: res.ownerUserId },
1440+
{
1441+
dataEngine: ql,
1442+
getSettings: () => {
1443+
try {
1444+
return ctx.getService<CurrencySettingsDoor>('settings');
1445+
} catch {
1446+
return undefined;
1447+
}
1448+
},
1449+
logger: ctx.logger,
1450+
},
1451+
);
1452+
}
14251453
} catch (e) {
14261454
ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', {
14271455
error: (e as Error).message,

‎packages/plugins/plugin-auth/src/ensure-default-organization.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ export interface EnsureDefaultOrganizationResult {
330330
| 'owner_bind_decided';
331331
/** Count of the default org's seeded rows re-owned to the platform admin. */
332332
ownershipClaimed?: number;
333+
/**
334+
* The user this call made the Default Organization's owner — set exactly when
335+
* `memberCreated` or `ownerPromoted` is. The single posture's wiring gives
336+
* that organization its owner's default currency at this moment
337+
* (`organization-default-currency.ts`), the same rule a created
338+
* organization's creator gets.
339+
*/
340+
ownerUserId?: string;
333341
}
334342

335343
/**
@@ -414,7 +422,14 @@ async function promoteReconciledMemberToOwner(
414422
logger?.warn?.('[default-org] seed ownership handoff failed', { error: (e as Error).message });
415423
}
416424
}
417-
return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, ownerPromoted: true, ownershipClaimed };
425+
return {
426+
defaultOrgCreated: false,
427+
defaultOrgId,
428+
memberCreated: false,
429+
ownerPromoted: true,
430+
ownershipClaimed,
431+
ownerUserId: adminUserId,
432+
};
418433
}
419434

420435
/**
@@ -647,5 +662,5 @@ export async function ensureDefaultOrganization(
647662
}
648663
}
649664

650-
return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed };
665+
return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed, ownerUserId: adminUserId };
651666
}

‎packages/plugins/plugin-auth/src/organization-created-default-currency.test.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,15 @@ describe('a new organization takes its creator\'s default currency (#25495)', ()
187187
settingsWrites.mock.calls.filter(([namespace, key]) => namespace === 'localization' && key === 'currency');
188188

189189
/** Sign the first user up (the bootstrap user, so no invitation is needed) and return the session cookie. */
190-
const signUp = async (email: string) => {
190+
const signUp = async (email: string, acceptLanguage?: string) => {
191191
const res = await auth.handleRequest(
192192
new Request(`${AUTH}/sign-up/email`, {
193193
method: 'POST',
194-
headers: { 'content-type': 'application/json', origin: BASE },
194+
headers: {
195+
'content-type': 'application/json',
196+
origin: BASE,
197+
...(acceptLanguage ? { 'accept-language': acceptLanguage } : {}),
198+
},
195199
body: JSON.stringify({ email, password: 'S3cure!Passw0rd-25495', name: 'Founder' }),
196200
}),
197201
);
@@ -302,6 +306,49 @@ describe('a new organization takes its creator\'s default currency (#25495)', ()
302306
}, 30_000);
303307
});
304308

309+
describe("the single posture's Default Organization, from its first owner (#25651)", () => {
310+
// No posture variable: the `single` posture, whose Default Organization is
311+
// created at boot with no creator. The first sign-up becomes its owner.
312+
313+
it("10. a zh-CN first sign-up leaves the Default Organization with localization.currency = 'CNY'", async () => {
314+
const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot();
315+
const orgId = await defaultOrganization();
316+
expect(await currencyOf(orgId), 'no currency before the owner is bound').toMatchObject({ source: 'default' });
317+
const { userId } = await signUp('first-cn@example.com', 'zh-CN,zh;q=0.9,en;q=0.8');
318+
const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS });
319+
expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId);
320+
321+
expect(await currencyOf(orgId)).toMatchObject({ value: 'CNY', source: 'tenant' });
322+
expect(currencyWrites()).toEqual([['localization', 'currency', 'CNY', { tenantId: orgId }]]);
323+
}, 30_000);
324+
325+
it('11. a first sign-up with nothing to derive from (no stored language, no Accept-Language): nothing is stored', async () => {
326+
const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot();
327+
const orgId = await defaultOrganization();
328+
const { userId } = await signUp('first-none@example.com');
329+
const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS });
330+
expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId);
331+
332+
const resolved = await currencyOf(orgId);
333+
expect(resolved).toMatchObject({ source: 'default' });
334+
expect(resolved?.value ?? null).toBeNull();
335+
expect(currencyWrites()).toEqual([]);
336+
}, 30_000);
337+
338+
it("12. never over an administrator's value: a currency already stored on the Default Organization is kept", async () => {
339+
const { ql, currencyOf, currencyWrites, signUp, defaultOrganization } = await boot({
340+
extra: [storedCurrencyAppPlugin('USD')],
341+
});
342+
const orgId = await defaultOrganization();
343+
const { userId } = await signUp('first-admin-set@example.com', 'zh-CN');
344+
const owner = await ql.findOne('sys_member', { where: { organization_id: orgId, role: 'owner' }, context: SYS });
345+
expect(owner?.user_id, 'the first sign-up must be bound as the owner for this case to mean anything').toBe(userId);
346+
347+
expect(await currencyOf(orgId)).toMatchObject({ value: 'USD', source: 'tenant' });
348+
expect(currencyWrites()).toEqual([]);
349+
}, 30_000);
350+
});
351+
305352
describe('the dev seed, from the host locale', () => {
306353
beforeEach(() => {
307354
process.env.NODE_ENV = 'development';

0 commit comments

Comments
 (0)