Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/studio-namespace-prefix.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<ns>_` 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 }> }> = [];

Expand Down Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions packages/objectql/src/protocol-install-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
44 changes: 44 additions & 0 deletions packages/objectql/src/protocol-publish-package-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,7 @@
"VersionConstraint (type)",
"VersionConstraintSchema (const)",
"VulnerabilitySeverity (type)",
"deriveNamespaceFromPackageId (function)",
"evaluateLockForDelete (function)",
"evaluateLockForWrite (function)",
"extractProtection (function)",
Expand All @@ -1735,7 +1736,8 @@
"listMetadataTypeSchemaTypes (function)",
"registerMetadataTypeActions (function)",
"registerMetadataTypeSchema (function)",
"resolveLockState (function)"
"resolveLockState (function)",
"validateObjectNamespacePrefix (function)"
],
"./ai": [
"AIKnowledgeSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
58 changes: 58 additions & 0 deletions packages/spec/src/kernel/namespace-prefix.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
63 changes: 63 additions & 0 deletions packages/spec/src/kernel/namespace-prefix.ts
Original file line number Diff line number Diff line change
@@ -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 '<ns>__<short>'. 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;
}
19 changes: 5 additions & 14 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 '<ns>__<short>'. 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;
}
Expand Down