Skip to content

Commit f2ea193

Browse files
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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `resolveBundleLocale` and the bundle reads behind every translator answer a locale only from the bundle's own keys
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A requested locale code that names an `Object.prototype` member (`constructor`, `toString`, `__proto__`) is found on every plain-object bundle. Until now `resolveBundleLocale` answered such a code as a registered locale, and the translators' bundle read handed back the inherited function as that locale's data. One answer was visible to readers: an object read (`/meta`) under `?locale=constructor` or `Accept-Language: constructor` served the `Object` constructor as the label of every platform-injected system field still at its English default (so the label went missing from the JSON), and under `__proto__` served `Object.prototype` there. Both reads now check for an own property: the code reads as an unknown locale and takes the same fallback `'xx'` takes, so those fields keep their English label. A bundle that really carries such a key as its own (built by `JSON.parse` or on `Object.create(null)`) still answers it, and an own key whose value is `undefined` still does not answer the exact step. No signature, export or schema changes.

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6492,3 +6492,54 @@ describe("label precedence on an overridden item — the pair's translation, the
64926492
});
64936493
});
64946494
});
6495+
6496+
describe('bundle locale keys are the bundle\'s OWN keys — an Object.prototype member name is an unknown code', () => {
6497+
const inherited = ['constructor', 'toString', '__proto__'] as const;
6498+
const zhBundle: TranslationBundle = {
6499+
'zh-CN': { objects: { account: { _views: { all_accounts: { label: '全部客户' } } } } } as any,
6500+
};
6501+
const view = { name: 'all_accounts', label: 'All Accounts', objectName: 'account' };
6502+
6503+
for (const code of inherited) {
6504+
it(`resolveBundleLocale answers ${code} exactly what it answers an unknown code`, () => {
6505+
expect(resolveBundleLocale({ en: {} }, code)).toBe(resolveBundleLocale({ en: {} }, 'xx'));
6506+
expect(resolveBundleLocale({ en: {} }, code)).toBeUndefined();
6507+
expect(resolveBundleLocale({ en: {}, 'zh-CN': {} }, code)).toBe(resolveBundleLocale({ en: {}, 'zh-CN': {} }, 'xx'));
6508+
});
6509+
6510+
it(`a reader asked for ${code} reads what an unknown code reads — the literal, or the next locale in the chain`, () => {
6511+
expect(resolveViewLabel(zhBundle, view, { locale: code })).toBe(resolveViewLabel(zhBundle, view, { locale: 'xx' }));
6512+
expect(resolveViewLabel(zhBundle, view, { locale: code })).toBe('All Accounts');
6513+
expect(resolveViewLabel(zhBundle, view, { locale: code, fallbackChain: ['zh-CN'] })).toBe('全部客户');
6514+
});
6515+
}
6516+
6517+
it('a key the bundle only inherits is no locale of it, whatever its prototype holds', () => {
6518+
const layered = Object.create(zhBundle) as TranslationBundle;
6519+
expect(resolveBundleLocale(layered, 'zh-CN')).toBe(resolveBundleLocale(layered, 'xx'));
6520+
expect(resolveViewLabel(layered, view, { locale: 'zh-CN' })).toBe('All Accounts');
6521+
});
6522+
6523+
it('an injected system field read under a prototype-name locale keeps its English default label, as an unknown code does', () => {
6524+
const doc = { name: 'contract', fields: { created_at: { type: 'datetime', label: 'Created At' } } };
6525+
const labelUnder = (locale: string): unknown => (translateObject(doc, undefined, { locale }) as any).fields.created_at.label;
6526+
for (const code of ['constructor', '__proto__']) {
6527+
expect(labelUnder(code)).toBe(labelUnder('xx'));
6528+
expect(typeof labelUnder(code)).toBe('string');
6529+
expect(labelUnder(code)).toBe('Created At');
6530+
}
6531+
});
6532+
6533+
it('an OWN constructor or __proto__ key still answers — an own-property check, not a deny-list', () => {
6534+
const parsed = JSON.parse(
6535+
'{"en":{},"constructor":{"objects":{"account":{"_views":{"all_accounts":{"label":"ctor"}}}}},"__proto__":{}}',
6536+
) as TranslationBundle;
6537+
expect(resolveBundleLocale(parsed, 'constructor')).toBe('constructor');
6538+
expect(resolveBundleLocale(parsed, '__proto__')).toBe('__proto__');
6539+
expect(resolveViewLabel(parsed, view, { locale: 'constructor' })).toBe('ctor');
6540+
6541+
const bare: Record<string, unknown> = Object.assign(Object.create(null), { en: {}, constructor: {} });
6542+
expect(resolveBundleLocale(bare, 'constructor')).toBe('constructor');
6543+
expect(resolveBundleLocale(bare, 'toString')).toBeUndefined();
6544+
});
6545+
});

‎packages/spec/src/system/i18n-resolver.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,12 @@ export function resolveBundleLocale(
522522
bundle: Record<string, unknown>,
523523
requested: string,
524524
): string | undefined {
525-
// 1. Exact match (fast path).
526-
if (bundle[requested] !== undefined) return requested;
525+
// 1. Exact match (fast path). An own key only: an `Object.prototype`
526+
// member name (`constructor`, `toString`, `__proto__`) is found on every
527+
// plain-object bundle and is never a locale the bundle registered.
528+
if (Object.prototype.hasOwnProperty.call(bundle, requested) && bundle[requested] !== undefined) {
529+
return requested;
530+
}
527531
return resolveLocaleAmong(requested, Object.keys(bundle));
528532
}
529533

@@ -591,7 +595,9 @@ function pickData<D>(
591595
locale: string,
592596
): D | undefined {
593597
if (!bundle) return undefined;
594-
const exact = bundle[locale];
598+
// An own key only, as in `resolveBundleLocale`: an inherited member is
599+
// never bundle data, so `constructor` reads as an unknown code, not a function.
600+
const exact = Object.prototype.hasOwnProperty.call(bundle, locale) ? bundle[locale] : undefined;
595601
if (exact !== undefined) return exact;
596602
const resolved = resolveBundleLocale(bundle, locale);
597603
return resolved !== undefined ? bundle[resolved] : undefined;

0 commit comments

Comments
 (0)