Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/default-org-owner-currency.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CurrencySettingsDoor>('settings');
} catch {
return undefined;
}
},
logger: ctx.logger,
},
);
}
} catch (e) {
ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', {
error: (e as Error).message,
Expand Down
19 changes: 17 additions & 2 deletions packages/plugins/plugin-auth/src/ensure-default-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
};
}

/**
Expand Down Expand Up @@ -647,5 +662,5 @@ export async function ensureDefaultOrganization(
}
}

return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed };
return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed, ownerUserId: adminUserId };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
);
Expand Down Expand Up @@ -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';
Expand Down
Loading