From 9de680734d9f62e41f93bc8460342daf8ff7450a Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 12 Jun 2026 14:17:27 +0500 Subject: [PATCH] feat(metadata): metadata-service registrations carry package provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata-service sync path (loadMetadataFromService) and MetadataFacade.register called registry.registerItem without the 4th packageId param, so applyProtection never stamped _packageId / _provenance on synced items — GET /api/v1/meta/:type consumers (objectui NavigationSyncEffect, objectui PR #1668) could not tell package-shipped code-defined metadata from user-authored rows and fell back to the sys_ name-prefix heuristic, misclassifying third-party plugin pages. Fix: pass through each item's own _packageId (stamped upstream by the metadata plugin's artifact loader). Never synthesize an id like 'metadata-service' — isArtifactBacked() keys two-tier write authorization (allowOrgOverride vs allowRuntimeCreate) off a truthy _packageId, so a fake id would flip authorization for runtime-authored items. The nearby bindHooks packageId is an unbind-grouping label, not provenance. Deliberately unchanged: protocol.ts DB-hydration registerItem calls (loadMetaFromDb / getMetaItems hydration / overlay-delete refill) stay bare-key — ADR-0005 overlay precedence relies on the bare runtime row shadowing the composite pkg:name artifact entry. Also closes the filesystem-scanner gap: new MetadataPluginOptions .packageId (the project's defineStack manifest id) lets hand-wired kernels stamp scanned source-file metadata via the same applyProtection call the artifact path uses. Unset → unchanged behavior; the option is never guessed from package.json (npm name ≠ manifest id, and the value feeds write authorization). Co-Authored-By: Claude Fable 5 --- .changeset/metadata-provenance-stamping.md | 6 ++ packages/metadata/src/plugin.test.ts | 67 +++++++++++++++++++ packages/metadata/src/plugin.ts | 23 +++++++ packages/objectql/src/metadata-facade.test.ts | 58 ++++++++++++++++ packages/objectql/src/metadata-facade.ts | 10 ++- .../objectql/src/plugin.integration.test.ts | 49 ++++++++++++++ packages/objectql/src/plugin.ts | 13 +++- packages/objectql/src/protocol-meta.test.ts | 20 ++++++ 8 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 .changeset/metadata-provenance-stamping.md create mode 100644 packages/objectql/src/metadata-facade.test.ts diff --git a/.changeset/metadata-provenance-stamping.md b/.changeset/metadata-provenance-stamping.md new file mode 100644 index 0000000000..7ae6d8748b --- /dev/null +++ b/.changeset/metadata-provenance-stamping.md @@ -0,0 +1,6 @@ +--- +"@objectstack/objectql": minor +"@objectstack/metadata": minor +--- + +Metadata registered through the metadata-service path now carries package provenance. `loadMetadataFromService` and `MetadataFacade.register` pass each item's own `_packageId` through to `registry.registerItem` so `applyProtection` stamps `_packageId`/`_provenance: 'package'` (never a synthetic id — `isArtifactBacked()` write authorization keys off `_packageId`). New `MetadataPluginOptions.packageId` lets hosts running the filesystem scanner declare the owning package id for scanned source-file metadata, closing the same gap for hand-wired kernels. GET /api/v1/meta/:type consumers (e.g. objectui NavigationSyncEffect) can now distinguish package-shipped items from user-authored rows without name heuristics. diff --git a/packages/metadata/src/plugin.test.ts b/packages/metadata/src/plugin.test.ts index 5657594da5..df72a3f7ae 100644 --- a/packages/metadata/src/plugin.test.ts +++ b/packages/metadata/src/plugin.test.ts @@ -110,3 +110,70 @@ describe('MetadataPlugin._parseAndRegisterArtifact — view name resolution (PR- expect(label).toBe('Service Workflow'); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// Filesystem-scanner provenance: when the host declares its package id +// (options.packageId — the project's `defineStack` manifest id), scanned +// source-file metadata must be stamped `_packageId`/`_provenance` exactly +// like the artifact path, so GET /meta consumers (objectui +// NavigationSyncEffect) can tell code-defined items from user-authored +// rows. Without the option, items must stay unstamped — `_packageId` +// feeds isArtifactBacked() write authorization. +// ───────────────────────────────────────────────────────────────────────── +describe('MetadataPlugin._loadFromFileSystem — package provenance stamping', () => { + const fakeCtx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } as any; + + it('stamps _packageId/_provenance on scanned items when options.packageId is set', async () => { + const plugin = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'eager' }, + packageId: 'com.example.proj', + }); + const mgr = (plugin as any).manager as NodeMetadataManager; + vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) => + type === 'page' ? [{ name: 'home_page', label: 'Home' }] : []); + + await (plugin as any)._loadFromFileSystem(fakeCtx); + + const item = await mgr.get('page', 'home_page') as any; + expect(item).toBeDefined(); + expect(item._packageId).toBe('com.example.proj'); + expect(item._provenance).toBe('package'); + }); + + it('does not overwrite an item\'s pre-existing _packageId', async () => { + const plugin = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'eager' }, + packageId: 'com.example.proj', + }); + const mgr = (plugin as any).manager as NodeMetadataManager; + vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) => + type === 'page' ? [{ name: 'vendor_page', _packageId: 'com.vendor.pkg' }] : []); + + await (plugin as any)._loadFromFileSystem(fakeCtx); + + const item = await mgr.get('page', 'vendor_page') as any; + expect(item._packageId).toBe('com.vendor.pkg'); + expect(item._provenance).toBe('package'); + }); + + it('leaves scanned items unstamped when options.packageId is not configured', async () => { + const plugin = new MetadataPlugin({ + watch: false, + config: { bootstrap: 'eager' }, + }); + const mgr = (plugin as any).manager as NodeMetadataManager; + vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) => + type === 'page' ? [{ name: 'plain_page', label: 'Plain' }] : []); + + await (plugin as any)._loadFromFileSystem(fakeCtx); + + const item = await mgr.get('page', 'plain_page') as any; + expect(item).toBeDefined(); + expect(item._packageId).toBeUndefined(); + expect(item._provenance).toBeUndefined(); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 0dfc10871a..a46c9c5061 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -141,6 +141,20 @@ export interface MetadataPluginOptions { * business-data namespaces. */ registerSystemObjects?: boolean; + /** + * Owning package id for source-file metadata loaded by the filesystem + * scanner (`watch`/eager mode) — the project's `defineStack({ manifest: + * { id } })` id. When set, scanned items are stamped with + * `_packageId`/`_provenance: 'package'` via `applyProtection`, exactly + * like the artifact path, so GET /meta consumers can tell code-defined + * metadata from user-authored rows. + * + * Leave unset when the host has no package identity — items then stay + * unstamped (runtime-authored semantics). Do NOT pass a guessed value: + * `_packageId` feeds `isArtifactBacked()` write authorization, so a + * wrong id silently changes who may edit these items. + */ + packageId?: string; } export class MetadataPlugin implements Plugin { @@ -654,6 +668,15 @@ export class MetadataPlugin implements Plugin { for (const item of items) { const meta = item as any; if (meta?.name) { + // Stamp package provenance when the host declared + // its package id (see MetadataPluginOptions.packageId) + // — same applyProtection call the artifact path + // uses, so both load paths produce identical + // _packageId/_provenance state. No-op when the + // option is unset or the item is already stamped. + applyProtection(meta, { + packageId: this.options.packageId, + }); await this.manager.register(entry.type, meta.name, item); } } diff --git a/packages/objectql/src/metadata-facade.test.ts b/packages/objectql/src/metadata-facade.test.ts new file mode 100644 index 0000000000..850746d434 --- /dev/null +++ b/packages/objectql/src/metadata-facade.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry'; +import { MetadataFacade } from './metadata-facade'; + +describe('MetadataFacade provenance passthrough', () => { + let registry: SchemaRegistry; + let facade: MetadataFacade; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + facade = new MetadataFacade(registry); + }); + + it('passes the item\'s own _packageId through to registerItem so _provenance is stamped', async () => { + await facade.register('page', 'installed_apps', { + name: 'installed_apps', + _packageId: 'com.example.marketplace', + }); + + const item = registry.getItem('page', 'installed_apps'); + expect(item).toBeDefined(); + expect(item._packageId).toBe('com.example.marketplace'); + expect(item._provenance).toBe('package'); + + // listItems is what protocol.getMetaItems serves from — the stamp + // must survive enumeration too. + const listed = registry.listItems('page'); + expect(listed).toHaveLength(1); + expect(listed[0]._packageId).toBe('com.example.marketplace'); + expect(listed[0]._provenance).toBe('package'); + }); + + it('leaves runtime-authored items (no _packageId) unstamped', async () => { + await facade.register('page', 'my_user_page', { name: 'my_user_page' }); + + const item = registry.getItem('page', 'my_user_page'); + expect(item).toBeDefined(); + expect(item._packageId).toBeUndefined(); + expect(item._provenance).toBeUndefined(); + }); + + it('never invents a synthetic package id for object registrations', async () => { + await facade.register('object', 'task', { + name: 'task', + label: 'Task', + fields: {}, + }); + + // getItem('object', …) routes to the merged-object path, so read the + // generic collection directly to inspect what register() stored. + const stored = (registry as any).metadata.get('object')?.get('task'); + expect(stored).toBeDefined(); + expect(stored._packageId).toBeUndefined(); + expect(stored._provenance).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/metadata-facade.ts b/packages/objectql/src/metadata-facade.ts index 716c9c17cf..61abb1420d 100644 --- a/packages/objectql/src/metadata-facade.ts +++ b/packages/objectql/src/metadata-facade.ts @@ -26,10 +26,16 @@ export class MetadataFacade { const definition = typeof data === 'object' && data !== null ? { ...data, name: data.name ?? name } : data; + // Pass through the item's own source package id (when stamped by an + // artifact loader) so provenance survives re-registration. Never + // synthesize one here — unstamped items are runtime-authored by + // definition and must not become artifact-backed (protocol.ts + // isArtifactBacked gates write authorization on _packageId). + const packageId = definition?._packageId; if (type === 'object') { - this.registry.registerItem(type, definition, 'name' as any); + this.registry.registerItem(type, definition, 'name' as any, packageId); } else { - this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any); + this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any, packageId); } } diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index f9cf285bab..dbe68a5965 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -298,6 +298,55 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // Note: The actual sync happens in start phase // We can verify by checking if ObjectQL detected external service }); + + it('passes each item\'s _packageId through so synced items carry provenance', async () => { + // Arrange — a view stamped by an artifact loader (carries _packageId) + // and a runtime-authored view (no stamp). The sync must preserve the + // distinction: stamped items get _provenance, unstamped stay clean. + const packagedView = { + name: 'pkg_view', + label: 'Packaged View', + _packageId: 'com.example.crm', + }; + const runtimeView = { + name: 'user_view', + label: 'User View', + }; + + const mockMetadataService = { + load: async () => null, + loadMany: async (type: string) => (type === 'view' ? [packagedView, runtimeView] : []), + save: async () => ({ success: true, path: '/test' }), + exists: async () => false, + list: async () => [] + }; + + await kernel.use({ + name: 'mock-metadata', + type: 'metadata', + version: '1.0.0', + init: async (ctx) => { + ctx.registerService('metadata', mockMetadataService); + } + }); + + await kernel.use(new ObjectQLPlugin()); + + // Act + await kernel.bootstrap(); + + // Assert + const objectql = kernel.getService('objectql') as any; + const synced = objectql.registry.getItem('view', 'pkg_view'); + expect(synced).toBeDefined(); + expect(synced._packageId).toBe('com.example.crm'); + expect(synced._provenance).toBe('package'); + + const runtime = objectql.registry.getItem('view', 'user_view'); + expect(runtime).toBeDefined(); + expect(runtime._packageId).toBeUndefined(); + expect(runtime._provenance).toBeUndefined(); + }); }); describe('Schema Sync on Start', () => { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 6c0e48b04f..e989b2644a 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -893,9 +893,18 @@ export class ObjectQLPlugin implements Plugin { return; } - // Register other types in the registry + // Register other types in the registry. Pass through + // the item's own source package id (stamped by the + // metadata plugin's artifact loader) so registerItem's + // applyProtection re-stamps _packageId/_provenance and + // GET /meta consumers can tell package-shipped items + // from user-authored ones. Items without _packageId + // (FS project files, runtime-authored rows) must stay + // unstamped — a synthetic id like 'metadata-service' + // would flip isArtifactBacked() and the two-tier write + // authorization for genuinely runtime-authored items. if (this.ql?.registry?.registerItem) { - this.ql.registry.registerItem(type, item, keyField); + this.ql.registry.registerItem(type, item, keyField, item._packageId); } }); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index e20bd7c213..258b7373f9 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -757,6 +757,26 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); }); + it('serves _packageId/_provenance for items registered with a source package id', async () => { + // Package-shipped item — registered with its real package id, as + // the engine manifest path and the metadata-service sync both do. + registry.registerItem('page', { name: 'pkg_page', label: 'Pkg Page' }, 'name', 'com.example.crm'); + // Runtime-authored item — no package id, must stay unstamped so + // clients can tell the two apart (objectui NavigationSyncEffect). + registry.registerItem('page', { name: 'user_page', label: 'User Page' }, 'name'); + mockEngine.find.mockResolvedValue([]); + + const result = await protocol.getMetaItems({ type: 'page' }); + + const pkgPage = result.items.find((i: any) => i.name === 'pkg_page'); + expect(pkgPage._packageId).toBe('com.example.crm'); + expect(pkgPage._provenance).toBe('package'); + + const userPage = result.items.find((i: any) => i.name === 'user_page'); + expect(userPage._packageId).toBeUndefined(); + expect(userPage._provenance).toBeUndefined(); + }); + it('should fall back to DB when registry is empty for type', async () => { mockEngine.find.mockResolvedValue([ {