Skip to content

fix(plugin-auth): store sys_user.locale in its BCP-47 case (zh-cn → zh-CN) on every write path - #30049

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-twt5i2
Nov 4, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-twt5i2

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29895

维护者速读

  • 改了什么:用户的语言偏好 sys_user.locale 每次写入都按 BCP-47 大小写存(zh-cn、ZH-CN 存成 zh-CN),覆盖数据接口(单条与批量)、管理员导入用户、身份源登录同步三条写入路径;归一用的就是 feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case #29833 给翻译语言做归一的 LocaleSchema,没有另写一份。
  • 为什么改:列只校验标签形状,zh-cn 被原样存下,而控制台按精确匹配挑界面语言,于是这个用户看到英文界面且没有任何报错。
  • 风险与回滚:只改大小写,不新增拒绝也不新增接受:zh_CN 仍答 400,空值仍是清空;已存的错大小写旧行不迁移,下次写这一列时才归一。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-auth/src/sys-user-locale-case.ts(钩子与归一),auth-plugin.ts 的注册处(优先级 5,在身份写守卫之前),idp-locale-claim.ts 的 resolveIdpLocale(记录与列同一拼写)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/sys-user-locale-case.test.ts,预期全部通过;真实路由见 dogfood user-locale-case-fold.dogfood.test.ts(6 个用例)。
  • 需要你做的:无,席位落地。

Clause-②: no
Metadata-protocol: none

What changes

  • New sys-user-locale-case.ts (plugin-auth). registerUserLocaleCaseFold registers beforeInsert / beforeUpdate hooks on sys_user at priority 5, for every context (isSystem included). It folds data.locale through canonicalUserLocale, which is LocaleSchema.safeParse(value) from @objectstack/spec/system: the parse's output on success, the value as sent otherwise. canonicalLocaleCase itself is package-internal in spec by design (its docblock: an exported fold would invite a second fold in a consumer), so the parse is the reuse, and no second fold exists.
  • Registered in auth-plugin.ts at kernel:ready, beside registerMemberRoleCanonicalization (the same write-path canonicalisation shape and priority slot, ahead of the ADR-0092 identity write guard at 10).
  • resolveIdpLocale (idp-locale-claim.ts) now answers the supported locale in the same case. Without that, a supported list spelled off-case (zh-cn) would get the column folded to zh-CN while the idp-locale: record kept zh-cn, and the next sign-in would read the IdP's own value as a local choice (decideIdpLocaleWrite compares stored with recorded). With the stock locales this changes nothing (zh-cn → zh-CN was already the answer).
  • Changeset: @objectstack/plugin-auth patch, with the two declaration lines.
  • ⛔ packages/metadata-protocol/src/protocol.ts and packages/objectql/src/engine.ts are not touched.

Write paths measured (origin/main c618e890)

Path Context Reaches the column through
PATCH /api/v1/data/sys_user/:id, and the bulk form (apiMethods: ['get','list','update','bulk']) user engine update → identity write guard whitelist (SYS_USER_PROFILE_EDIT_FIELDS has locale)
Admin user import (admin-import-users.ts:750, SYS_USER_IMPORT_UPDATE_FIELDS ⊇ locale) system engine update
IdP locale claim (idp-locale-claim.ts IdpLocaleSync.apply) system engine update
better-auth's own adapter none never: locale is not on its user model (identity-write-guard.ts SESSION_SNAPSHOT_MIRRORED_FIELDS note)

All of them are ObjectQL writes, so the one hook pair covers them. Generic data import on sys_user is not open (import is not in apiMethods). The format rule runs after the before-hooks (engine validateRecord follows triggerHooks('beforeUpdate')), and the fold is case only, so the rule's verdict on any value is unchanged. sys-user-locale-case.test.ts pins that for every case in its table.

Tests

  • sys-user-locale-case.test.ts (new, plugin-auth). The fold table: zh-cn, ZH-CN → zh-CN, EN → en, ZH-HANS-cn → zh-Hans-CN, xx-yy → xx-YY; zh_CN, Chinese, zh CN, a trailing space, '' and non-strings come back as sent. The fold composed with the real SysUser rules through evaluateValidationRules: refused after exactly when refused before, and zh_CN still VALIDATION_FAILED on locale / invalid_format. The hooks: sys_user only, priority 5, fold in place on insert and update, for user, system and no context; canonical, refused and cleared values untouched; no key invented; the shared predicate-update payload folded.
  • idp-locale-claim.test.ts: one new case, an off-case supported list answers the canonical tag.
  • user-locale-case-fold.dogfood.test.ts (new, the real door on a booted showcase). A signed-up member's own-row PATCH /api/v1/data/sys_user/:id: zh-cn and ZH-CN → 200, stored zh-CN; GET /auth/me/localization serves zh-CN; zh-CN stored unchanged (control); zh_CN → 400 VALIDATION_FAILED, stored value unchanged; a system-context update with JA-jp stores ja-JP.

Acceptance notes

  • Rows stored before this. Not migrated, per the order. Such a row reads back verbatim: storedSignedInUserLocale (plugin-hono-server current-user-endpoints.ts) trims it and checks the column's shape rule only, so /auth/me/localization still serves zh-cn and the console still renders English for that user. That lasts until the column is written again, and the next write is folded.
  • The console's exact-match pick (domain:ui, not touched). Once stored values are canonical it matters for two populations only: rows stored before this release, and canonical tags that are not spelled exactly like a console locale (zh, zh-Hans-CN, en-GB). The server's own matcher (resolveLocale: exact → case → base language → variant) would answer those, and the console does not.
  • Out of scope, reported for the seat to file (fold only, no refusal): the same door stores a shape-valid tag no platform locale serves (xx-YY, now stored xx-YY) and '' with 200, as the card notes. Refusing either would narrow the door.
  • Serial constraints re-checked at PR time: no open PR matches locale or 29895 (REST search, 0 hits).

Verification

Local runs at head 1c72a05f (one commit on origin/main c618e890):

Build, suites, type-check, lint
  • pnpm --filter '@objectstack/plugin-auth...' build (under os-verify-lock): VERDICT command-exit 0 (5m03s).
  • pnpm --filter @objectstack/plugin-auth typecheck: VERDICT command-exit 0; check:test-typecheck: OK (test layer, 23 pinned signatures held).
  • pnpm --filter @objectstack/plugin-auth test: Test Files 220 passed (220), Tests 3815 passed, 10 skipped (3825), VERDICT command-exit 0.
  • pnpm --filter '@objectstack/dogfood^...' build: VERDICT command-exit 0 (7m00s); then pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/user-locale-case-fold.dogfood.test.ts: Test Files 1 passed (1), Tests 6 passed (6), VERDICT command-exit 0.
  • pnpm lint: exit 0.
  • Full dogfood battery: not run (no ! in the title, no (narrowing) arm).
Ablation (fix committed first, mutation through scripts/ablation-replace.mjs, plugin-auth dist rebuilt both legs)
  • Mutation: in sys-user-locale-case.ts, const after = canonicalUserLocale(before); → const after = (globalThis as any).ABLATION_29895_NO_FOLD === undefined ? before : canonicalUserLocale(before); (the fold off, which is the old behaviour). ablation-replace exit 0, ablation-dist-preflight @objectstack/plugin-auth ABLATION_29895_NO_FOLD exit 0 after the rebuild, so the dogfood boot read the mutated dist.
  • Dogfood file: Tests 4 failed | 2 passed (6), VERDICT command-exit 1. Red: both mis-cased PATCH cases (expected 'zh-cn' to be 'zh-CN', expected 'ZH-CN' to be 'zh-CN'), the localization door, the system-context write. Green as expected: the zh-CN control and the zh_CN 400 case.
  • Unit file: Tests 4 failed | 59 passed (63): the four hook-fold cases.
  • Restore: ablation-replace --restore reports blob == HEAD (e3fd65676c23) and git diff HEAD empty; rebuild; ablation-dist-preflight … --absent exit 0 (working tree clean against HEAD); dogfood file 6/6 again, VERDICT command-exit 0.
dispatch-gates --commands (68 derived) and --ran
  • All 68 recorded exit 0. Two were first exit 3 on prerequisites and were rerun: check-plugin-teardown-shape --self-test (shallow clone: fetched the pinned fixture 621a4876, then 48 cases pass) and check:dual-build-cjs-loads (seven packages had no dist/; built them under the lock, then exit 0).
  • node scripts/pm/dispatch-gates.mjs --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED, exit 0.
  • check-platform-checklist: not run (no exported symbol renamed or removed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfsqbTMmPhapjVEeoo


Generated by Claude Code

zh-cn and ZH-CN are stored as zh-CN on every ObjectQL write of the
column (data door, bulk, admin import, IdP locale sync), through the
LocaleSchema fold translation locales already use. A value the schema
does not accept is left as sent, so the column rule still refuses
zh_CN and an empty value still clears.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfsqbTMmPhapjVEeoo
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 9 documentable anchor(s).

37 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 318bc76faf9ffec6437c0842a476216aca001fec.

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 22 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 318bc76faf9ffec6437c0842a476216aca001fec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fec757d045e7e4b61e4cc68f4ee65a74b1def205 — the merge of head 1c72a05faee0465e2b869b65858cbfcd2c4410b2 into base 318bc76faf9ffec6437c0842a476216aca001fec, 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 fec757d045e7e4b61e4cc68f4ee65a74b1def205 && git checkout fec757d045e7e4b61e4cc68f4ee65a74b1def205
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 318bc76faf9ffec6437c0842a476216aca001fec 1c72a05faee0465e2b869b65858cbfcd2c4410b2 && git checkout -B drift-repro 318bc76faf9ffec6437c0842a476216aca001fec && git merge --no-ff 1c72a05faee0465e2b869b65858cbfcd2c4410b2

node scripts/docs-audit/affected-docs.mjs --json 318bc76faf9ffec6437c0842a476216aca001fec

⚠️ 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 318bc76faf9ffec6437c0842a476216aca001fec → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head 1c72a05faee0465e2b869b65858cbfcd2c4410b2 · 2026-11-04T10:46Z

  • Shape: sys_user gets a beforeInsert / beforeUpdate hook pair in plugin-auth (sys-user-locale-case.ts, priority 5, every context). It folds locale to BCP-47 case through LocaleSchema's own parse, which is the feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case #29833 fold, with no second spelling of it. It covers the data door (by id and bulk), the admin user import and the IdP locale sync, since better-auth never writes the column. resolveIdpLocale also answers in canonical case, so the IdP record matches the column.
  • Seat review: the diff was read whole (7 files, +406/−6).
    • The fold changes case only, so the column's shape rule gives the same verdict as before. zh_CN still gets 400, '' still clears, and xx-YY is folded and stored as before. Nothing new is refused, as ruled.
    • Nothing new is exported from the package entry, so Clause-②: no and Metadata-protocol: none hold. @objectstack/plugin-auth patch.
  • Premise measured: live. Real-route dogfood 6/6. Ablation with the fold off and dist rebuilt: dogfood 4 red / 2 green and unit 4 red, then restored. plugin-auth 3815 passed, typecheck 0, pnpm lint 0, 68/68 derived gates exit 0, --ran 0 NOT-MEASURED.
  • Accepted deviations: the harness-pinned branch claude/project-thread-twt5i2. Legacy mis-cased rows are not migrated: /auth/me/localization serves them as stored until the next write folds the row. Carried for the seat to file: the door still stores xx-YY and '' with 200, because refusing them would be a narrowing. The domain:ui exact-match locale pick still matters for legacy rows and for zh / zh-Hans-CN / en-GB.
  • Landing: on all-green at this head, the seat sends pr_ready + automerge_enable through the relay.

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/m tests tooling

Projects

None yet

1 participant