diff --git a/.changeset/studio-namespace-prefix.md b/.changeset/studio-namespace-prefix.md new file mode 100644 index 000000000..e6966cc3e --- /dev/null +++ b/.changeset/studio-namespace-prefix.md @@ -0,0 +1,29 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/spec": minor +--- + +Enforce the package namespace-prefix rule for Studio-authored packages. + +The protocol requires every object name in a package to carry the package's +`manifest.namespace` prefix (`crm_account`); `defineStack()` enforces this at +compile time via `validateNamespacePrefix`. Studio/runtime-authored packages +never take that path, and they were created without a namespace at all — so the +rule was silently inert and objects published with bare, collision-prone names. + +Two runtime changes close the gap: + +- `protocol.installPackage` now derives a default namespace from the package id + (`com.example.leave` → `leave`) when the manifest declares none, and persists + it on the manifest (in-memory registry + `sys_packages`). An explicitly + declared namespace always wins (e.g. HotCRM's `crm`). +- `protocol.publishPackageDrafts` now rejects any object draft whose name lacks + the package namespace prefix, before promoting anything (atomic), with an + actionable message (`Rename it to 'leave_ticket'`). Packages that declare no + namespace are grandfathered — mirroring `defineStack`, the rule is not + invented at enforcement time. + +The per-object prefix check and the id→namespace derivation are extracted into +`@objectstack/spec/kernel` (`validateObjectNamespacePrefix`, +`deriveNamespaceFromPackageId`) as the single source shared by `defineStack` and +the runtime publish path, so the two enforcement points cannot drift. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index afc5f5945..bcfc40107 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -29,6 +29,7 @@ import { type MetadataLock, type MetadataProvenance, } from '@objectstack/spec/kernel'; +import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; import { z } from 'zod'; import { computeMetadataDiagnostics, @@ -4446,6 +4447,37 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { const repo = this.getOverlayRepo(orgId); const drafts = await repo.listDrafts({ packageId: request.packageId }); + // Runtime enforcement of the package namespace-prefix rule (ADR-0028 + // current-state contract). `defineStack` enforces this at compile time, + // but Studio-authored packages never take that path — so a bare, + // collision-prone object name (`ticket` instead of `leave_ticket`) + // could publish unchecked. Read the package's DECLARED namespace and + // reject any object draft missing the `_` prefix BEFORE promoting + // anything — the publish is atomic, so one bad name fails the whole + // batch with an actionable message. Like `defineStack`, we do NOT + // invent a prefix here when the package declares no namespace (legacy + // packages are grandfathered); the default is derived+persisted once at + // install time (`installPackage`), so real Studio packages always have + // one by the time they publish. + const pkgNamespace = this.engine?.registry?.getPackage?.(request.packageId)?.manifest?.namespace; + if (pkgNamespace) { + const nsViolations: Array<{ type: string; name: string; error: string; code: string }> = []; + for (const d of drafts) { + if (d.type !== 'object') continue; + const err = validateObjectNamespacePrefix(d.name, pkgNamespace); + if (err) nsViolations.push({ type: d.type, name: d.name, error: err, code: 'NAMESPACE_PREFIX' }); + } + if (nsViolations.length > 0) { + return { + success: false, + publishedCount: 0, + failedCount: nsViolations.length, + published: [], + failed: nsViolations, + }; + } + } + const published: Array<{ type: string; name: string; version: string }> = []; const failed: Array<{ type: string; name: string; error: string; code?: string; issues?: Array<{ path: string; message: string; code?: string }> }> = []; @@ -5928,6 +5960,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { manifest.version = '0.1.0'; } + // Studio-authored writable packages arrive WITHOUT a namespace. The + // protocol mandates a package namespace whose prefix every object name + // must carry (manifest.zod `namespace`); `defineStack` enforces it at + // compile time, but runtime-created packages never take that path — so + // the rule was silently inert for them. Derive a default namespace from + // the package id (`com.example.leave` → `leave`) so the prefix can be + // enforced at publish. An explicitly declared namespace always wins. + // Set it on the single `manifest` object shared by the in-memory + // registry and the durable `sys_packages` row below, so both agree. + if (typeof manifest.namespace !== 'string' || !manifest.namespace) { + const derived = deriveNamespaceFromPackageId(manifest.id); + if (derived) manifest.namespace = derived; + } + // ADR-0087 D1 — protocol handshake. Refuse a package whose declared // `engines.protocol` range excludes this runtime's major BEFORE writing // it to the registry, with a structured diagnostic naming the migrate diff --git a/packages/objectql/src/protocol-install-package.test.ts b/packages/objectql/src/protocol-install-package.test.ts index 75041caea..186857359 100644 --- a/packages/objectql/src/protocol-install-package.test.ts +++ b/packages/objectql/src/protocol-install-package.test.ts @@ -38,13 +38,36 @@ describe('protocol.installPackage (ADR-0033 consolidation)', () => { message: string; }; - expect(registry.installPackage).toHaveBeenCalledWith(manifest, undefined); + // The registry + durable row receive the manifest with a namespace derived + // from the id (`app.demo` → `demo`) since none was declared (see below). + expect(registry.installPackage).toHaveBeenCalledWith( + { ...manifest, namespace: 'demo' }, + undefined, + ); expect(publish).toHaveBeenCalledTimes(1); - expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({ manifest }); + expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({ + manifest: { ...manifest, namespace: 'demo' }, + }); expect(res.package.manifest.id).toBe('app.demo'); expect(res.message).toContain('app.demo'); }); + it('derives + persists a namespace from the id when the manifest declares none', async () => { + const { protocol, registry } = makeProtocol(); + const manifest = { id: 'com.example.leave', name: 'Leave', version: '1.0.0', type: 'application' }; + await protocol.installPackage({ manifest } as never); + // `com.example.leave` → namespace `leave` (last dot-segment). + expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('leave'); + }); + + it('does NOT override an explicitly declared namespace', async () => { + const { protocol, registry } = makeProtocol(); + // HotCRM ships namespace `crm`, which differs from the id last segment. + const manifest = { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '1.0.0', type: 'application', namespace: 'crm' }; + await protocol.installPackage({ manifest } as never); + expect((registry.installPackage.mock.calls[0][0] as any).namespace).toBe('crm'); + }); + it('forwards install-time settings to the registry', async () => { const { protocol, registry } = makeProtocol(); const manifest = { id: 'app.s', name: 'S', version: '1.0.0', type: 'application' }; diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index dde5cc245..273dcfbb8 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -37,6 +37,50 @@ describe('protocol.publishPackageDrafts (ADR-0033)', () => { expect(res.published.map((p) => p.name)).toEqual(['course', 'student', 'course_list']); }); + it('rejects an object draft missing the package namespace prefix — atomic, before promoting', async () => { + const { protocol, publishMetaItem } = makeProtocol([ + { type: 'object', name: 'edu_course' }, + { type: 'object', name: 'ticket' }, // missing the 'edu_' prefix + ]); + // Package declares namespace 'edu' (derived+persisted at install time). + (protocol as any).engine = { registry: { getPackage: () => ({ manifest: { namespace: 'edu' } }) } }; + + const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' }); + + expect(publishMetaItem).not.toHaveBeenCalled(); // aborted BEFORE any promote + expect(res.success).toBe(false); + expect(res.publishedCount).toBe(0); + expect(res.failedCount).toBe(1); + expect(res.failed[0]).toMatchObject({ type: 'object', name: 'ticket', code: 'NAMESPACE_PREFIX' }); + expect(res.failed[0].error).toMatch(/Rename it to 'edu_ticket'/); + }); + + it('publishes compliant prefixed object drafts under a declared namespace', async () => { + const { protocol, publishMetaItem } = makeProtocol([ + { type: 'object', name: 'edu_course' }, + { type: 'object', name: 'edu_student' }, + ]); + (protocol as any).engine = { registry: { getPackage: () => ({ manifest: { namespace: 'edu' } }) } }; + publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never); + + const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' }); + + expect(res).toMatchObject({ success: true, publishedCount: 2, failedCount: 0 }); + }); + + it('skips the namespace check when the package declares no namespace (legacy grandfathered)', async () => { + // No registry / no declared namespace → bare names still publish, exactly + // as before this rule existed (mirrors defineStack's absent-namespace skip). + const { protocol, publishMetaItem } = makeProtocol([ + { type: 'object', name: 'course' }, // bare name, no prefix + ]); + publishMetaItem.mockResolvedValue({ success: true, version: 'h', seq: 1 } as never); + + const res = await protocol.publishPackageDrafts({ packageId: 'app.edu' }); + + expect(res).toMatchObject({ success: true, publishedCount: 1 }); + }); + it('collects per-item failures without aborting the rest', async () => { const { protocol, publishMetaItem } = makeProtocol([ { type: 'object', name: 'course' }, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 0d738dff5..ccf94f95f 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1724,6 +1724,7 @@ "VersionConstraint (type)", "VersionConstraintSchema (const)", "VulnerabilitySeverity (type)", + "deriveNamespaceFromPackageId (function)", "evaluateLockForDelete (function)", "evaluateLockForWrite (function)", "extractProtection (function)", @@ -1735,7 +1736,8 @@ "listMetadataTypeSchemaTypes (function)", "registerMetadataTypeActions (function)", "registerMetadataTypeSchema (function)", - "resolveLockState (function)" + "resolveLockState (function)", + "validateObjectNamespacePrefix (function)" ], "./ai": [ "AIKnowledgeSchema (const)", diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 611e26000..a724e8a64 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -9,6 +9,7 @@ export * from './events.zod'; export * from './feature.zod'; export * from './manifest.zod'; export * from './metadata-customization.zod'; +export * from './namespace-prefix'; export * from './metadata-loader.zod'; export * from './metadata-plugin.zod'; export * from './metadata-protection.zod'; diff --git a/packages/spec/src/kernel/namespace-prefix.test.ts b/packages/spec/src/kernel/namespace-prefix.test.ts new file mode 100644 index 000000000..2b8c05748 --- /dev/null +++ b/packages/spec/src/kernel/namespace-prefix.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { validateObjectNamespacePrefix, deriveNamespaceFromPackageId } from './namespace-prefix'; + +describe('validateObjectNamespacePrefix', () => { + it('returns null for a compliant prefixed name', () => { + expect(validateObjectNamespacePrefix('todo_task', 'todo')).toBeNull(); + }); + + it('flags a missing prefix and suggests the fix', () => { + const err = validateObjectNamespacePrefix('task', 'todo'); + expect(err).toMatch(/missing the package namespace prefix/); + expect(err).toMatch(/Rename it to 'todo_task'/); + }); + + it('flags the legacy double-underscore FQN form', () => { + const err = validateObjectNamespacePrefix('todo__task', 'todo'); + expect(err).toMatch(/legacy FQN form/); + expect(err).toMatch(/Rename it to 'todo_task'/); + }); + + it('always allows sys_-prefixed names', () => { + expect(validateObjectNamespacePrefix('sys_user', 'todo')).toBeNull(); + }); + + it('skips the check when namespace is absent', () => { + expect(validateObjectNamespacePrefix('task', undefined)).toBeNull(); + expect(validateObjectNamespacePrefix('task', '')).toBeNull(); + }); + + it('skips the check when object name is absent', () => { + expect(validateObjectNamespacePrefix(undefined, 'todo')).toBeNull(); + }); +}); + +describe('deriveNamespaceFromPackageId', () => { + it('derives from the last dot-segment of a reverse-DNS id', () => { + expect(deriveNamespaceFromPackageId('com.example.leave')).toBe('leave'); + expect(deriveNamespaceFromPackageId('com.example.showcase')).toBe('showcase'); + expect(deriveNamespaceFromPackageId('app.objectstack.hotcrm')).toBe('hotcrm'); + }); + + it('handles a bare single-segment id', () => { + expect(deriveNamespaceFromPackageId('myapp')).toBe('myapp'); + }); + + it('sanitizes hyphens and mixed case to the namespace charset', () => { + expect(deriveNamespaceFromPackageId('com.acme.Field-Service')).toBe('field_service'); + }); + + it('returns null when nothing valid can be derived', () => { + expect(deriveNamespaceFromPackageId('')).toBeNull(); + expect(deriveNamespaceFromPackageId(undefined)).toBeNull(); + expect(deriveNamespaceFromPackageId('com.example.x')).toBeNull(); // single char < 2 + expect(deriveNamespaceFromPackageId('com.example.123')).toBeNull(); // must start with a letter + }); +}); diff --git a/packages/spec/src/kernel/namespace-prefix.ts b/packages/spec/src/kernel/namespace-prefix.ts new file mode 100644 index 000000000..ce273ce4d --- /dev/null +++ b/packages/spec/src/kernel/namespace-prefix.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Namespace-prefix rules (ADR-0028 current-state contract). + * + * A package declares a short `manifest.namespace`; every `object.name` it + * defines MUST be `${namespace}_${shortName}` (except platform-reserved + * `sys_*` objects). See {@link file://../kernel/manifest.zod.ts} `namespace`. + * + * These helpers are the SINGLE source of that rule so the two enforcement + * points cannot drift: + * - `defineStack()` / `os validate` (compile-time) — `validateNamespacePrefix` + * in `stack.zod.ts`. + * - `MetadataManager.publishPackage()` (runtime, Studio "全部发布"). + */ + +/** Namespace charset accepted by `manifest.namespace` (2-20 chars). */ +const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/; + +/** + * Validate a single object name against a package namespace prefix. + * + * Returns an actionable error message, or `null` when the name is compliant. + * `sys_*` names are platform-reserved and always allowed. When `namespace` is + * empty the check is skipped (returns `null`) — callers decide whether an + * absent namespace is itself an error. + */ +export function validateObjectNamespacePrefix( + objectName: string | undefined, + namespace: string | undefined, +): string | null { + if (!objectName || !namespace) return null; + if (objectName.startsWith('sys_')) return null; + + const expectedPrefix = `${namespace}_`; + if (objectName.includes('__')) { + return `Object '${objectName}' uses the legacy FQN form '__'. Rename it to '${expectedPrefix}${objectName.slice(objectName.indexOf('__') + 2)}'.`; + } + if (!objectName.startsWith(expectedPrefix)) { + return `Object '${objectName}' is missing the package namespace prefix. Rename it to '${expectedPrefix}${objectName}' (namespace = '${namespace}').`; + } + return null; +} + +/** + * Derive a default namespace from a package id when the manifest declares none. + * + * Uses the last dot-segment of the id (`com.example.leave` → `leave`), + * lowercased and sanitized to the namespace charset. Returns `null` when + * nothing valid can be derived (caller then leaves the namespace unset rather + * than inventing a bad one). This only supplies a DEFAULT for packages that + * omit `namespace`; an explicitly declared namespace always wins. + */ +export function deriveNamespaceFromPackageId(packageId: string | undefined): string | null { + if (!packageId) return null; + const seg = packageId.split('.').pop() ?? packageId; + const ns = seg + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') // non-charset → underscore + .replace(/^[^a-z]+/, '') // must start with a letter + .slice(0, 20); + return NAMESPACE_RE.test(ns) ? ns : null; +} diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 56b1bb33a..ee1982993 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ManifestSchema } from './kernel/manifest.zod'; +import { validateObjectNamespacePrefix } from './kernel/namespace-prefix'; import { ClusterCapabilityConfigSchema } from './kernel/cluster.zod'; import { DatasourceSchema } from './data/datasource.zod'; import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod'; @@ -496,21 +497,11 @@ function validateNamespacePrefix(config: ObjectStackDefinition): string[] { const ns = config.manifest?.namespace; if (!ns || !config.objects) return errors; - const expectedPrefix = `${ns}_`; + // Single source of the per-object prefix rule — shared verbatim with the + // runtime publish enforcement in MetadataManager.publishPackage. for (const obj of config.objects) { - if (!obj.name) continue; - if (obj.name.startsWith('sys_')) continue; - if (obj.name.includes('__')) { - errors.push( - `Object '${obj.name}' uses the legacy FQN form '__'. Rename it to '${expectedPrefix}${obj.name.slice(obj.name.indexOf('__') + 2)}'.`, - ); - continue; - } - if (!obj.name.startsWith(expectedPrefix)) { - errors.push( - `Object '${obj.name}' is missing the package namespace prefix. Rename it to '${expectedPrefix}${obj.name}' (manifest.namespace = '${ns}').`, - ); - } + const err = validateObjectNamespacePrefix(obj.name, ns); + if (err) errors.push(err); } return errors; }