Skip to content

Commit fc7e7f7

Browse files
os-zhuangclaude
andauthored
feat(metadata-protocol): enforce package namespace prefix for Studio-authored packages (#2694)
* feat(metadata-protocol): enforce package namespace prefix for Studio-authored packages The protocol requires every object.name to carry its package's manifest.namespace prefix (crm_account); defineStack enforces this at compile time. Studio/runtime- authored packages never took that path and were created without a namespace at all, so the rule was silently inert and objects published with bare, collision- prone names (e.g. two packages could each publish `leave_request`). - installPackage: derive a default namespace from the package id when the manifest declares none (com.example.leave → leave) and persist it on the manifest; an explicit namespace always wins (HotCRM's crm is untouched). - publishPackageDrafts: reject any object draft missing the <ns>_ prefix before promoting anything (atomic), with an actionable "rename to ..." message. Namespace-less packages are grandfathered, mirroring defineStack. The per-object check and id→namespace derivation are extracted to @objectstack/spec/kernel as the single source shared by defineStack and the runtime publish path, so the two enforcement points cannot drift. Found by dogfooding package-first authoring: a Studio-built package (com.example.leave) persisted namespace=null, so its object leave_request had no prefix and published with zero validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(spec): regenerate api-surface for new namespace-prefix exports Additive-only: registers validateObjectNamespacePrefix and deriveNamespaceFromPackageId (both new exports from @objectstack/spec/kernel) in the frozen API-surface snapshot. Consistent with the minor bump already in the changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 799b285 commit fc7e7f7

9 files changed

Lines changed: 274 additions & 17 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
Enforce the package namespace-prefix rule for Studio-authored packages.
7+
8+
The protocol requires every object name in a package to carry the package's
9+
`manifest.namespace` prefix (`crm_account`); `defineStack()` enforces this at
10+
compile time via `validateNamespacePrefix`. Studio/runtime-authored packages
11+
never take that path, and they were created without a namespace at all — so the
12+
rule was silently inert and objects published with bare, collision-prone names.
13+
14+
Two runtime changes close the gap:
15+
16+
- `protocol.installPackage` now derives a default namespace from the package id
17+
(`com.example.leave` → `leave`) when the manifest declares none, and persists
18+
it on the manifest (in-memory registry + `sys_packages`). An explicitly
19+
declared namespace always wins (e.g. HotCRM's `crm`).
20+
- `protocol.publishPackageDrafts` now rejects any object draft whose name lacks
21+
the package namespace prefix, before promoting anything (atomic), with an
22+
actionable message (`Rename it to 'leave_ticket'`). Packages that declare no
23+
namespace are grandfathered — mirroring `defineStack`, the rule is not
24+
invented at enforcement time.
25+
26+
The per-object prefix check and the id→namespace derivation are extracted into
27+
`@objectstack/spec/kernel` (`validateObjectNamespacePrefix`,
28+
`deriveNamespaceFromPackageId`) as the single source shared by `defineStack` and
29+
the runtime publish path, so the two enforcement points cannot drift.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type MetadataLock,
3030
type MetadataProvenance,
3131
} from '@objectstack/spec/kernel';
32+
import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel';
3233
import { z } from 'zod';
3334
import {
3435
computeMetadataDiagnostics,
@@ -4446,6 +4447,37 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
44464447
const repo = this.getOverlayRepo(orgId);
44474448
const drafts = await repo.listDrafts({ packageId: request.packageId });
44484449

4450+
// Runtime enforcement of the package namespace-prefix rule (ADR-0028
4451+
// current-state contract). `defineStack` enforces this at compile time,
4452+
// but Studio-authored packages never take that path — so a bare,
4453+
// collision-prone object name (`ticket` instead of `leave_ticket`)
4454+
// could publish unchecked. Read the package's DECLARED namespace and
4455+
// reject any object draft missing the `<ns>_` prefix BEFORE promoting
4456+
// anything — the publish is atomic, so one bad name fails the whole
4457+
// batch with an actionable message. Like `defineStack`, we do NOT
4458+
// invent a prefix here when the package declares no namespace (legacy
4459+
// packages are grandfathered); the default is derived+persisted once at
4460+
// install time (`installPackage`), so real Studio packages always have
4461+
// one by the time they publish.
4462+
const pkgNamespace = this.engine?.registry?.getPackage?.(request.packageId)?.manifest?.namespace;
4463+
if (pkgNamespace) {
4464+
const nsViolations: Array<{ type: string; name: string; error: string; code: string }> = [];
4465+
for (const d of drafts) {
4466+
if (d.type !== 'object') continue;
4467+
const err = validateObjectNamespacePrefix(d.name, pkgNamespace);
4468+
if (err) nsViolations.push({ type: d.type, name: d.name, error: err, code: 'NAMESPACE_PREFIX' });
4469+
}
4470+
if (nsViolations.length > 0) {
4471+
return {
4472+
success: false,
4473+
publishedCount: 0,
4474+
failedCount: nsViolations.length,
4475+
published: [],
4476+
failed: nsViolations,
4477+
};
4478+
}
4479+
}
4480+
44494481
const published: Array<{ type: string; name: string; version: string }> = [];
44504482
const failed: Array<{ type: string; name: string; error: string; code?: string; issues?: Array<{ path: string; message: string; code?: string }> }> = [];
44514483

@@ -5928,6 +5960,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
59285960
manifest.version = '0.1.0';
59295961
}
59305962

5963+
// Studio-authored writable packages arrive WITHOUT a namespace. The
5964+
// protocol mandates a package namespace whose prefix every object name
5965+
// must carry (manifest.zod `namespace`); `defineStack` enforces it at
5966+
// compile time, but runtime-created packages never take that path — so
5967+
// the rule was silently inert for them. Derive a default namespace from
5968+
// the package id (`com.example.leave` → `leave`) so the prefix can be
5969+
// enforced at publish. An explicitly declared namespace always wins.
5970+
// Set it on the single `manifest` object shared by the in-memory
5971+
// registry and the durable `sys_packages` row below, so both agree.
5972+
if (typeof manifest.namespace !== 'string' || !manifest.namespace) {
5973+
const derived = deriveNamespaceFromPackageId(manifest.id);
5974+
if (derived) manifest.namespace = derived;
5975+
}
5976+
59315977
// ADR-0087 D1 — protocol handshake. Refuse a package whose declared
59325978
// `engines.protocol` range excludes this runtime's major BEFORE writing
59335979
// it to the registry, with a structured diagnostic naming the migrate

‎packages/objectql/src/protocol-install-package.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,36 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => {
3838
message: string;
3939
};
4040

41-
expect(registry.installPackage).toHaveBeenCalledWith(manifest, undefined);
41+
// The registry + durable row receive the manifest with a namespace derived
42+
// from the id (`app.demo` → `demo`) since none was declared (see below).
43+
expect(registry.installPackage).toHaveBeenCalledWith(
44+
{ ...manifest, namespace: 'demo' },
45+
undefined,
46+
);
4247
expect(publish).toHaveBeenCalledTimes(1);
43-
expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({ manifest });
48+
expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({
49+
manifest: { ...manifest, namespace: 'demo' },
50+
});
4451
expect(res.package.manifest.id).toBe('app.demo');
4552
expect(res.message).toContain('app.demo');
4653
});
4754

55+
it('derives + persists a namespace from the id when the manifest declares none', async () => {
56+
const { protocol, registry } = makeProtocol();
57+
const manifest = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'application' };
58+
await protocol.installPackage({ manifest } as never);
59+
// `com.example.leave` → namespace `leave` (last dot-segment).
60+
expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('leave');
61+
});
62+
63+
it('does NOT override an explicitly declared namespace', async () => {
64+
const { protocol, registry } = makeProtocol();
65+
// HotCRM ships namespace `crm`, which differs from the id last segment.
66+
const manifest = { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '1.0.0', type: 'application', namespace: 'crm' };
67+
await protocol.installPackage({ manifest } as never);
68+
expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('crm');
69+
});
70+
4871
it('forwards install-time settings to the registry', async () => {
4972
const { protocol, registry } = makeProtocol();
5073
const manifest = { id: 'app.s', name: 'S', version: '1.0.0', type: 'application' };

‎packages/objectql/src/protocol-publish-package-drafts.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,50 @@ describe('protocol.publishPackageDrafts (ADR-0033)', () => {
3737
expect(res.published.map((p) => p.name)).toEqual(['course', 'student', 'course_list']);
3838
});
3939

40+
it('rejects an object draft missing the package namespace prefix — atomic, before promoting', async () => {
41+
const { protocol, publishMetaItem } = makeProtocol([
42+
{ type: 'object', name: 'edu_course' },
43+
{ type: 'object', name: 'ticket' }, // missing the 'edu_' prefix
44+
]);
45+
// Package declares namespace 'edu' (derived+persisted at install time).
46+
(protocol as any).engine = { registry: { getPackage: () => ({ manifest: { namespace: 'edu' } }) } };
47+
48+
const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });
49+
50+
expect(publishMetaItem).not.toHaveBeenCalled(); // aborted BEFORE any promote
51+
expect(res.success).toBe(false);
52+
expect(res.publishedCount).toBe(0);
53+
expect(res.failedCount).toBe(1);
54+
expect(res.failed[0]).toMatchObject({ type: 'object', name: 'ticket', code: 'NAMESPACE_PREFIX' });
55+
expect(res.failed[0].error).toMatch(/Rename it to 'edu_ticket'/);
56+
});
57+
58+
it('publishes compliant prefixed object drafts under a declared namespace', async () => {
59+
const { protocol, publishMetaItem } = makeProtocol([
60+
{ type: 'object', name: 'edu_course' },
61+
{ type: 'object', name: 'edu_student' },
62+
]);
63+
(protocol as any).engine = { registry: { getPackage: () => ({ manifest: { namespace: 'edu' } }) } };
64+
publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);
65+
66+
const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });
67+
68+
expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 });
69+
});
70+
71+
it('skips the namespace check when the package declares no namespace (legacy grandfathered)', async () => {
72+
// No registry / no declared namespace → bare names still publish, exactly
73+
// as before this rule existed (mirrors defineStack's absent-namespace skip).
74+
const { protocol, publishMetaItem } = makeProtocol([
75+
{ type: 'object', name: 'course' }, // bare name, no prefix
76+
]);
77+
publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never);
78+
79+
const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' });
80+
81+
expect(res).toMatchObject({ success: true, publishedCount: 1 });
82+
});
83+
4084
it('collects per-item failures without aborting the rest', async () => {
4185
const { protocol, publishMetaItem } = makeProtocol([
4286
{ type: 'object', name: 'course' },

‎packages/spec/api-surface.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1699,6 +1699,7 @@
16991699
"VersionConstraint (type)",
17001700
"VersionConstraintSchema (const)",
17011701
"VulnerabilitySeverity (type)",
1702+
"deriveNamespaceFromPackageId (function)",
17021703
"evaluateLockForDelete (function)",
17031704
"evaluateLockForWrite (function)",
17041705
"extractProtection (function)",
@@ -1710,7 +1711,8 @@
17101711
"listMetadataTypeSchemaTypes (function)",
17111712
"registerMetadataTypeActions (function)",
17121713
"registerMetadataTypeSchema (function)",
1713-
"resolveLockState (function)"
1714+
"resolveLockState (function)",
1715+
"validateObjectNamespacePrefix (function)"
17141716
],
17151717
"./ai": [
17161718
"AIKnowledgeSchema (const)",

‎packages/spec/src/kernel/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './events.zod';
99
export * from './feature.zod';
1010
export * from './manifest.zod';
1111
export * from './metadata-customization.zod';
12+
export * from './namespace-prefix';
1213
export * from './metadata-loader.zod';
1314
export * from './metadata-plugin.zod';
1415
export * from './metadata-protection.zod';
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 } from 'vitest';
4+
import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from './namespace-prefix';
5+
6+
describe('validateObjectNamespacePrefix', () => {
7+
it('returns null for a compliant prefixed name', () => {
8+
expect(validateObjectNamespacePrefix('todo_task', 'todo')).toBeNull();
9+
});
10+
11+
it('flags a missing prefix and suggests the fix', () => {
12+
const err = validateObjectNamespacePrefix('task', 'todo');
13+
expect(err).toMatch(/missing the package namespace prefix/);
14+
expect(err).toMatch(/Rename it to 'todo_task'/);
15+
});
16+
17+
it('flags the legacy double-underscore FQN form', () => {
18+
const err = validateObjectNamespacePrefix('todo__task', 'todo');
19+
expect(err).toMatch(/legacy FQN form/);
20+
expect(err).toMatch(/Rename it to 'todo_task'/);
21+
});
22+
23+
it('always allows sys_-prefixed names', () => {
24+
expect(validateObjectNamespacePrefix('sys_user', 'todo')).toBeNull();
25+
});
26+
27+
it('skips the check when namespace is absent', () => {
28+
expect(validateObjectNamespacePrefix('task', undefined)).toBeNull();
29+
expect(validateObjectNamespacePrefix('task', '')).toBeNull();
30+
});
31+
32+
it('skips the check when object name is absent', () => {
33+
expect(validateObjectNamespacePrefix(undefined, 'todo')).toBeNull();
34+
});
35+
});
36+
37+
describe('deriveNamespaceFromPackageId', () => {
38+
it('derives from the last dot-segment of a reverse-DNS id', () => {
39+
expect(deriveNamespaceFromPackageId('com.example.leave')).toBe('leave');
40+
expect(deriveNamespaceFromPackageId('com.example.showcase')).toBe('showcase');
41+
expect(deriveNamespaceFromPackageId('app.objectstack.hotcrm')).toBe('hotcrm');
42+
});
43+
44+
it('handles a bare single-segment id', () => {
45+
expect(deriveNamespaceFromPackageId('myapp')).toBe('myapp');
46+
});
47+
48+
it('sanitizes hyphens and mixed case to the namespace charset', () => {
49+
expect(deriveNamespaceFromPackageId('com.acme.Field-Service')).toBe('field_service');
50+
});
51+
52+
it('returns null when nothing valid can be derived', () => {
53+
expect(deriveNamespaceFromPackageId('')).toBeNull();
54+
expect(deriveNamespaceFromPackageId(undefined)).toBeNull();
55+
expect(deriveNamespaceFromPackageId('com.example.x')).toBeNull(); // single char < 2
56+
expect(deriveNamespaceFromPackageId('com.example.123')).toBeNull(); // must start with a letter
57+
});
58+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Namespace-prefix rules (ADR-0028 current-state contract).
5+
*
6+
* A package declares a short `manifest.namespace`; every `object.name` it
7+
* defines MUST be `${namespace}_${shortName}` (except platform-reserved
8+
* `sys_*` objects). See {@link file://../kernel/manifest.zod.ts} `namespace`.
9+
*
10+
* These helpers are the SINGLE source of that rule so the two enforcement
11+
* points cannot drift:
12+
* - `defineStack()` / `os validate` (compile-time) — `validateNamespacePrefix`
13+
* in `stack.zod.ts`.
14+
* - `MetadataManager.publishPackage()` (runtime, Studio "全部发布").
15+
*/
16+
17+
/** Namespace charset accepted by `manifest.namespace` (2-20 chars). */
18+
const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/;
19+
20+
/**
21+
* Validate a single object name against a package namespace prefix.
22+
*
23+
* Returns an actionable error message, or `null` when the name is compliant.
24+
* `sys_*` names are platform-reserved and always allowed. When `namespace` is
25+
* empty the check is skipped (returns `null`) — callers decide whether an
26+
* absent namespace is itself an error.
27+
*/
28+
export function validateObjectNamespacePrefix(
29+
objectName: string | undefined,
30+
namespace: string | undefined,
31+
): string | null {
32+
if (!objectName || !namespace) return null;
33+
if (objectName.startsWith('sys_')) return null;
34+
35+
const expectedPrefix = `${namespace}_`;
36+
if (objectName.includes('__')) {
37+
return `Object '${objectName}' uses the legacy FQN form '<ns>__<short>'. Rename it to '${expectedPrefix}${objectName.slice(objectName.indexOf('__') + 2)}'.`;
38+
}
39+
if (!objectName.startsWith(expectedPrefix)) {
40+
return `Object '${objectName}' is missing the package namespace prefix. Rename it to '${expectedPrefix}${objectName}' (namespace = '${namespace}').`;
41+
}
42+
return null;
43+
}
44+
45+
/**
46+
* Derive a default namespace from a package id when the manifest declares none.
47+
*
48+
* Uses the last dot-segment of the id (`com.example.leave` → `leave`),
49+
* lowercased and sanitized to the namespace charset. Returns `null` when
50+
* nothing valid can be derived (caller then leaves the namespace unset rather
51+
* than inventing a bad one). This only supplies a DEFAULT for packages that
52+
* omit `namespace`; an explicitly declared namespace always wins.
53+
*/
54+
export function deriveNamespaceFromPackageId(packageId: string | undefined): string | null {
55+
if (!packageId) return null;
56+
const seg = packageId.split('.').pop() ?? packageId;
57+
const ns = seg
58+
.toLowerCase()
59+
.replace(/[^a-z0-9_]/g, '_') // non-charset → underscore
60+
.replace(/^[^a-z]+/, '') // must start with a letter
61+
.slice(0, 20);
62+
return NAMESPACE_RE.test(ns) ? ns : null;
63+
}

‎packages/spec/src/stack.zod.ts‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44

55
import { ManifestSchema } from './kernel/manifest.zod';
6+
import { validateObjectNamespacePrefix } from './kernel/namespace-prefix';
67
import { ClusterCapabilityConfigSchema } from './kernel/cluster.zod';
78
import { DatasourceSchema } from './data/datasource.zod';
89
import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod';
@@ -496,21 +497,11 @@ function validateNamespacePrefix(config: ObjectStackDefinition): string[] {
496497
const ns = config.manifest?.namespace;
497498
if (!ns || !config.objects) return errors;
498499

499-
const expectedPrefix = `${ns}_`;
500+
// Single source of the per-object prefix rule — shared verbatim with the
501+
// runtime publish enforcement in MetadataManager.publishPackage.
500502
for (const obj of config.objects) {
501-
if (!obj.name) continue;
502-
if (obj.name.startsWith('sys_')) continue;
503-
if (obj.name.includes('__')) {
504-
errors.push(
505-
`Object '${obj.name}' uses the legacy FQN form '<ns>__<short>'. Rename it to '${expectedPrefix}${obj.name.slice(obj.name.indexOf('__') + 2)}'.`,
506-
);
507-
continue;
508-
}
509-
if (!obj.name.startsWith(expectedPrefix)) {
510-
errors.push(
511-
`Object '${obj.name}' is missing the package namespace prefix. Rename it to '${expectedPrefix}${obj.name}' (manifest.namespace = '${ns}').`,
512-
);
513-
}
503+
const err = validateObjectNamespacePrefix(obj.name, ns);
504+
if (err) errors.push(err);
514505
}
515506
return errors;
516507
}

0 commit comments

Comments
 (0)