From 3792fe325ce8b286cfbef07c77eda2959d9e074e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:28:32 +0000 Subject: [PATCH 1/2] fix(core): refuse a plugin redeclaring a field another plugin introduced Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Cmjr3Cq9wqd6xok31R15hJ --- .changeset/quiet-fields-own.md | 5 ++ CLAUDE.md | 2 +- ...elongs-to-the-plugin-that-introduced-it.md | 20 ++++++ .../core/src/config/plugin-engine.test.ts | 72 +++++++++++++++++++ packages/core/src/config/plugin-engine.ts | 24 +++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-fields-own.md create mode 100644 docs/adr/0077-a-field-belongs-to-the-plugin-that-introduced-it.md diff --git a/.changeset/quiet-fields-own.md b/.changeset/quiet-fields-own.md new file mode 100644 index 000000000..466654b9a --- /dev/null +++ b/.changeset/quiet-fields-own.md @@ -0,0 +1,5 @@ +--- +'@opensaas/stack-core': patch +--- + +`extendList` now throws when a plugin redeclares a field another plugin introduced, instead of silently discarding that field's access, hooks and options. diff --git a/CLAUDE.md b/CLAUDE.md index d00abaae5..d221fb4ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,7 +322,7 @@ export default config({ - **Extend Lists**: Add fields or hooks to existing lists - **Declare Extension packs**: `context.addExtension({ name, from })`, so a field type needing a pack does not push that declaration onto the application - **Hook Chaining**: Multiple plugins can add hooks that execute in sequence -- **Deep Merging**: Plugins safely merge fields, hooks, and access control +- **Merging**: Hooks chain (`mergeHooks`); fields are replaced per key, and a plugin may redeclare an app-declared field or its own, but redeclaring a field another plugin introduced throws; operation-level access on an existing list is refused (ADR-0013, ADR-0077) - **Lifecycle Hooks**: `beforeGenerate`, `afterGenerate` for code generation control - **Dependency Resolution**: Automatic execution ordering via topological sort diff --git a/docs/adr/0077-a-field-belongs-to-the-plugin-that-introduced-it.md b/docs/adr/0077-a-field-belongs-to-the-plugin-that-introduced-it.md new file mode 100644 index 000000000..bc41770e1 --- /dev/null +++ b/docs/adr/0077-a-field-belongs-to-the-plugin-that-introduced-it.md @@ -0,0 +1,20 @@ +# A field belongs to the plugin that introduced it + +Status: accepted + +The plugin engine records which plugin first introduced each field key (via `addList` or `extendList`); fields declared by the application belong to the app. An `extendList` from plugin P that redeclares a field owned by a **different plugin** throws a config error naming P, the owner, the list and the field. Redeclaring an app-declared field, or P's own field, is allowed and replaces per key as before. + +## Context + +`extendList` merges fields as `{ ...existing.fields, ...extension.fields }`, so a later plugin silently discarded an earlier plugin's field-level `access`, hooks and options. ADR-0013 closed the operation-level equivalent; this closes the field-level one. Plugins legitimately refine fields by redeclaring them (RAG defaults `dimensions` and wires `autoGenerate` on its own and app-declared `embedding()` fields), so a blanket refusal was not an option. + +## Considered options + +- **Ownership guard (chosen).** Loud, no composition semantics, leaves legitimate refinement intact. +- **Deep-merge a field's `access`.** Rejected: access rules return booleans or Where conditions, composing them is surprising (same reason as ADR-0013), and it still lets a plugin alter a field it does not own. +- **Opt-out flag.** Rejected: a plugin-held flag lets the plugin re-silence the overwrite, as in ADR-0013. + +## Consequences + +- Ownership is engine-internal state, not exposed on the resolved config. +- Hook chaining and the operation-access refusal are unchanged. diff --git a/packages/core/src/config/plugin-engine.test.ts b/packages/core/src/config/plugin-engine.test.ts index affa5ba68..19c742631 100644 --- a/packages/core/src/config/plugin-engine.test.ts +++ b/packages/core/src/config/plugin-engine.test.ts @@ -471,6 +471,78 @@ describe('Plugin Engine', () => { expect(result.lists.Post.fields.views).toBeDefined() }) + describe('field ownership', () => { + const run = (plugins: Plugin[], app = true) => + executePlugins({ + db: { provider: 'postgresql' }, + lists: app ? { Post: { fields: { title: text() } } } : {}, + plugins, + }) + const a: Plugin = { + name: 'a', + init: async (c) => c.extendList('Post', { fields: { summary: text() } }), + } + + test('throws when a plugin redeclares a field another plugin extended in', async () => { + const b: Plugin = { + name: 'b', + dependencies: ['a'], + init: async (c) => c.extendList('Post', { fields: { summary: text() } }), + } + await expect(run([a, b])).rejects.toThrow( + /Plugin "b".*"summary".*"Post".*introduced by plugin "a"/, + ) + }) + + test('throws when the field was introduced through addList', async () => { + const adder: Plugin = { + name: 'a', + init: async (c) => c.addList('Note', { fields: { body: text() } }), + } + const b: Plugin = { + name: 'b', + dependencies: ['a'], + init: async (c) => c.extendList('Note', { fields: { body: text() } }), + } + await expect(run([adder, b])).rejects.toThrow(/"body".*"Note".*introduced by plugin "a"/) + }) + + test('allows a plugin to redeclare its own field', async () => { + const self: Plugin = { + name: 'a', + init: async (c) => { + c.extendList('Post', { fields: { summary: text() } }) + c.extendList('Post', { fields: { summary: integer() } }) + }, + } + const result = await run([self]) + expect(result.lists.Post.fields.summary.type).toBe('integer') + }) + + test('allows a plugin to redeclare an app-declared field', async () => { + const p: Plugin = { + name: 'p', + init: async (c) => c.extendList('Post', { fields: { title: integer() } }), + } + const result = await run([p]) + expect(result.lists.Post.fields.title.type).toBe('integer') + }) + + test('allows a plugin to add a new field to a list another plugin created', async () => { + const adder: Plugin = { + name: 'a', + init: async (c) => c.addList('Note', { fields: { body: text() } }), + } + const b: Plugin = { + name: 'b', + dependencies: ['a'], + init: async (c) => c.extendList('Note', { fields: { tag: text() } }), + } + const result = await run([adder, b]) + expect(result.lists.Note.fields.tag).toBeDefined() + }) + }) + test('throws when extending non-existent list', async () => { const plugin: Plugin = { name: 'test-plugin', diff --git a/packages/core/src/config/plugin-engine.ts b/packages/core/src/config/plugin-engine.ts index 20d37cfac..3677c9813 100644 --- a/packages/core/src/config/plugin-engine.ts +++ b/packages/core/src/config/plugin-engine.ts @@ -156,6 +156,15 @@ export async function executePlugins(config: OpenSaasConfig): Promise fieldKey -> the plugin that introduced it. A field absent here + // belongs to the application. + const fieldOwners = new Map>() + const claimFields = (listKey: string, fieldKeys: string[], owner: string) => { + const owners = fieldOwners.get(listKey) ?? new Map() + for (const key of fieldKeys) if (!owners.has(key)) owners.set(key, owner) + fieldOwners.set(listKey, owners) + } + for (const plugin of sortedPlugins) { const context: PluginContext = { config: currentConfig, @@ -174,6 +183,7 @@ export async function executePlugins(config: OpenSaasConfig): Promise { @@ -205,11 +215,25 @@ export async function executePlugins(config: OpenSaasConfig): Promise Date: Wed, 7 Oct 2026 11:31:56 +0000 Subject: [PATCH 2/2] fix(core): keep app-declared fields app-owned across plugin refinements Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Cmjr3Cq9wqd6xok31R15hJ --- packages/core/src/config/plugin-engine.test.ts | 14 ++++++++++++++ packages/core/src/config/plugin-engine.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/core/src/config/plugin-engine.test.ts b/packages/core/src/config/plugin-engine.test.ts index 19c742631..4de444c57 100644 --- a/packages/core/src/config/plugin-engine.test.ts +++ b/packages/core/src/config/plugin-engine.test.ts @@ -528,6 +528,20 @@ describe('Plugin Engine', () => { expect(result.lists.Post.fields.title.type).toBe('integer') }) + test('keeps an app-declared field app-owned across successive plugin refinements', async () => { + const first: Plugin = { + name: 'first', + init: async (c) => c.extendList('Post', { fields: { title: integer() } }), + } + const second: Plugin = { + name: 'second', + dependencies: ['first'], + init: async (c) => c.extendList('Post', { fields: { title: text() } }), + } + const result = await run([first, second]) + expect(result.lists.Post.fields.title.type).toBe('text') + }) + test('allows a plugin to add a new field to a list another plugin created', async () => { const adder: Plugin = { name: 'a', diff --git a/packages/core/src/config/plugin-engine.ts b/packages/core/src/config/plugin-engine.ts index 3677c9813..faceedc4d 100644 --- a/packages/core/src/config/plugin-engine.ts +++ b/packages/core/src/config/plugin-engine.ts @@ -232,7 +232,11 @@ export async function executePlugins(config: OpenSaasConfig): Promise !Object.hasOwn(existing.fields, key)), + plugin.name, + ) const mergedHooks = mergeHooks(existing.hooks, extension.hooks)