Skip to content

feat(plugin-auth): a new organization takes its creator's default currency (#25495) - #25621

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25495-org-default-currency
Oct 24, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-25495-org-default-currency

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/service-settings, touching 32 documentable anchor(s).

39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 7265b33e779e1d63afe50fb86e411beda7582838.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7265b33e779e1d63afe50fb86e411beda7582838 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1cd4f3a860f106a6e6b8d6943bf12dd43147c079 — the merge of head 40b9af0e491b4618d7c6b30f1ebe187781d69846 into base 7265b33e779e1d63afe50fb86e411beda7582838, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1cd4f3a860f106a6e6b8d6943bf12dd43147c079 && git checkout 1cd4f3a860f106a6e6b8d6943bf12dd43147c079
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7265b33e779e1d63afe50fb86e411beda7582838 40b9af0e491b4618d7c6b30f1ebe187781d69846 && git checkout -B drift-repro 7265b33e779e1d63afe50fb86e411beda7582838 && git merge --no-ff 40b9af0e491b4618d7c6b30f1ebe187781d69846

node scripts/docs-audit/affected-docs.mjs --json 7265b33e779e1d63afe50fb86e411beda7582838

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7265b33e779e1d63afe50fb86e411beda7582838 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…rough auth-manager's one reader

Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · sunhaolin · session_01JVTd3PprStGa26aez5omLB · 2026-10-24T11:12Z · head 40b9af0e49 · claim 6382118697 · addendum 6383481471

Checklist conclusions (read on GitHub, not from the report):

  • Shape: draft, base main, author objectstack-fleet[bot], assignee sunhaolin. Fixes #25495 is right: the PR delivers the whole card (Ask 1–5 and Done when). Clause-②: no and Metadata-protocol: none are line-initial in the body and the changeset; the card declared no new spec key.
  • Scope: 7 files, all in packages/plugins/plugin-auth/src/, packages/services/service-settings/src/manifests/localization.manifest.ts and one changeset (+998/-16 with the consolidation). Not governed (check-governed-merges --pr 25621; under 3000 lines). The organization-plugin region PR refactor(plugin-auth): move the organization plugin factory into auth-manager-organization-plugin.ts (#25224 cut 15) #25594 moved is untouched: the diff does not edit the factory, and auth-manager.ts changes only by export on acceptLanguageHeader.
  • Changeset, read sentence by sentence: patch on @objectstack/plugin-auth. Each sentence matches the diff: the creator's stored sys_user.locale, else the request's Accept-Language; zh-CN → CNY, ja → JPY, en-GB → GBP; nothing derivable (including es-419) writes nothing, with no default_country/locale fallback; a value on any rung is never overwritten; the host's own onOrganizationCreated runs first; the os dev seed reads LC_ALL, LC_MONETARY, LANG, and C/POSIX write nothing; the table is a CLDR 48 currencyData snapshot.
  • Behaviour, read in the diff: seedOrganizationCurrency writes only when settings.get('localization','currency',{tenantId}) answers source === 'default'; any other source, or no answer, keeps the value. withOrganizationDefaultCurrency runs the host callback first and rethrows its failure after the seed. A seed failure is a warn naming the Setup path, never a failed organization create (a default, so fail-open is right).
  • One reader: 40b9af0e49 exports acceptLanguageHeader from auth-manager.ts and deletes the copy. src/index.ts does not re-export it, and the package's exports are ., ./rate-limit-storage, ./client-address, so the published surface is unchanged.
  • Tests: at 40b9af0e49, @objectstack/plugin-auth 3667 passed / 10 skipped; the new organization-created-default-currency.test.ts 9/9 runs a real kernel and a real POST /organization/create. Two ablation legs (Accept-Language never read; the source !== 'default' guard removed) went red as predicted and were restored by hash.
  • Console check (the card's done-when), measured: a zh-CN creator's new organization stores currency {CNY, tenant}; a dynamic-mode unit_price renders ¥1,234.50; Setup → Localization shows 默认货币 = CNY with the tenant badge; the creator-less Default Organization stays plain. The seat recorded it in the PR body's ## Console check.
  • CI on head 40b9af0e49: read at landing; every check must be green or an expected skip.
  • Writes: mcp_calls 1, mcp__claude-code-remote__add_repo for objectstack-ai/cloud (refused; not a GitHub write tool). Every GitHub write went through the relay as the fleet.

Deviations accepted: the PR body left for the seat to edit (os-dev.md assigns later body edits to the seat; done in this act).

Open, not blocking this PR:

  • To the maintainer: should the single posture's creator-less Default Organization take its first owner's currency when the owner is bound? Ruling 乙 names a new organization's creator; the dev recommends a separate S card. Asked in the seat's session.
  • To triage: A3, whether a cloud environment's first organization is created through onOrganizationCreated, is NOT MEASURED (objectstack-ai/cloud was not reachable from the dev's session).

Landing: pr_ready + automerge_enable through the relay once every check on this head is green. Fixes #25495 closes the card on merge, and the seat strips pm:dispatched in the same act.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 24, 2026 11:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 24, 2026 11:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit e8de8a7 Oct 24, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25495-org-default-currency branch October 24, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants