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
18 changes: 18 additions & 0 deletions .changeset/20863-orphan-package-binding-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a flow saved through the metadata door naming, as its base, a package this deployment has not installed is refused, instead of being stored bound to a package that does not exist (#20863)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which flow saves the metadata door accepts: a save that names a package no installed package holds is now refused. -->

**BREAKING**: shipped as `minor` under the launch-window convention. `PUT /api/v1/meta/flow/:name` answered `200` and stored the flow live when the save named, as the package the flow belongs to, a package id this deployment has never installed. It now answers `422 WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or registered.

**What changed.** The one authoring rule every flow write door asks (`tenantAuthoredWriteRefusal`) now also judges the base a save names. After the locked-base refusal and before the provenance check, a named base must be a package the registry holds as installed: a code package, an installed package, or a tenant's own writable base created through the package door. That is the same registry read the metadata write path already resolves a base against, so no second list of packages is kept. The refusal does not depend on what the definition carries: a save with no provenance of its own and a save whose provenance names that same missing package were both stored before, and both are refused now. It applies on a single-kernel host and on an environment kernel alike.

- The code is `WRITABLE_PACKAGE_REQUIRED` / `422`, the one ADR-0070 D1 already uses for a runtime create whose base is missing or read-only. The refusal names the package id the save sent.
- **Unchanged:** a flow saved without naming a package; a flow saved into an installed package; the locked-base refusal of a shipped flow, which still answers first; every other metadata type, whose saves keep their old handling; the `/automation` create, update and clone doors, which name no package; and the server-stated rewrites of stored rows (the stored-metadata migration and package duplication), which the rule does not judge.

**What to send instead.** Save the flow into a package this deployment has installed (the package list shows them, and a base that does not exist yet is created first through the package door), or save the flow without naming a package.
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,11 @@ function makeStubEngine() {
registry: {
registerItem: () => {},
registerObject: () => {},
// No declared package namespace → publishPackageDrafts skips the
// The base the drafts are bound to is INSTALLED — a flow may be
// saved only into a package the registry holds (#20863) — and its
// manifest declares no namespace → publishPackageDrafts skips the
// ADR-0028 prefix check (legacy-grandfathered path).
getPackage: () => undefined,
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
},
};
return { engine, rows, historyRows };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,13 @@ function makeStubEngine(options?: { liveObjects?: unknown[]; livePermissions?: u
if (type === 'permission') return options?.livePermissions ?? [liveReadonlySet];
return [];
},
// No declared package namespace → the ADR-0028 prefix pre-flight is
// skipped (legacy-grandfathered path), and `resolveWritePackageScope`
// narrows nothing.
getPackage: () => undefined,
// The base the drafts are bound to is INSTALLED — a flow may be
// saved only into a package the registry holds (#20863) — and its
// manifest declares no namespace → the ADR-0028 prefix pre-flight is
// skipped (legacy-grandfathered path); it declares no dependencies
// and no live object carries a package stamp, so
// `resolveWritePackageScope`'s closure removes nothing.
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
},
};
return { engine, rows, historyRows };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,12 @@ function makeStubEngine() {
getPackage: (id: string) => packages[id],
},
};
return { engine, rows };
return { engine, rows, packages };
}

function makeProtocol() {
const { engine, rows } = makeStubEngine();
return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any, rows };
const { engine, rows, packages } = makeStubEngine();
return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any, rows, packages };
}

const save = (protocol: any, item: any, extra: Record<string, unknown> = {}) =>
Expand Down Expand Up @@ -238,13 +238,23 @@ describe('the write package closure reaches the gate (#9612)', () => {
// Its `manifest.dependencies` is the ONLY declaration of what it may
// reference; without it there is no bound, and an unbounded closure is
// the whole collection.
const { protocol } = makeProtocol();
//
// [#20863] A flow SAVE can no longer name a package the registry does
// not hold — the one authoring rule refuses it before this gate runs —
// so the state is reached the way it still arises: a draft stored into
// an installed package, promoted after that package has left the
// registry. The promotion names the draft's base, as a package publish
// does; the same promotion with the package still installed is the
// discriminating control.
const promoted = async (uninstall: boolean) => {
const { protocol, packages } = makeProtocol();
await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN, mode: 'draft' });
if (uninstall) delete packages[OWN];
return protocol.publishMetaItem({ type: 'flow', name: 'crm_on_stranger', packageId: OWN });
};

const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), {
packageId: 'com.never.installed',
});

expect(triggerAdvisories(result)).toEqual([]);
expect(triggerAdvisories(await promoted(false))).toEqual(['flow-trigger-unknown-object']);
expect(triggerAdvisories(await promoted(true))).toEqual([]);
});

it('narrows NOTHING for the `sys_metadata` overlay sentinel', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,11 @@ function makeStubEngine() {
registry: {
registerItem: () => {},
registerObject: () => {},
// No declared package namespace → the ADR-0028 prefix check is
// The base the drafts are bound to is INSTALLED — a flow may be
// saved only into a package the registry holds (#20863) — and its
// manifest declares no namespace → the ADR-0028 prefix check is
// skipped (legacy-grandfathered path).
getPackage: () => undefined,
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
},
};
return { engine, rows, historyRows };
Expand Down
124 changes: 117 additions & 7 deletions packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@
* base the write itself names;
* - everything else is admitted, and every type but `flow` is untouched.
*
* The registry double serves only `getArtifactItem` — what the real
* `SchemaRegistry` returns for an artifact a code package registered. The
* engine double serves only `findOne` over `sys_metadata` rows (and records
* `insert`, which a refused save must never reach). `@objectstack/objectql`
* cannot be imported here: it depends on this package.
* [#20863] …and a flow saved naming, as its base, a package no installed
* package holds is refused `WRITABLE_PACKAGE_REQUIRED` / 422 (ADR-0070 D1: the
* resolved target is missing) — whatever stamps its body carries, on both
* topologies — before anything is written or registered. The controls: a flow
* naming no package passes, a flow naming an installed package passes, and a
* shipped flow is still refused as a locked base first.
*
* The registry double serves `getArtifactItem` — what the real
* `SchemaRegistry` returns for an artifact a code package registered — and
* `getPackage`, what it returns for an installed package (the read the `/meta`
* write path resolves a base against); `registerItem` is recorded, and a
* refused save must never reach it. The engine double serves only `findOne`
* over `sys_metadata` rows (and records `insert`, which a refused save must
* never reach either). `@objectstack/objectql` cannot be imported here: it
* depends on this package.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
Expand Down Expand Up @@ -49,13 +59,29 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
/** The stamps a caller would send to claim the real package's provenance. */
const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' };

/** A tenant's own writable base — installed, so a save may name it. */
const TENANT_BASE = 'com.tenant.base';
/** A package id no installed package has. */
const ORPHAN = 'com.nowhere.pkg';

/** What the registry holds as installed: the code package and the tenant's base. */
const INSTALLED = new Map<string, unknown>([
[PACKAGE_ID, { manifest: { id: PACKAGE_ID, scope: 'system' } }],
[TENANT_BASE, { manifest: { id: TENANT_BASE, scope: 'project' } }],
]);

interface StoredRow { type: string; name: string; package_id: string | null }

function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise<unknown>; environmentId?: string } = {}) {
const rows = opts.rows ?? [];
const insert = vi.fn(async () => ({ id: 'never' }));
const registerItem = vi.fn();
const engine = {
registry: { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) },
registry: {
getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name),
getPackage: (id: string) => INSTALLED.get(id),
registerItem,
},
findOne: opts.findOne ?? (async (table: string, query: { where: Record<string, unknown> }) => {
assertEngineFindOnePredicate(table, query);
if (table !== 'sys_metadata') return null;
Expand All @@ -64,7 +90,7 @@ function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise<unknow
insert,
};
const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map(), opts.environmentId);
return { protocol, insert };
return { protocol, insert, registerItem };
}

const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);
Expand Down Expand Up @@ -199,3 +225,87 @@ describe('saveMetaItem applies the rule to a flow, before anything is written',
expect(await ask({ writeFace: 'package-duplicate' })).toBe(0);
});
});

describe('a flow saved naming, as its base, a package no installed package holds is refused', () => {
const refusalFor = (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'orphan_flow', item: flowBody('orphan_flow'), ...request } as never);

it('WRITABLE_PACKAGE_REQUIRED / 422, whatever stamps the body carries, on both topologies', async () => {
for (const environmentId of [undefined, 'env_1']) {
const { protocol } = protocolWith({ environmentId });
for (const stamps of [
{},
{ _packageId: ORPHAN },
{ _packageId: ORPHAN, _provenance: 'package' },
{ _packageId: ORPHAN, _provenance: 'org' },
ASSERTED,
]) {
const refusal: any = await refusalFor(protocol, { item: flowBody('orphan_flow', stamps), packageId: ORPHAN });
expect(shape(refusal), JSON.stringify({ environmentId, stamps })).toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
expect(refusal.packageId).toBe(ORPHAN);
}
// The type is folded at the producer: a plural spelling cannot address around it.
expect(shape(await refusalFor(protocol, { type: 'flows', packageId: ORPHAN })))
.toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
}
});

it('saveMetaItem throws it before anything is written or registered — published or drafted, on both topologies', async () => {
for (const environmentId of [undefined, 'env_1']) {
for (const mode of [undefined, 'draft'] as const) {
const { protocol, insert, registerItem } = protocolWith({ environmentId });
const thrown: any = await protocol.saveMetaItem({
type: 'flow', name: 'orphan_flow', item: flowBody('orphan_flow'), packageId: ORPHAN,
...(mode ? { mode } : {}),
}).then(() => undefined, (e) => e);

expect(shape(thrown), JSON.stringify({ environmentId, mode })).toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
expect(insert).not.toHaveBeenCalled();
expect(registerItem).not.toHaveBeenCalled();
}
}
});

it('control: a flow naming no package passes this rule, and the stored-row sentinel names no package', async () => {
const { protocol } = protocolWith();
for (const packageId of [undefined, null, 'sys_metadata']) {
expect(await refusalFor(protocol, { packageId }), String(packageId)).toBeNull();
}
});

it('control: a flow naming an installed package passes this rule, with or without its binding echoed', async () => {
const { protocol } = protocolWith();
expect(await refusalFor(protocol, { packageId: TENANT_BASE })).toBeNull();
expect(await refusalFor(protocol, { item: flowBody('orphan_flow', { _packageId: TENANT_BASE }), packageId: TENANT_BASE })).toBeNull();
// A read-only installed package is installed: whether it is a writable
// base is the ADR-0070 D1 gate's question further down the save, never this rule's.
expect(await refusalFor(protocol, { packageId: PACKAGE_ID })).toBeNull();
});

it('control: a shipped flow is refused as a locked base first, whatever base the save names', async () => {
for (const environmentId of [undefined, 'env_1']) {
const { protocol } = protocolWith({ environmentId });
const served = ARTIFACTS.get('flow')!.get('pkg_flow');
for (const packageId of [ORPHAN, PACKAGE_ID]) {
const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId });
expect(refusal?.status, packageId).toBe(403);
expect(['NOT_OVERRIDABLE', 'ITEM_LOCKED']).toContain(refusal.code);
}
}
});

it('with the operator hatch open a shipped flow passes the lock, and a base no installed package holds is still refused', async () => {
process.env.OS_METADATA_WRITABLE = 'flow';
ObjectStackProtocolImplementation.resetEnvWritableCache();
const { protocol } = protocolWith();
const served = ARTIFACTS.get('flow')!.get('pkg_flow');
expect(shape(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: ORPHAN })))
.toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: PACKAGE_ID })).toBeNull();
});

it('every other metadata type is untouched — a view naming the same base is not this rule\'s to judge', async () => {
const { protocol } = protocolWith();
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'customer_view', item: { name: 'customer_view' }, packageId: ORPHAN })).toBeNull();
});
});
Loading
Loading