From d212a5e042f813f0877f10f1ef40fd8dc7eee89e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:55:03 +0000 Subject: [PATCH 1/2] fix(spec): normalizeMetadataCollection reads only a plain object as the map form A Set, Map, Date or class instance is no longer passed to Object.entries (which yields [] for a Set or Map) before the strict parse; it reaches the parse unchanged and is refused at the key. Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude --- ...tadata-collection-non-plain-object.test.ts | 111 ++++++++++++++++++ .../src/shared/metadata-collection.zod.ts | 25 +++- 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/shared/metadata-collection-non-plain-object.test.ts diff --git a/packages/spec/src/shared/metadata-collection-non-plain-object.test.ts b/packages/spec/src/shared/metadata-collection-non-plain-object.test.ts new file mode 100644 index 00000000000..c0f2ca8bab6 --- /dev/null +++ b/packages/spec/src/shared/metadata-collection-non-plain-object.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `normalizeMetadataCollection` reads ONLY a plain object as the map form. + * + * ## What was wrong + * + * The map-form branch tested `typeof value === 'object'`, so a `Set`, a `Map` + * or a `Date` was read as a map too. `Object.entries` of any of them is `[]`, + * and normalization runs before the schema parse, so the strict `defineStack` + * door saw a valid empty array and ACCEPTED the stack with every authored + * entry (e.g. its permission-set grants) gone. + * + * ## What is pinned + * + * A composed artifact is complete or it is refused. For every key that accepts + * the map form (derived from `MAP_SUPPORTED_FIELDS`, never transcribed), a + * non-plain object reaches the strict parse unchanged and is refused with the + * ordinary strict envelope: `STACK_SCHEMA_INVALID`, `status: 422`, a zod issue + * rooted at the key expecting an array. The controls: the same key authored as + * a plain-object map — a literal, a null-prototype object, and a plain object + * from another realm — is still normalized. + */ +import { describe, it, expect } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import { normalizeMetadataCollection, MAP_SUPPORTED_FIELDS } from './metadata-collection.zod'; +import { defineStack } from '../stack.zod'; + +type Envelope = Error & { + code?: string; + status?: number; + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>; +}; + +/** The thrown value, or `null` when the stack is accepted. */ +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const manifest = { id: 'com.example.a', name: 'a', version: '1.0.0', type: 'app' as const }; + +class Holder { + rep = { label: 'Rep' }; +} + +const NON_PLAIN: ReadonlyArray unknown]> = [ + ['a Set', () => new Set([{ name: 'rep', label: 'Rep' }])], + ['a Map', () => new Map([['rep', { label: 'Rep' }]])], + ['a Date', () => new Date(0)], + ['a class instance', () => new Holder()], +]; + +describe('normalizeMetadataCollection — only a plain object is the map form', () => { + for (const [label, make] of NON_PLAIN) { + it(`${label} is returned unchanged, never read as an empty map`, () => { + const value = make(); + expect(normalizeMetadataCollection(value)).toBe(value); + }); + } + + it('control: an object literal is normalized with key → name', () => { + expect(normalizeMetadataCollection({ rep: { label: 'Rep' } })).toEqual([{ name: 'rep', label: 'Rep' }]); + }); + + it('control: a null-prototype object is normalized with key → name', () => { + const map = Object.assign(Object.create(null), { rep: { label: 'Rep' } }); + expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]); + }); + + it('control: a plain object from another realm is normalized with key → name', () => { + const map = runInNewContext('({ rep: { label: "Rep" } })'); + expect(Object.getPrototypeOf(map)).not.toBe(Object.prototype); + expect(normalizeMetadataCollection(map)).toEqual([{ name: 'rep', label: 'Rep' }]); + }); +}); + +describe('strict defineStack refuses a non-plain object for a map-form collection key', () => { + it('covers every map-form key the normalizer declares (the list is the census)', () => { + expect(MAP_SUPPORTED_FIELDS).toContain('permissions'); + expect(MAP_SUPPORTED_FIELDS.length).toBeGreaterThanOrEqual(20); + }); + + for (const key of MAP_SUPPORTED_FIELDS) { + for (const [label, make] of NON_PLAIN) { + it(`'${key}': ${label} is refused with STACK_SCHEMA_INVALID / 422 at the key — never accepted as []`, () => { + const err = refusal(() => defineStack({ manifest, [key]: make() } as never)); + expect(err, `'${key}' given ${label} was accepted`).not.toBeNull(); + expect(err!.code).toBe('STACK_SCHEMA_INVALID'); + expect(err!.status).toBe(422); + expect(err!.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_type', path: [key], expected: 'array' }), + ]), + ); + }); + } + } + + it("control: 'permissions' authored as a plain-object map is accepted with its entry", () => { + const stack = defineStack({ + manifest, + permissions: { rep: { label: 'Rep', objects: {} } }, + } as never); + expect(stack.permissions?.map((p) => p.name)).toEqual(['rep']); + }); +}); diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index 3456f9d4ecf..f5f0f9d1314 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -114,11 +114,26 @@ export { singularToPlural, } from '../meta-spelling/manifest-collection-spelling.js'; +/** + * Whether `value` is a plain object — a `{ … }` literal, `Object.create(null)`, + * or a plain object from another realm (a `vm` context): its prototype is + * `null`, or a prototype whose own prototype is `null` (that realm's + * `Object.prototype`). A `Set`, `Map`, `Date`, array or class instance is not. + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto: unknown = Object.getPrototypeOf(value); + return proto === null || Object.getPrototypeOf(proto) === null; +} + /** * Normalize a single metadata collection value from map format to array format. * If the input is already an array (or nullish), it is returned unchanged. * If the input is a plain object (map), it is converted to an array where * each key is injected as the `name` field of the corresponding item. + * Any other value — including a non-plain object such as a `Set` or `Map` — is + * returned unchanged, so schema validation refuses it rather than this + * function reading it as an empty map. * * **Precedence:** If an item already has a `name` property, it is preserved * (the map key is only used as a fallback). @@ -148,8 +163,14 @@ export function normalizeMetadataCollection(value: unknown, keyField = 'name'): // Nullish or already an array — pass through if (value == null || Array.isArray(value)) return value; - // Plain object — treat as map and convert to array - if (typeof value === 'object') { + // Plain object — treat as map and convert to array. ONLY a plain object: a + // `Set`, `Map`, `Date` or class instance is `typeof 'object'` too, and + // `Object.entries` reads its own enumerable string keys — `[]` for a `Set` or + // a `Map` — so treating it as the map form would hand the parse a valid empty + // array and drop every authored entry before any schema saw it. A non-plain + // object falls through unchanged so the strict parse refuses it as a + // non-array at the key, where it was written. + if (isPlainObject(value)) { return Object.entries(value as Record).map(([key, item]) => { if (item && typeof item === 'object' && !Array.isArray(item)) { const obj = item as Record; From a4c6b1c5ad7ba8f9cd202f8ffcc7bfafdb136653 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:04:26 +0000 Subject: [PATCH 2/2] chore(changeset): 19796 non-plain map-form refusal Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude --- .changeset/19796-map-form-non-plain-object.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/19796-map-form-non-plain-object.md diff --git a/.changeset/19796-map-form-non-plain-object.md b/.changeset/19796-map-form-non-plain-object.md new file mode 100644 index 00000000000..c3259324dd6 --- /dev/null +++ b/.changeset/19796-map-form-non-plain-object.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): strict `defineStack` refuses a `Set`, `Map` or other non-plain object for a map-form collection key instead of accepting it as an empty collection + +**BREAKING** — `defineStack` (strict, the default) now refuses a class of input it used to accept with every authored entry silently missing. + +`normalizeMetadataCollection` turns the map form of a collection (`permissions: { rep: { … } }`) into an array before the schema parse. It read any `typeof 'object'` value as that map form, so a `Set`, a `Map` or a `Date` went through `Object.entries`, which yields `[]` for them. The parse then saw a valid empty array: `defineStack({ manifest, permissions: new Set([{ name: 'rep', … }]) })` was accepted with `permissions: []` — the author's grants gone, no error, no warning. This reached every map-form key (every entry of `MAP_SUPPORTED_FIELDS`: `objects`, `apps`, `permissions`, `flows`, `agents`, …). + +| the key's value | before | after | +| :--- | :--- | :--- | +| a `Set`, a `Map`, a `Date` | accepted, the collection is `[]` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, zod issue at the key (`expected: 'array'`) | +| a class instance | read as a map of its own fields | refused the same way | +| an object literal, `Object.create(null)`, a plain object from another realm | normalized (key → `name`) | unchanged | +| an array | passed through | unchanged | + +Only a plain object is the map form; every other value reaches the parse unchanged and is refused there, at the key where it was written. `normalizeMetadataCollection`, `normalizeStackInput` and `normalizePluginMetadata` (public `@objectstack/spec` exports) now return such a value unchanged instead of `[]`. + +The one-line fix: author the key as an array (`[...set]`, `[...map.values()]`) or as a plain-object map (`Object.fromEntries(map)`). + +No code is added to the ADR-0112 ledger and no export changes: the refusal is the strict parse's existing `STACK_SCHEMA_INVALID`. + + + +Clause-②: no (narrowing)