Skip to content

Commit 4cc4fb7

Browse files
os-zhuangclaude
andauthored
feat(spec,cli): carry the package namespace on the publish payload (ADR-0048 addendum Phase A1) (#6864)
* feat(spec,cli): carry the package namespace on the publish payload (#6760) ADR-0048 addendum Phase A1. `PackageSchema` and `CreatePackageRequestSchema` gain an optional `namespace` mirroring `manifest.namespace`, and `objectstack package publish` reads it off the compiled artifact's manifest and sends it on the publish body — so the publish-time exclusivity gate (Phase A2, enterprise-side) has an input to check. - optional on both schemas, per §A.2's `if (namespace is absent) -> allow` - absent namespace => the key is omitted entirely (never null/'') - malformed namespace => refused before any network call - not overridable by a flag or objectstack.manifest.json: the reservation must name the object-name prefix the package actually ships - TemplateManifestSchema omits the field rather than inheriting it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M * docs(spec): regenerate cloud/package reference for the new namespace field (#6760) Generated by `gen:schema && gen:docs` — do not hand-edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a92b179 commit 4cc4fb7

8 files changed

Lines changed: 472 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(spec,cli): carry the package namespace on the publish payload (#6760)
7+
8+
ADR-0048's addendum defines a publish-time namespace exclusivity registry
9+
(`namespace → publisher`), so a cross-vendor namespace collision is caught while
10+
exactly one party can still fix it cheaply — the publisher, before anything ships
11+
— instead of surfacing at install time, where the tenant who suffers it can do
12+
nothing. That gate is enterprise-side (Phase A2), and it could not be built
13+
because the namespace never left the artifact: `PackageSchema` had no
14+
`namespace` field at all, `CreatePackageRequestSchema` did not accept one, and
15+
`objectstack package publish` transmitted `manifest_id` only. This is Phase A1,
16+
the open-side half that gives the gate an input.
17+
18+
**`PackageSchema` and `CreatePackageRequestSchema` gain an optional
19+
`namespace`.** It mirrors `manifest.namespace` exactly — same 2-20 character
20+
rule (`/^[a-z][a-z0-9_]{1,19}$/`), same optionality — so the publish payload and
21+
the artifact manifest cannot disagree about what a namespace is. Optional is the
22+
ruled shape, not a convenience: the addendum's algorithm opens with
23+
`if (namespace is absent) -> allow`, and a package that declares no namespace
24+
makes no reservation and is not gated. A parity test judges both fields against
25+
one table of values, so a change to either side fails.
26+
27+
**`objectstack package publish` sends it, read off the compiled artifact's
28+
`manifest.namespace`** — the same place the command already reads
29+
`manifest.id`. Three behaviours, matching the addendum's algorithm:
30+
31+
- namespace present → it travels on the `POST /cloud/packages` body as
32+
`namespace`, and is echoed in the publish summary;
33+
- namespace absent → the key is omitted entirely (not `null`, not `''`), so
34+
"declares no namespace" never becomes a value the gate has to interpret;
35+
- namespace malformed → the publish is refused before any network call, naming
36+
the rule and the fix.
37+
38+
The namespace is deliberately **not** overridable by a flag or by
39+
`objectstack.manifest.json`, unlike `manifestId`: a reservation is only
40+
meaningful if it names the object-name prefix the package actually ships, and a
41+
second declaration surface would let a publisher reserve `foo` while installing
42+
`bar_*` objects. For the same reason `TemplateManifestSchema` omits the field
43+
rather than inheriting it.
44+
45+
Nothing about install-time behaviour changes. The in-process install gate,
46+
`NamespaceConflictError`, the shareable `base`/`system`/`sys` set and the
47+
`OS_METADATA_COLLISION=warn` downgrade are untouched, and the install path
48+
acquires no network dependency.

content/docs/references/cloud/package.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Register a new package in the Control Plane
4444
| Property | Type | Required | Description |
4545
| :--- | :--- | :--- | :--- |
4646
| **manifestId** | `string` || Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
47+
| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") |
4748
| **ownerOrgId** | `string` || Owner organization ID |
4849
| **displayName** | `string` || Display name shown in Studio and Marketplace |
4950
| **description** | `string` | optional | Short package description |
@@ -69,6 +70,7 @@ Register a new package in the Control Plane
6970
| :--- | :--- | :--- | :--- |
7071
| **id** | `string` || UUID of the package (stable, never reused) |
7172
| **manifestId** | `string` || Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
73+
| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") |
7274
| **ownerOrgId** | `string` || Organization ID of the package owner/publisher |
7375
| **displayName** | `string` || Display name shown in Studio and Marketplace |
7476
| **description** | `string` | optional | Short package description |

packages/cli/src/commands/package/publish.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.
2626

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

29+
/**
30+
* Mirror of `manifest.namespace`'s pattern in `@objectstack/spec`
31+
* (`kernel/manifest.zod.ts`, and `cloud/package.zod.ts` for the publish
32+
* payload): 2-20 chars, a lowercase letter followed by lowercase letters,
33+
* digits or underscores. Pinned against the spec schema in
34+
* `test/package-publish.test.ts`.
35+
*/
36+
export const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/;
37+
2938
function slugify(input: string): string {
3039
return input
3140
.toLowerCase()
@@ -53,6 +62,28 @@ function deriveManifestId(artifact: any, artifactPath: string): string {
5362
return `local.${slugify(basename(artifactPath).replace(/\.json$/i, ''))}`;
5463
}
5564

65+
/**
66+
* Read the metadata namespace off the COMPILED ARTIFACT's manifest — the same
67+
* place {@link deriveManifestId} reads `manifest.id`.
68+
*
69+
* ADR-0048 addendum §A.2 (Phase A1) requires the namespace to travel with the
70+
* publish payload so the publish-time exclusivity gate has an input to check.
71+
* It is deliberately NOT overridable by a flag or by `objectstack.manifest.json`
72+
* (unlike `manifestId`): the namespace is the physical object-name prefix baked
73+
* into the artifact at build time, and a reservation that names a different
74+
* string than the package actually ships would be worse than no reservation —
75+
* a publisher could reserve `foo` while installing `bar_*` objects.
76+
*
77+
* Returns `undefined` when the artifact declares no namespace. That is a
78+
* supported artifact shape (`manifest.namespace` is optional), and §A.2's
79+
* algorithm opens with `if (namespace is absent) -> allow`.
80+
*/
81+
function readArtifactNamespace(artifact: any): string | undefined {
82+
const ns = artifact?.manifest?.namespace;
83+
if (typeof ns !== 'string' || !ns.trim()) return undefined;
84+
return ns.trim();
85+
}
86+
5687
function deriveDisplayName(artifact: any, manifestId: string): string {
5788
const n = artifact?.manifest?.name;
5889
if (typeof n === 'string' && n.trim()) return n.trim();
@@ -294,6 +325,21 @@ export default class PackagePublish extends Command {
294325
this.exit(1);
295326
return;
296327
}
328+
// ADR-0048 addendum §A.2 Phase A1 — the namespace must reach the control
329+
// plane, and it must be the artifact's real one. A malformed value is
330+
// refused here, before any network call: silently dropping it would
331+
// publish an artifact whose namespace the exclusivity gate never sees,
332+
// which is precisely the hole this phase exists to close.
333+
const namespace = readArtifactNamespace(artifact);
334+
if (namespace !== undefined && !NAMESPACE_RE.test(namespace)) {
335+
printError(
336+
`Invalid manifest.namespace '${namespace}' in the artifact. Expected 2-20 characters: ` +
337+
'a lowercase letter followed by lowercase letters, digits or underscores ' +
338+
"(e.g. 'crm'). Fix `manifest.namespace` in objectstack.config.ts and rebuild.",
339+
);
340+
this.exit(1);
341+
return;
342+
}
297343
const displayName = (
298344
flags['display-name']
299345
?? (typeof m.displayName === 'string' ? m.displayName : undefined)
@@ -339,6 +385,11 @@ export default class PackagePublish extends Command {
339385
display_name: displayName,
340386
visibility: flags.visibility,
341387
};
388+
// Absent namespace ⇒ absent key. `CreatePackageRequestSchema.namespace`
389+
// is optional and §A.2 allows an unnamespaced publish; sending `null` or
390+
// `''` would turn "declares no namespace" into a value the gate has to
391+
// interpret.
392+
if (namespace) pkgBody.namespace = namespace;
342393
const desc = flags.description ?? (typeof m.description === 'string' ? m.description : undefined);
343394
if (desc) pkgBody.description = desc;
344395
const cat = flags.category ?? (typeof m.category === 'string' ? m.category : undefined);
@@ -516,6 +567,7 @@ export default class PackagePublish extends Command {
516567
printSuccess('Package published');
517568
printKV(' Package', manifestId);
518569
printKV(' Package ID', String(pkg?.id ?? '—'));
570+
if (namespace) printKV(' Namespace', namespace);
519571
printKV(' Version', String(ver?.version ?? version));
520572
printKV(' Version ID', String(ver?.id ?? '—'));
521573
if (ver?.checksum) printKV(' Checksum', String(ver.checksum).slice(0, 16));
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0048 addendum §A.2 Phase A1 — `os package publish` carries the artifact's
5+
* namespace to the control plane.
6+
*
7+
* The publish-time exclusivity gate (Phase A2, enterprise-side) reads the
8+
* namespace off the publish payload. Before this phase the namespace never left
9+
* the artifact, so the gate had nothing to check. These cases pin the three
10+
* behaviours the addendum's algorithm distinguishes: a namespace present (it
11+
* travels), a namespace absent (`if (namespace is absent) -> allow` — the key
12+
* is simply not sent), and a namespace that is not a namespace (refused before
13+
* any network call).
14+
*/
15+
16+
import { describe, it, expect, afterEach, vi } from 'vitest';
17+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
18+
import { tmpdir } from 'node:os';
19+
import { join } from 'node:path';
20+
import { CreatePackageRequestSchema } from '@objectstack/spec/cloud';
21+
import PackagePublish, { NAMESPACE_RE } from '../src/commands/package/publish.js';
22+
23+
type Call = { url: string; body: any };
24+
25+
function artifactJson(manifest: Record<string, unknown>): string {
26+
return JSON.stringify({
27+
manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0', ...manifest },
28+
objects: [],
29+
});
30+
}
31+
32+
/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */
33+
function stubCloud(): Call[] {
34+
const calls: Call[] = [];
35+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
36+
calls.push({ url, body: JSON.parse(init.body) });
37+
const data = url.endsWith('/versions')
38+
? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' }
39+
: { id: 'pkg_1', created: true, visibility: 'org' };
40+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any;
41+
}));
42+
return calls;
43+
}
44+
45+
describe('os package publish — namespace on the publish payload', () => {
46+
let dir = '';
47+
const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY };
48+
const prevCwd = process.cwd();
49+
50+
afterEach(async () => {
51+
process.chdir(prevCwd);
52+
vi.unstubAllGlobals();
53+
vi.restoreAllMocks();
54+
process.env.OS_CLOUD_URL = prevEnv.url;
55+
process.env.OS_CLOUD_API_KEY = prevEnv.key;
56+
if (dir) await rm(dir, { recursive: true, force: true });
57+
});
58+
59+
async function artifactAt(manifest: Record<string, unknown>): Promise<string> {
60+
dir = await mkdtemp(join(tmpdir(), 'package-publish-ns-'));
61+
const path = join(dir, 'objectstack.json');
62+
await writeFile(path, artifactJson(manifest));
63+
process.env.OS_CLOUD_URL = 'http://cloud.test';
64+
process.env.OS_CLOUD_API_KEY = 'tok_123';
65+
return path;
66+
}
67+
68+
it('sends `namespace` read off the compiled artifact manifest', async () => {
69+
const path = await artifactAt({ namespace: 'crm' });
70+
const calls = stubCloud();
71+
72+
await PackagePublish.run([path]);
73+
74+
expect(calls).toHaveLength(2);
75+
expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages');
76+
expect(calls[0].body).toMatchObject({ manifest_id: 'com.acme.crm', namespace: 'crm' });
77+
// The value the CLI puts on the wire is one the acceptance face accepts.
78+
expect(CreatePackageRequestSchema.shape.namespace.safeParse(calls[0].body.namespace).success).toBe(true);
79+
});
80+
81+
// Reverse verification, direction 1: an artifact with NO namespace must not
82+
// grow one. §A.2 allows an absent namespace, and the key must be absent
83+
// rather than null/'' so the gate never has to interpret an empty value.
84+
it('omits the key entirely when the artifact declares no namespace', async () => {
85+
const path = await artifactAt({});
86+
const calls = stubCloud();
87+
88+
await PackagePublish.run([path]);
89+
90+
expect(calls).toHaveLength(2);
91+
expect('namespace' in calls[0].body).toBe(false);
92+
expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
93+
});
94+
95+
// Reverse verification, direction 2: a malformed namespace is refused, and
96+
// refused BEFORE the network call — silently dropping it would publish an
97+
// artifact whose namespace the A2 gate never sees.
98+
it('refuses a malformed namespace with exit code 1 and never calls the cloud', async () => {
99+
const path = await artifactAt({ namespace: 'CRM-App' });
100+
const calls = stubCloud();
101+
const errors: string[] = [];
102+
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
103+
errors.push(args.map(String).join(' '));
104+
});
105+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
106+
errors.push(args.map(String).join(' '));
107+
});
108+
109+
let exitCode: number | undefined;
110+
try {
111+
await PackagePublish.run([path]);
112+
} catch (err: any) {
113+
exitCode = err?.oclif?.exit ?? err?.exitCode;
114+
}
115+
116+
expect(exitCode).toBe(1);
117+
expect(calls).toEqual([]);
118+
expect(errors.join('\n')).toContain("Invalid manifest.namespace 'CRM-App'");
119+
// The message names the rule and the remedy, not just the failure.
120+
expect(errors.join('\n')).toContain('objectstack.config.ts');
121+
// …and the payload schema agrees this value is not a namespace.
122+
const rejected = CreatePackageRequestSchema.shape.namespace.safeParse('CRM-App');
123+
expect(rejected.success).toBe(false);
124+
expect(rejected.success === false && rejected.error.issues[0].code).toBe('invalid_format');
125+
});
126+
127+
// The namespace has exactly ONE source. `objectstack.manifest.json` may
128+
// override manifestId/displayName/category; it must not be able to claim a
129+
// namespace the artifact does not ship, or the reservation would name a
130+
// different string than the installed object prefix.
131+
it('ignores a namespace in objectstack.manifest.json — the artifact wins', async () => {
132+
const path = await artifactAt({ namespace: 'crm' });
133+
await writeFile(
134+
join(dir, 'objectstack.manifest.json'),
135+
JSON.stringify({ name: 'acme-crm', namespace: 'squatted', displayName: 'Acme CRM' }),
136+
);
137+
process.chdir(dir);
138+
const calls = stubCloud();
139+
140+
await PackagePublish.run([path]);
141+
142+
expect(calls[0].body.namespace).toBe('crm');
143+
});
144+
});
145+
146+
describe('the CLI namespace rule is the spec namespace rule', () => {
147+
it('agrees with CreatePackageRequestSchema on every value', () => {
148+
const cases: ReadonlyArray<readonly [string, boolean]> = [
149+
['crm', true],
150+
['todo', true],
151+
['a1', true],
152+
['my_app_2', true],
153+
['abcdefghijklmnopqrst', true],
154+
['a', false],
155+
['abcdefghijklmnopqrstu', false],
156+
['1crm', false],
157+
['CRM', false],
158+
['crm-app', false],
159+
['crm.account', false],
160+
['crm account', false],
161+
['', false],
162+
];
163+
const disagreements = cases.filter(([value, expected]) => {
164+
const cli = NAMESPACE_RE.test(value);
165+
const spec = CreatePackageRequestSchema.shape.namespace.safeParse(value).success;
166+
return cli !== expected || spec !== expected;
167+
});
168+
expect(disagreements).toEqual([]);
169+
});
170+
});

packages/spec/authorable-surface/cloud.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"cloud/CreatePackageRequest:isStarter",
5757
"cloud/CreatePackageRequest:license",
5858
"cloud/CreatePackageRequest:manifestId",
59+
"cloud/CreatePackageRequest:namespace",
5960
"cloud/CreatePackageRequest:ownerOrgId",
6061
"cloud/CreatePackageRequest:publisher",
6162
"cloud/CreatePackageRequest:tags",
@@ -245,6 +246,7 @@
245246
"cloud/Package:isStarter",
246247
"cloud/Package:license",
247248
"cloud/Package:manifestId",
249+
"cloud/Package:namespace",
248250
"cloud/Package:ownerOrgId",
249251
"cloud/Package:publisher",
250252
"cloud/Package:readme",

0 commit comments

Comments
 (0)