From aa1ad0bc8a206671cfaf29d3070dccd6c13de326 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:17:31 +0000 Subject: [PATCH 1/3] fix(spec): resolveBundleLocale and pickData answer only a bundle's own locale keys A requested code naming an Object.prototype member (constructor, toString, __proto__) was found on every plain-object bundle: resolveBundleLocale's exact fast path answered it as a registered locale and pickData's exact read handed back the inherited function as bundle data. Both reads now use an own-property check, so the code takes the same fallback an unknown code takes; an own key of that name still answers. Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa Co-authored-by: Claude --- .changeset/30619-bundle-locale-own-key.md | 10 +++++ .../spec/src/system/i18n-resolver.test.ts | 43 +++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 12 ++++-- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 .changeset/30619-bundle-locale-own-key.md diff --git a/.changeset/30619-bundle-locale-own-key.md b/.changeset/30619-bundle-locale-own-key.md new file mode 100644 index 00000000000..fc9c36fcb49 --- /dev/null +++ b/.changeset/30619-bundle-locale-own-key.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `resolveBundleLocale` and the bundle reads behind every translator answer a locale only from the bundle's own keys + +Clause-②: no +Metadata-protocol: none + +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. Both reads now check for an own property: the code reads as an unknown locale and takes the same fallback `'xx'` takes. 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. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 8c4bc7d9ba0..b85f5cad286 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -6467,3 +6467,46 @@ describe("label precedence on an overridden item — the pair's translation, the }); }); }); + +describe('bundle locale keys are the bundle\'s OWN keys — an Object.prototype member name is an unknown code', () => { + const inherited = ['constructor', 'toString', '__proto__'] as const; + const zhBundle: TranslationBundle = { + 'zh-CN': { objects: { account: { _views: { all_accounts: { label: '全部客户' } } } } } as any, + }; + const view = { name: 'all_accounts', label: 'All Accounts', objectName: 'account' }; + + for (const code of inherited) { + it(`resolveBundleLocale answers ${code} exactly what it answers an unknown code`, () => { + expect(resolveBundleLocale({ en: {} }, code)).toBe(resolveBundleLocale({ en: {} }, 'xx')); + expect(resolveBundleLocale({ en: {} }, code)).toBeUndefined(); + expect(resolveBundleLocale({ en: {}, 'zh-CN': {} }, code)).toBe(resolveBundleLocale({ en: {}, 'zh-CN': {} }, 'xx')); + }); + + it(`a reader asked for ${code} reads what an unknown code reads — the literal, or the next locale in the chain`, () => { + expect(resolveViewLabel(zhBundle, view, { locale: code })).toBe(resolveViewLabel(zhBundle, view, { locale: 'xx' })); + expect(resolveViewLabel(zhBundle, view, { locale: code })).toBe('All Accounts'); + expect(resolveViewLabel(zhBundle, view, { locale: code, fallbackChain: ['zh-CN'] })).toBe('全部客户'); + }); + } + + it('a key the bundle only inherits is no locale of it, whatever its prototype holds', () => { + const layered = Object.create(zhBundle) as TranslationBundle; + expect(resolveBundleLocale(layered, 'zh-CN')).toBe(resolveBundleLocale(layered, 'xx')); + expect(resolveViewLabel(layered, view, { locale: 'zh-CN' })).toBe('All Accounts'); + }); + + it('an OWN constructor or __proto__ key still answers — an own-property check, not a deny-list', () => { + const parsed = JSON.parse( + '{"en":{},"constructor":{"objects":{"account":{"_views":{"all_accounts":{"label":"ctor"}}}}},"__proto__":{}}', + ) as TranslationBundle; + expect(resolveBundleLocale(parsed, 'constructor')).toBe('constructor'); + expect(resolveBundleLocale(parsed, '__proto__')).toBe('__proto__'); + expect(resolveViewLabel(parsed, view, { locale: 'constructor' })).toBe('ctor'); + + const bare = Object.create(null) as Record; + bare.en = {}; + bare.constructor = {}; + expect(resolveBundleLocale(bare, 'constructor')).toBe('constructor'); + expect(resolveBundleLocale(bare, 'toString')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index f0c99b1a5e6..405d8b76e0a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -522,8 +522,12 @@ export function resolveBundleLocale( bundle: Record, requested: string, ): string | undefined { - // 1. Exact match (fast path). - if (bundle[requested] !== undefined) return requested; + // 1. Exact match (fast path). An own key only: an `Object.prototype` + // member name (`constructor`, `toString`, `__proto__`) is found on every + // plain-object bundle and is never a locale the bundle registered. + if (Object.prototype.hasOwnProperty.call(bundle, requested) && bundle[requested] !== undefined) { + return requested; + } return resolveLocaleAmong(requested, Object.keys(bundle)); } @@ -591,7 +595,9 @@ function pickData( locale: string, ): D | undefined { if (!bundle) return undefined; - const exact = bundle[locale]; + // An own key only, as in `resolveBundleLocale`: an inherited member is + // never bundle data, so `constructor` reads as an unknown code, not a function. + const exact = Object.prototype.hasOwnProperty.call(bundle, locale) ? bundle[locale] : undefined; if (exact !== undefined) return exact; const resolved = resolveBundleLocale(bundle, locale); return resolved !== undefined ? bundle[resolved] : undefined; From a160083beab329a933ae8af9867049e08a52b3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:33:53 +0000 Subject: [PATCH 2/3] test(spec): build the null-prototype bundle without a Function-typed constructor write Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa Co-authored-by: Claude --- packages/spec/src/system/i18n-resolver.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index b85f5cad286..258b63cdd3f 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -6503,9 +6503,7 @@ describe('bundle locale keys are the bundle\'s OWN keys — an Object.prototype expect(resolveBundleLocale(parsed, '__proto__')).toBe('__proto__'); expect(resolveViewLabel(parsed, view, { locale: 'constructor' })).toBe('ctor'); - const bare = Object.create(null) as Record; - bare.en = {}; - bare.constructor = {}; + const bare: Record = Object.assign(Object.create(null), { en: {}, constructor: {} }); expect(resolveBundleLocale(bare, 'constructor')).toBe('constructor'); expect(resolveBundleLocale(bare, 'toString')).toBeUndefined(); }); From fdba0dbd7563d1da25a725806ed887270211b89f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:41:33 +0000 Subject: [PATCH 3/3] test(spec): pin the system-field label under a prototype-name locale through translateObject builtinSystemFieldLabel returned entry[resolved] straight, so before the own-key fast path a constructor or __proto__ locale served an inherited member as an injected system field's label. The pin reads created_at through translateObject under both names and expects what 'xx' answers. The changeset now names that user-visible answer. Claude-Session: https://claude.ai/code/session_01L6bhgTqqeHSwZYJmApbDCa Co-authored-by: Claude --- .changeset/30619-bundle-locale-own-key.md | 2 +- packages/spec/src/system/i18n-resolver.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/30619-bundle-locale-own-key.md b/.changeset/30619-bundle-locale-own-key.md index fc9c36fcb49..1b5f233e3ba 100644 --- a/.changeset/30619-bundle-locale-own-key.md +++ b/.changeset/30619-bundle-locale-own-key.md @@ -7,4 +7,4 @@ fix(spec): `resolveBundleLocale` and the bundle reads behind every translator an Clause-②: no Metadata-protocol: none -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. Both reads now check for an own property: the code reads as an unknown locale and takes the same fallback `'xx'` takes. 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. +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. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 258b63cdd3f..73d9f29669f 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -6495,6 +6495,16 @@ describe('bundle locale keys are the bundle\'s OWN keys — an Object.prototype expect(resolveViewLabel(layered, view, { locale: 'zh-CN' })).toBe('All Accounts'); }); + it('an injected system field read under a prototype-name locale keeps its English default label, as an unknown code does', () => { + const doc = { name: 'contract', fields: { created_at: { type: 'datetime', label: 'Created At' } } }; + const labelUnder = (locale: string): unknown => (translateObject(doc, undefined, { locale }) as any).fields.created_at.label; + for (const code of ['constructor', '__proto__']) { + expect(labelUnder(code)).toBe(labelUnder('xx')); + expect(typeof labelUnder(code)).toBe('string'); + expect(labelUnder(code)).toBe('Created At'); + } + }); + it('an OWN constructor or __proto__ key still answers — an own-property check, not a deny-list', () => { const parsed = JSON.parse( '{"en":{},"constructor":{"objects":{"account":{"_views":{"all_accounts":{"label":"ctor"}}}}},"__proto__":{}}',