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
48 changes: 48 additions & 0 deletions .changeset/publish-payload-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
---

feat(spec,cli): carry the package namespace on the publish payload (#6760)

ADR-0048's addendum defines a publish-time namespace exclusivity registry
(`namespace → publisher`), so a cross-vendor namespace collision is caught while
exactly one party can still fix it cheaply — the publisher, before anything ships
— instead of surfacing at install time, where the tenant who suffers it can do
nothing. That gate is enterprise-side (Phase A2), and it could not be built
because the namespace never left the artifact: `PackageSchema` had no
`namespace` field at all, `CreatePackageRequestSchema` did not accept one, and
`objectstack package publish` transmitted `manifest_id` only. This is Phase A1,
the open-side half that gives the gate an input.

**`PackageSchema` and `CreatePackageRequestSchema` gain an optional
`namespace`.** It mirrors `manifest.namespace` exactly — same 2-20 character
rule (`/^[a-z][a-z0-9_]{1,19}$/`), same optionality — so the publish payload and
the artifact manifest cannot disagree about what a namespace is. Optional is the
ruled shape, not a convenience: the addendum's algorithm opens with
`if (namespace is absent) -> allow`, and a package that declares no namespace
makes no reservation and is not gated. A parity test judges both fields against
one table of values, so a change to either side fails.

**`objectstack package publish` sends it, read off the compiled artifact's
`manifest.namespace`** — the same place the command already reads
`manifest.id`. Three behaviours, matching the addendum's algorithm:

- namespace present → it travels on the `POST /cloud/packages` body as
`namespace`, and is echoed in the publish summary;
- namespace absent → the key is omitted entirely (not `null`, not `''`), so
"declares no namespace" never becomes a value the gate has to interpret;
- namespace malformed → the publish is refused before any network call, naming
the rule and the fix.

The namespace is deliberately **not** overridable by a flag or by
`objectstack.manifest.json`, unlike `manifestId`: a reservation is only
meaningful if it names the object-name prefix the package actually ships, and a
second declaration surface would let a publisher reserve `foo` while installing
`bar_*` objects. For the same reason `TemplateManifestSchema` omits the field
rather than inheriting it.

Nothing about install-time behaviour changes. The in-process install gate,
`NamespaceConflictError`, the shareable `base`/`system`/`sys` set and the
`OS_METADATA_COLLISION=warn` downgrade are untouched, and the install path
acquires no network dependency.
2 changes: 2 additions & 0 deletions content/docs/references/cloud/package.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Register a new package in the Control Plane
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **manifestId** | `string` | ✅ | Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") |
| **ownerOrgId** | `string` | ✅ | Owner organization ID |
| **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace |
| **description** | `string` | optional | Short package description |
Expand All @@ -69,6 +70,7 @@ Register a new package in the Control Plane
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | UUID of the package (stable, never reused) |
| **manifestId** | `string` | ✅ | Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") |
| **ownerOrgId** | `string` | ✅ | Organization ID of the package owner/publisher |
| **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace |
| **description** | `string` | optional | Short package description |
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/commands/package/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.

const MANIFEST_ID_RE = /^[a-z0-9][a-z0-9._-]{0,254}$/i;

/**
* Mirror of `manifest.namespace`'s pattern in `@objectstack/spec`
* (`kernel/manifest.zod.ts`, and `cloud/package.zod.ts` for the publish
* payload): 2-20 chars, a lowercase letter followed by lowercase letters,
* digits or underscores. Pinned against the spec schema in
* `test/package-publish.test.ts`.
*/
export const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/;

function slugify(input: string): string {
return input
.toLowerCase()
Expand Down Expand Up @@ -53,6 +62,28 @@ function deriveManifestId(artifact: any, artifactPath: string): string {
return `local.${slugify(basename(artifactPath).replace(/\.json$/i, ''))}`;
}

/**
* Read the metadata namespace off the COMPILED ARTIFACT's manifest — the same
* place {@link deriveManifestId} reads `manifest.id`.
*
* ADR-0048 addendum §A.2 (Phase A1) requires the namespace to travel with the
* publish payload so the publish-time exclusivity gate has an input to check.
* It is deliberately NOT overridable by a flag or by `objectstack.manifest.json`
* (unlike `manifestId`): the namespace is the physical object-name prefix baked
* into the artifact at build time, and a reservation that names a different
* string than the package actually ships would be worse than no reservation —
* a publisher could reserve `foo` while installing `bar_*` objects.
*
* Returns `undefined` when the artifact declares no namespace. That is a
* supported artifact shape (`manifest.namespace` is optional), and §A.2's
* algorithm opens with `if (namespace is absent) -> allow`.
*/
function readArtifactNamespace(artifact: any): string | undefined {
const ns = artifact?.manifest?.namespace;
if (typeof ns !== 'string' || !ns.trim()) return undefined;
return ns.trim();
}

function deriveDisplayName(artifact: any, manifestId: string): string {
const n = artifact?.manifest?.name;
if (typeof n === 'string' && n.trim()) return n.trim();
Expand Down Expand Up @@ -294,6 +325,21 @@ export default class PackagePublish extends Command {
this.exit(1);
return;
}
// ADR-0048 addendum §A.2 Phase A1 — the namespace must reach the control
// plane, and it must be the artifact's real one. A malformed value is
// refused here, before any network call: silently dropping it would
// publish an artifact whose namespace the exclusivity gate never sees,
// which is precisely the hole this phase exists to close.
const namespace = readArtifactNamespace(artifact);
if (namespace !== undefined && !NAMESPACE_RE.test(namespace)) {
printError(
`Invalid manifest.namespace '${namespace}' in the artifact. Expected 2-20 characters: ` +
'a lowercase letter followed by lowercase letters, digits or underscores ' +
"(e.g. 'crm'). Fix `manifest.namespace` in objectstack.config.ts and rebuild.",
);
this.exit(1);
return;
}
const displayName = (
flags['display-name']
?? (typeof m.displayName === 'string' ? m.displayName : undefined)
Expand Down Expand Up @@ -339,6 +385,11 @@ export default class PackagePublish extends Command {
display_name: displayName,
visibility: flags.visibility,
};
// Absent namespace ⇒ absent key. `CreatePackageRequestSchema.namespace`
// is optional and §A.2 allows an unnamespaced publish; sending `null` or
// `''` would turn "declares no namespace" into a value the gate has to
// interpret.
if (namespace) pkgBody.namespace = namespace;
const desc = flags.description ?? (typeof m.description === 'string' ? m.description : undefined);
if (desc) pkgBody.description = desc;
const cat = flags.category ?? (typeof m.category === 'string' ? m.category : undefined);
Expand Down Expand Up @@ -516,6 +567,7 @@ export default class PackagePublish extends Command {
printSuccess('Package published');
printKV(' Package', manifestId);
printKV(' Package ID', String(pkg?.id ?? '—'));
if (namespace) printKV(' Namespace', namespace);
printKV(' Version', String(ver?.version ?? version));
printKV(' Version ID', String(ver?.id ?? '—'));
if (ver?.checksum) printKV(' Checksum', String(ver.checksum).slice(0, 16));
Expand Down
170 changes: 170 additions & 0 deletions packages/cli/test/package-publish-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0048 addendum §A.2 Phase A1 — `os package publish` carries the artifact's
* namespace to the control plane.
*
* The publish-time exclusivity gate (Phase A2, enterprise-side) reads the
* namespace off the publish payload. Before this phase the namespace never left
* the artifact, so the gate had nothing to check. These cases pin the three
* behaviours the addendum's algorithm distinguishes: a namespace present (it
* travels), a namespace absent (`if (namespace is absent) -> allow` — the key
* is simply not sent), and a namespace that is not a namespace (refused before
* any network call).
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CreatePackageRequestSchema } from '@objectstack/spec/cloud';
import PackagePublish, { NAMESPACE_RE } from '../src/commands/package/publish.js';

type Call = { url: string; body: any };

function artifactJson(manifest: Record<string, unknown>): string {
return JSON.stringify({
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0', ...manifest },
objects: [],
});
}

/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */
function stubCloud(): Call[] {
const calls: Call[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
calls.push({ url, body: JSON.parse(init.body) });
const data = url.endsWith('/versions')
? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' }
: { id: 'pkg_1', created: true, visibility: 'org' };
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any;
}));
return calls;
}

describe('os package publish — namespace on the publish payload', () => {
let dir = '';
const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY };
const prevCwd = process.cwd();

afterEach(async () => {
process.chdir(prevCwd);
vi.unstubAllGlobals();
vi.restoreAllMocks();
process.env.OS_CLOUD_URL = prevEnv.url;
process.env.OS_CLOUD_API_KEY = prevEnv.key;
if (dir) await rm(dir, { recursive: true, force: true });
});

async function artifactAt(manifest: Record<string, unknown>): Promise<string> {
dir = await mkdtemp(join(tmpdir(), 'package-publish-ns-'));
const path = join(dir, 'objectstack.json');
await writeFile(path, artifactJson(manifest));
process.env.OS_CLOUD_URL = 'http://cloud.test';
process.env.OS_CLOUD_API_KEY = 'tok_123';
return path;
}

it('sends `namespace` read off the compiled artifact manifest', async () => {
const path = await artifactAt({ namespace: 'crm' });
const calls = stubCloud();

await PackagePublish.run([path]);

expect(calls).toHaveLength(2);
expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages');
expect(calls[0].body).toMatchObject({ manifest_id: 'com.acme.crm', namespace: 'crm' });
// The value the CLI puts on the wire is one the acceptance face accepts.
expect(CreatePackageRequestSchema.shape.namespace.safeParse(calls[0].body.namespace).success).toBe(true);
});

// Reverse verification, direction 1: an artifact with NO namespace must not
// grow one. §A.2 allows an absent namespace, and the key must be absent
// rather than null/'' so the gate never has to interpret an empty value.
it('omits the key entirely when the artifact declares no namespace', async () => {
const path = await artifactAt({});
const calls = stubCloud();

await PackagePublish.run([path]);

expect(calls).toHaveLength(2);
expect('namespace' in calls[0].body).toBe(false);
expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
});

// Reverse verification, direction 2: a malformed namespace is refused, and
// refused BEFORE the network call — silently dropping it would publish an
// artifact whose namespace the A2 gate never sees.
it('refuses a malformed namespace with exit code 1 and never calls the cloud', async () => {
const path = await artifactAt({ namespace: 'CRM-App' });
const calls = stubCloud();
const errors: string[] = [];
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
errors.push(args.map(String).join(' '));
});
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
errors.push(args.map(String).join(' '));
});

let exitCode: number | undefined;
try {
await PackagePublish.run([path]);
} catch (err: any) {
exitCode = err?.oclif?.exit ?? err?.exitCode;
}

expect(exitCode).toBe(1);
expect(calls).toEqual([]);
expect(errors.join('\n')).toContain("Invalid manifest.namespace 'CRM-App'");
// The message names the rule and the remedy, not just the failure.
expect(errors.join('\n')).toContain('objectstack.config.ts');
// …and the payload schema agrees this value is not a namespace.
const rejected = CreatePackageRequestSchema.shape.namespace.safeParse('CRM-App');
expect(rejected.success).toBe(false);
expect(rejected.success === false && rejected.error.issues[0].code).toBe('invalid_format');
});

// The namespace has exactly ONE source. `objectstack.manifest.json` may
// override manifestId/displayName/category; it must not be able to claim a
// namespace the artifact does not ship, or the reservation would name a
// different string than the installed object prefix.
it('ignores a namespace in objectstack.manifest.json — the artifact wins', async () => {
const path = await artifactAt({ namespace: 'crm' });
await writeFile(
join(dir, 'objectstack.manifest.json'),
JSON.stringify({ name: 'acme-crm', namespace: 'squatted', displayName: 'Acme CRM' }),
);
process.chdir(dir);
const calls = stubCloud();

await PackagePublish.run([path]);

expect(calls[0].body.namespace).toBe('crm');
});
});

describe('the CLI namespace rule is the spec namespace rule', () => {
it('agrees with CreatePackageRequestSchema on every value', () => {
const cases: ReadonlyArray<readonly [string, boolean]> = [
['crm', true],
['todo', true],
['a1', true],
['my_app_2', true],
['abcdefghijklmnopqrst', true],
['a', false],
['abcdefghijklmnopqrstu', false],
['1crm', false],
['CRM', false],
['crm-app', false],
['crm.account', false],
['crm account', false],
['', false],
];
const disagreements = cases.filter(([value, expected]) => {
const cli = NAMESPACE_RE.test(value);
const spec = CreatePackageRequestSchema.shape.namespace.safeParse(value).success;
return cli !== expected || spec !== expected;
});
expect(disagreements).toEqual([]);
});
});
2 changes: 2 additions & 0 deletions packages/spec/authorable-surface/cloud.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"cloud/CreatePackageRequest:isStarter",
"cloud/CreatePackageRequest:license",
"cloud/CreatePackageRequest:manifestId",
"cloud/CreatePackageRequest:namespace",
"cloud/CreatePackageRequest:ownerOrgId",
"cloud/CreatePackageRequest:publisher",
"cloud/CreatePackageRequest:tags",
Expand Down Expand Up @@ -245,6 +246,7 @@
"cloud/Package:isStarter",
"cloud/Package:license",
"cloud/Package:manifestId",
"cloud/Package:namespace",
"cloud/Package:ownerOrgId",
"cloud/Package:publisher",
"cloud/Package:readme",
Expand Down
Loading
Loading