Repository navigation
Commit f2ea193
fix(spec): resolveBundleLocale and pickData answer only a bundle's own locale keys (#30678)
domain:spec#3 · os-sales · session_01L6bhgTqqeHSwZYJmApbDCa
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuKB8Sg6c6sVpL96ruVvdxaf)_
Fixes #30619
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:`resolveBundleLocale` 的精确快路径与 `pickData` 的精确读取改为只认翻译包自己的键(own
property),`constructor` / `toString` / `__proto__` 这类原型成员名不再被当成已注册语言。
- 为什么改:请求语言码(Accept-Language 首个标签或
`?locale=`)原样进入解析器,原型成员名会命中任意普通对象翻译包,解析器把它当语言返回;用户可见的一处是 `/meta`
对象读取时,平台注入的系统字段(如「Created At」)标签丢失。
- 风险与回滚:两行判断,无签名、导出或 schema 变化;七个外部调用方的返回值实测不变,唯一变化是系统字段标签恢复为英文默认(详见
Callers 表);回滚即还原 `i18n-resolver.ts` 这两处。
- 先看:`packages/spec/src/system/i18n-resolver.ts` 的 `resolveBundleLocale`
快路径与 `pickData` 首行;其次 `i18n-resolver.test.ts` 末尾新增的 describe 块。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/system/i18n-resolver.test.ts`,预期 501 passed,其中包括末尾 own-key 新块的 9
个用例。
- 需要你做的:无,席位落地。
## What changes
`packages/spec/src/system/i18n-resolver.ts`, two reads, both now
`Object.prototype.hasOwnProperty.call` (the spec package's
`tsconfig.json` targets `lib: ES2020`, which has no `Object.hasOwn`; the
file already uses this form at its other two own-key reads):
- `resolveBundleLocale`, step 1 (the exact fast path):
`bundle[requested] !== undefined` becomes an own-key check plus the same
`!== undefined` test. An own key whose value is `undefined` still does
not answer this step, as before.
- `pickData`, the exact read: `bundle[locale]` is read only when
`locale` is an own key; otherwise the read goes to `resolveBundleLocale`
as an unknown code does.
The rest of `resolveBundleLocale` was read and needs no change: steps 2
to 5 (case-insensitive, platform table language, base language, variant
expansion) run in `resolveLocaleAmong` over `Object.keys(bundle)`, which
lists own keys only, and `pickData`'s last read, `bundle[resolved]`, is
keyed by a code that one of those own-key steps returned. No other read
in `pickData` indexes the bundle by a requested code. Reads keyed by
metadata names (`objects?.[objectName]` and the like) are out of scope
and untouched.
No signature, export or schema change, and no edit outside
`packages/spec`.
## Callers
`resolveBundleLocale` has seven non-test callers outside `packages/spec`
on `origin/main` (the assumption holds, line numbers too), plus four
inside `packages/spec`: `pickData` (the translators' bundle read),
`builtinSystemFieldLabel`, `validation-message.ts` and
`operation-message.ts`. Traced read-only:
| Caller | Where `requested` comes from | Validated or normalized first?
|
|:---|:---|:---|
| `packages/objectql/src/engine-validation-messages.ts:204`
(`negotiatedMessageLocale`) | `context.locale`, which is
`ExecutionContext.locale`: the request's `requestLocale ??
localization.locale`
(`packages/core/src/security/assemble-execution-context.ts:394`). The
dispatcher's request leg is `preferredLocaleFromHeader(accept-language)`
(`packages/runtime/src/security/resolve-execution-context.ts:271`); the
REST leg also takes `?locale=`. | **No.** The header's first tag
(q-weight stripped, trimmed) or the raw `?locale=` string arrives as is.
|
| `packages/plugins/plugin-approvals/src/reader-locale-labels.ts:128` |
`readerLabelsFor(service, context?.locale)` (`approval-service.ts:9622`,
`reader-locale-labels-middleware.ts:127`): `ExecutionContext.locale`. |
**No**, same source as above. |
| `packages/plugins/plugin-audit/src/activity-summary-message.ts:278` |
`readerSummaryLocale(getI18n(), ctx.context?.locale, …)` (`:387`):
`ExecutionContext.locale`. | **No**, same source. |
| `packages/plugins/plugin-email/src/email-service.ts:1512` |
`input.locale` of `sendTemplate` / `renderTemplate`, then the stack's
declared fallback. The only callers in the tree are `plugin-auth`'s
mails, through `emailLocaleArg`: the stored `sys_user.locale`
(shape-checked by `normalizeRecipientLocale`,
`^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$`), else the request's
`Accept-Language` tag only when it is a shipped auth-mail locale
(`authEmailLocaleFromRequest`), else the deployment's configured locale.
| **Yes.** No `Object.prototype` member name passes the tag shape or the
shipped-locale list. |
| `packages/runtime/src/sandbox/hook-refusal-catalogue.ts:113` |
`engineCtx.session.locale` (`:181`): the request's execution-context
locale. | **No.** |
| `packages/services/service-automation/src/engine.ts:1462`
(`negotiatedServiceLocale`) | `AutomationContext.locale`, the door's
`ExecutionContext.locale` (its own docblock: "whose header leg is the
request's tag verbatim"), via `translatedFlowTemplate` in
`node-text-rendering.ts:197`. | **No.** |
| `packages/services/service-messaging/src/notification-text.ts:353` |
`recipientLocale()`: `resolveRecipientLocale(sys_user.locale, deployment
default)`; the public `render(keys, literal, locale)` takes a caller's
locale, and no non-test caller in the tree calls it. | **Yes**,
`normalizeRecipientLocale`'s tag shape. |
| `packages/spec/src/system/i18n-resolver.ts` `pickData` (inside
`packages/spec`) | Every code of the translators' locale chain: the
request locale the serving layer threads (`extractLocale` /
`metaRequestLocale`: the `Accept-Language` top tag or a raw `?locale=`),
then the declared fallback chain. | **No.** |
| `packages/spec/src/system/i18n-resolver.ts` `builtinSystemFieldLabel`
(inside `packages/spec`, called from `translateObject`'s field pass) |
The same locale chain, per platform-injected system field still carrying
its English default. | **No.** |
| `packages/spec/src/system/validation-message.ts:399`,
`operation-message.ts:332` (inside `packages/spec`) | The `locale`
option their callers pass, the same `ExecutionContext.locale` in the
engine paths. | **No.** |
So five of the seven outside callers, and all four inside ones, pass an
unvalidated request value. What it measurably changed before this PR:
- Every one of the five outside callers ends
`resolveBundleLocale(offered, requested) ?? requested`, so a
prototype-name request came back as itself both before and after the
fix, and the answer the caller hands `t()` is the same.
- The two message catalogs read
`BUILTIN_*_MESSAGES[matched][messageKey]`. No built-in message key is a
`Function` or `Object.prototype` member name, so they fell back to the
default locale's template before the fix too.
- `pickData`'s inherited answer was never read through: every reader's
first hop is a fixed group (`objects`, `apps`, `settings`, …) that none
of those members carries.
- **One wrong answer was user-visible, and this PR removes it.**
`builtinSystemFieldLabel` returns `entry[resolved]` directly, and
`translateObject`'s field pass assigns any truthy answer to
`next.label`. Before this PR an object read (`/meta`) under
`?locale=constructor` or `Accept-Language: constructor` served the
`Object` constructor as the label of every injected system field still
at its English default; JSON serialisation dropped it, so the label went
missing. Under `__proto__` it served `Object.prototype` (an object where
a string belongs). Now the ladder answers `undefined` for those codes,
the chain moves on, and the English default stands.
## Tests
New block at the end of `packages/spec/src/system/i18n-resolver.test.ts`
(`bundle locale keys are the bundle's OWN keys …`), at the end of the
file so #30618's cases cannot conflict with it:
- for each of `constructor`, `toString` and `__proto__`:
`resolveBundleLocale({ en: {} }, code)` equals `resolveBundleLocale({
en: {} }, 'xx')` (both `undefined`), and the same over `{ en, zh-CN }`;
- for each of them, the exported reader `resolveViewLabel` (which reads
through `pickData`; `pickData` itself is not exported) answers exactly
what it answers for `'xx'`: the literal label, or the next locale of the
fallback chain;
- a bundle that only *inherits* `zh-CN` (`Object.create(bundle)`)
answers `zh-CN` the way it answers an unknown code, in
`resolveBundleLocale` and through `resolveViewLabel`;
- an injected system field (`created_at`, label `Created At`) read
through the exported `translateObject` with `{ locale: 'constructor' }`
and `{ locale: '__proto__' }` keeps a string label equal to what `'xx'`
gives (`Created At`). This pins the served-document path above;
- an **own** `constructor` and `__proto__` key (built by `JSON.parse`)
and an own `constructor` on an `Object.create(null)` bundle still
answer, and `resolveViewLabel` reads the own `constructor` data. This
shows the fix is an own-property check, not a deny-list.
<details><summary>Readings (head fdba0db, round 1)</summary>
- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
src/system/i18n-resolver.test.ts`: `Tests 501 passed (501)`, lock
`VERDICT command-exit 0`.
- `pnpm --filter @objectstack/spec build && pnpm --filter
@objectstack/spec typecheck && pnpm --filter @objectstack/spec test`:
`Test Files 817 passed (817)`, `Tests 25783 passed | 1 todo (25784)`,
`VERDICT command-exit 0`.
- `pnpm --filter @objectstack/spec check:generated`: exit 0, `✓ All 14
generated artifacts are up to date`, including `check:test-typecheck`
green with the new pin. In round 0 an earlier run caught one new TS2740
in the first version of the test (a `constructor` assignment typed as
`Function`); the fixture was rewritten, and the ledger was not touched.
- `eslint --no-inline-config` on both changed `.ts` files: exit 0, no
output.
- `node scripts/check-changeset-no-major.mjs --base b3d2f4b --event
EVENT` (a `pull_request` payload carrying this body's two declaration
lines): exit 0, `✓ LEVEL AXIS: this PR declares clause-② no and
metadata-protocol none … a non-protocol change at patch`.
**Ablation** (via `scripts/ablation-replace.mjs`, which takes a literal
anchor, checks that it hit, and restores; the spec tests import
`./i18n-resolver` from source, so no `dist` leg applies):
- Leg A, the fast path reverted to `if (bundle[requested] !== undefined)
{`. Round 1, on fdba0db: anchor x1 → x0, blob `405d8b76` → `d21038ff`.
`Tests 5 failed | 496 passed`: the three prototype-name
`resolveBundleLocale` pins, the inherited-key pin, and the new
`translateObject` system-field pin. Restored: blob after restore
`405d8b76` equals the HEAD blob, and `git diff HEAD` is empty. In round
0, on aa1ad0b, the same leg gave 4 failed, before the system-field pin
existed.
- Leg B, `pickData`'s read reverted to `const exact = bundle[locale];`
(round 0, on aa1ad0b): anchor x1 → x0, blob `405d8b76` → `eb268592`.
`Tests 1 failed | 499 passed`: the inherited-key pin. Restored the same
way, blob equals HEAD. `i18n-resolver.ts` has not changed since, so its
blob is still `405d8b76`.
Observed direction for leg B: the `constructor`/`toString`/`__proto__`
reader pins stay green with `pickData`'s read reverted. No exported
reader reads a property that `Object`, `Function.prototype.toString` or
`Object.prototype` carries (every reader's first hop is a fixed group
such as `objects`, `apps` or `settings`), so the function `pickData`
returned was never visible through them. The inherited-key pin is the
one that shows the `pickData` read on its own.
</details>
## Acceptance notes
- Reads at merge base `b3d2f4bdb`. `origin/main` has moved 6 commits
since, none in `i18n-resolver.ts`, its test or `platform-locales.ts`, so
no merge was taken. If #30618 lands first, `main` is merged in (no
rebase).
- The ruling's "`pickData` returns `undefined`" is pinned through
`resolveViewLabel`, because `pickData` is not exported and the ruling
forbids an export change. See the leg-B note above for what that pin can
and cannot see.
- `resolveBundleLocale({ en: {}, fr: undefined }, 'fr')` still answers
`'fr'`. The fast path skips the `undefined` value as before, but step 1
of `resolveLocaleAmong` finds `fr` in `Object.keys`. This is unchanged
by this PR and is not pinned here.
- Five of the seven outside callers, and all four inside ones, pass an
unvalidated request locale (table above). After this PR none of them
gets a wrong answer from it. Whether a door should validate the locale
before it builds `ExecutionContext.locale` is a separate question this
PR does not take up.
- **Round 1** (contract review 6671371228, should-fix S1): the round-0
census missed the inside caller `builtinSystemFieldLabel`, and with it
the one user-visible wrong answer: a missing system-field label under a
prototype-name locale. That answer is fixed by the same fast-path
change. Round 1 adds the `translateObject` pin for it, corrects the
census and the impact sentence here, and names the label in the
changeset. No source change: `i18n-resolver.ts` is byte-identical to
round 0. `origin/main` moved no commit on `i18n-resolver.ts`, its test
or `platform-locales.ts`, so no merge was taken.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 59bcd22 commit f2ea193
3 files changed
Lines changed: 70 additions & 3 deletions
File tree
- .changeset
- packages/spec/src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6492 | 6492 | | |
6493 | 6493 | | |
6494 | 6494 | | |
| 6495 | + | |
| 6496 | + | |
| 6497 | + | |
| 6498 | + | |
| 6499 | + | |
| 6500 | + | |
| 6501 | + | |
| 6502 | + | |
| 6503 | + | |
| 6504 | + | |
| 6505 | + | |
| 6506 | + | |
| 6507 | + | |
| 6508 | + | |
| 6509 | + | |
| 6510 | + | |
| 6511 | + | |
| 6512 | + | |
| 6513 | + | |
| 6514 | + | |
| 6515 | + | |
| 6516 | + | |
| 6517 | + | |
| 6518 | + | |
| 6519 | + | |
| 6520 | + | |
| 6521 | + | |
| 6522 | + | |
| 6523 | + | |
| 6524 | + | |
| 6525 | + | |
| 6526 | + | |
| 6527 | + | |
| 6528 | + | |
| 6529 | + | |
| 6530 | + | |
| 6531 | + | |
| 6532 | + | |
| 6533 | + | |
| 6534 | + | |
| 6535 | + | |
| 6536 | + | |
| 6537 | + | |
| 6538 | + | |
| 6539 | + | |
| 6540 | + | |
| 6541 | + | |
| 6542 | + | |
| 6543 | + | |
| 6544 | + | |
| 6545 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
522 | 522 | | |
523 | 523 | | |
524 | 524 | | |
525 | | - | |
526 | | - | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
527 | 531 | | |
528 | 532 | | |
529 | 533 | | |
| |||
591 | 595 | | |
592 | 596 | | |
593 | 597 | | |
594 | | - | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
595 | 601 | | |
596 | 602 | | |
597 | 603 | | |
| |||
0 commit comments