Skip to content

Commit 9de6807

Browse files
os-zhuangclaude
andcommitted
feat(metadata): metadata-service registrations carry package provenance
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 <noreply@anthropic.com>
1 parent 2796a1f commit 9de6807

8 files changed

Lines changed: 242 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/metadata": minor
4+
---
5+
6+
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.

packages/metadata/src/plugin.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,70 @@ describe('MetadataPlugin._parseAndRegisterArtifact — view name resolution (PR-
110110
expect(label).toBe('Service Workflow');
111111
});
112112
});
113+
114+
// ─────────────────────────────────────────────────────────────────────────
115+
// Filesystem-scanner provenance: when the host declares its package id
116+
// (options.packageId — the project's `defineStack` manifest id), scanned
117+
// source-file metadata must be stamped `_packageId`/`_provenance` exactly
118+
// like the artifact path, so GET /meta consumers (objectui
119+
// NavigationSyncEffect) can tell code-defined items from user-authored
120+
// rows. Without the option, items must stay unstamped — `_packageId`
121+
// feeds isArtifactBacked() write authorization.
122+
// ─────────────────────────────────────────────────────────────────────────
123+
describe('MetadataPlugin._loadFromFileSystem — package provenance stamping', () => {
124+
const fakeCtx = {
125+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
126+
} as any;
127+
128+
it('stamps _packageId/_provenance on scanned items when options.packageId is set', async () => {
129+
const plugin = new MetadataPlugin({
130+
watch: false,
131+
config: { bootstrap: 'eager' },
132+
packageId: 'com.example.proj',
133+
});
134+
const mgr = (plugin as any).manager as NodeMetadataManager;
135+
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
136+
type === 'page' ? [{ name: 'home_page', label: 'Home' }] : []);
137+
138+
await (plugin as any)._loadFromFileSystem(fakeCtx);
139+
140+
const item = await mgr.get('page', 'home_page') as any;
141+
expect(item).toBeDefined();
142+
expect(item._packageId).toBe('com.example.proj');
143+
expect(item._provenance).toBe('package');
144+
});
145+
146+
it('does not overwrite an item\'s pre-existing _packageId', async () => {
147+
const plugin = new MetadataPlugin({
148+
watch: false,
149+
config: { bootstrap: 'eager' },
150+
packageId: 'com.example.proj',
151+
});
152+
const mgr = (plugin as any).manager as NodeMetadataManager;
153+
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
154+
type === 'page' ? [{ name: 'vendor_page', _packageId: 'com.vendor.pkg' }] : []);
155+
156+
await (plugin as any)._loadFromFileSystem(fakeCtx);
157+
158+
const item = await mgr.get('page', 'vendor_page') as any;
159+
expect(item._packageId).toBe('com.vendor.pkg');
160+
expect(item._provenance).toBe('package');
161+
});
162+
163+
it('leaves scanned items unstamped when options.packageId is not configured', async () => {
164+
const plugin = new MetadataPlugin({
165+
watch: false,
166+
config: { bootstrap: 'eager' },
167+
});
168+
const mgr = (plugin as any).manager as NodeMetadataManager;
169+
vi.spyOn(mgr, 'loadMany').mockImplementation(async (type: string) =>
170+
type === 'page' ? [{ name: 'plain_page', label: 'Plain' }] : []);
171+
172+
await (plugin as any)._loadFromFileSystem(fakeCtx);
173+
174+
const item = await mgr.get('page', 'plain_page') as any;
175+
expect(item).toBeDefined();
176+
expect(item._packageId).toBeUndefined();
177+
expect(item._provenance).toBeUndefined();
178+
});
179+
});

packages/metadata/src/plugin.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,20 @@ export interface MetadataPluginOptions {
141141
* business-data namespaces.
142142
*/
143143
registerSystemObjects?: boolean;
144+
/**
145+
* Owning package id for source-file metadata loaded by the filesystem
146+
* scanner (`watch`/eager mode) — the project's `defineStack({ manifest:
147+
* { id } })` id. When set, scanned items are stamped with
148+
* `_packageId`/`_provenance: 'package'` via `applyProtection`, exactly
149+
* like the artifact path, so GET /meta consumers can tell code-defined
150+
* metadata from user-authored rows.
151+
*
152+
* Leave unset when the host has no package identity — items then stay
153+
* unstamped (runtime-authored semantics). Do NOT pass a guessed value:
154+
* `_packageId` feeds `isArtifactBacked()` write authorization, so a
155+
* wrong id silently changes who may edit these items.
156+
*/
157+
packageId?: string;
144158
}
145159

146160
export class MetadataPlugin implements Plugin {
@@ -654,6 +668,15 @@ export class MetadataPlugin implements Plugin {
654668
for (const item of items) {
655669
const meta = item as any;
656670
if (meta?.name) {
671+
// Stamp package provenance when the host declared
672+
// its package id (see MetadataPluginOptions.packageId)
673+
// — same applyProtection call the artifact path
674+
// uses, so both load paths produce identical
675+
// _packageId/_provenance state. No-op when the
676+
// option is unset or the item is already stamped.
677+
applyProtection(meta, {
678+
packageId: this.options.packageId,
679+
});
657680
await this.manager.register(entry.type, meta.name, item);
658681
}
659682
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { SchemaRegistry } from './registry';
5+
import { MetadataFacade } from './metadata-facade';
6+
7+
describe('MetadataFacade provenance passthrough', () => {
8+
let registry: SchemaRegistry;
9+
let facade: MetadataFacade;
10+
11+
beforeEach(() => {
12+
registry = new SchemaRegistry({ multiTenant: false });
13+
facade = new MetadataFacade(registry);
14+
});
15+
16+
it('passes the item\'s own _packageId through to registerItem so _provenance is stamped', async () => {
17+
await facade.register('page', 'installed_apps', {
18+
name: 'installed_apps',
19+
_packageId: 'com.example.marketplace',
20+
});
21+
22+
const item = registry.getItem<any>('page', 'installed_apps');
23+
expect(item).toBeDefined();
24+
expect(item._packageId).toBe('com.example.marketplace');
25+
expect(item._provenance).toBe('package');
26+
27+
// listItems is what protocol.getMetaItems serves from — the stamp
28+
// must survive enumeration too.
29+
const listed = registry.listItems<any>('page');
30+
expect(listed).toHaveLength(1);
31+
expect(listed[0]._packageId).toBe('com.example.marketplace');
32+
expect(listed[0]._provenance).toBe('package');
33+
});
34+
35+
it('leaves runtime-authored items (no _packageId) unstamped', async () => {
36+
await facade.register('page', 'my_user_page', { name: 'my_user_page' });
37+
38+
const item = registry.getItem<any>('page', 'my_user_page');
39+
expect(item).toBeDefined();
40+
expect(item._packageId).toBeUndefined();
41+
expect(item._provenance).toBeUndefined();
42+
});
43+
44+
it('never invents a synthetic package id for object registrations', async () => {
45+
await facade.register('object', 'task', {
46+
name: 'task',
47+
label: 'Task',
48+
fields: {},
49+
});
50+
51+
// getItem('object', …) routes to the merged-object path, so read the
52+
// generic collection directly to inspect what register() stored.
53+
const stored = (registry as any).metadata.get('object')?.get('task');
54+
expect(stored).toBeDefined();
55+
expect(stored._packageId).toBeUndefined();
56+
expect(stored._provenance).toBeUndefined();
57+
});
58+
});

packages/objectql/src/metadata-facade.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ export class MetadataFacade {
2626
const definition = typeof data === 'object' && data !== null
2727
? { ...data, name: data.name ?? name }
2828
: data;
29+
// Pass through the item's own source package id (when stamped by an
30+
// artifact loader) so provenance survives re-registration. Never
31+
// synthesize one here — unstamped items are runtime-authored by
32+
// definition and must not become artifact-backed (protocol.ts
33+
// isArtifactBacked gates write authorization on _packageId).
34+
const packageId = definition?._packageId;
2935
if (type === 'object') {
30-
this.registry.registerItem(type, definition, 'name' as any);
36+
this.registry.registerItem(type, definition, 'name' as any, packageId);
3137
} else {
32-
this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any);
38+
this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any, packageId);
3339
}
3440
}
3541

packages/objectql/src/plugin.integration.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,55 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
298298
// Note: The actual sync happens in start phase
299299
// We can verify by checking if ObjectQL detected external service
300300
});
301+
302+
it('passes each item\'s _packageId through so synced items carry provenance', async () => {
303+
// Arrange — a view stamped by an artifact loader (carries _packageId)
304+
// and a runtime-authored view (no stamp). The sync must preserve the
305+
// distinction: stamped items get _provenance, unstamped stay clean.
306+
const packagedView = {
307+
name: 'pkg_view',
308+
label: 'Packaged View',
309+
_packageId: 'com.example.crm',
310+
};
311+
const runtimeView = {
312+
name: 'user_view',
313+
label: 'User View',
314+
};
315+
316+
const mockMetadataService = {
317+
load: async () => null,
318+
loadMany: async (type: string) => (type === 'view' ? [packagedView, runtimeView] : []),
319+
save: async () => ({ success: true, path: '/test' }),
320+
exists: async () => false,
321+
list: async () => []
322+
};
323+
324+
await kernel.use({
325+
name: 'mock-metadata',
326+
type: 'metadata',
327+
version: '1.0.0',
328+
init: async (ctx) => {
329+
ctx.registerService('metadata', mockMetadataService);
330+
}
331+
});
332+
333+
await kernel.use(new ObjectQLPlugin());
334+
335+
// Act
336+
await kernel.bootstrap();
337+
338+
// Assert
339+
const objectql = kernel.getService('objectql') as any;
340+
const synced = objectql.registry.getItem('view', 'pkg_view');
341+
expect(synced).toBeDefined();
342+
expect(synced._packageId).toBe('com.example.crm');
343+
expect(synced._provenance).toBe('package');
344+
345+
const runtime = objectql.registry.getItem('view', 'user_view');
346+
expect(runtime).toBeDefined();
347+
expect(runtime._packageId).toBeUndefined();
348+
expect(runtime._provenance).toBeUndefined();
349+
});
301350
});
302351

303352
describe('Schema Sync on Start', () => {

packages/objectql/src/plugin.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -893,9 +893,18 @@ export class ObjectQLPlugin implements Plugin {
893893
return;
894894
}
895895

896-
// Register other types in the registry
896+
// Register other types in the registry. Pass through
897+
// the item's own source package id (stamped by the
898+
// metadata plugin's artifact loader) so registerItem's
899+
// applyProtection re-stamps _packageId/_provenance and
900+
// GET /meta consumers can tell package-shipped items
901+
// from user-authored ones. Items without _packageId
902+
// (FS project files, runtime-authored rows) must stay
903+
// unstamped — a synthetic id like 'metadata-service'
904+
// would flip isArtifactBacked() and the two-tier write
905+
// authorization for genuinely runtime-authored items.
897906
if (this.ql?.registry?.registerItem) {
898-
this.ql.registry.registerItem(type, item, keyField);
907+
this.ql.registry.registerItem(type, item, keyField, item._packageId);
899908
}
900909
});
901910

packages/objectql/src/protocol-meta.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,26 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
757757
});
758758
});
759759

760+
it('serves _packageId/_provenance for items registered with a source package id', async () => {
761+
// Package-shipped item — registered with its real package id, as
762+
// the engine manifest path and the metadata-service sync both do.
763+
registry.registerItem('page', { name: 'pkg_page', label: 'Pkg Page' }, 'name', 'com.example.crm');
764+
// Runtime-authored item — no package id, must stay unstamped so
765+
// clients can tell the two apart (objectui NavigationSyncEffect).
766+
registry.registerItem('page', { name: 'user_page', label: 'User Page' }, 'name');
767+
mockEngine.find.mockResolvedValue([]);
768+
769+
const result = await protocol.getMetaItems({ type: 'page' });
770+
771+
const pkgPage = result.items.find((i: any) => i.name === 'pkg_page');
772+
expect(pkgPage._packageId).toBe('com.example.crm');
773+
expect(pkgPage._provenance).toBe('package');
774+
775+
const userPage = result.items.find((i: any) => i.name === 'user_page');
776+
expect(userPage._packageId).toBeUndefined();
777+
expect(userPage._provenance).toBeUndefined();
778+
});
779+
760780
it('should fall back to DB when registry is empty for type', async () => {
761781
mockEngine.find.mockResolvedValue([
762782
{

0 commit comments

Comments
 (0)