Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/20676-mount-flow-clone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
---

fix(runtime): `POST /api/v1/automation/:name/clone` is served over HTTP

Clause-②: no

Cloning a flow under a new machine name (ADR-0126 §7.1) is how an admin customizes a packaged
flow whose base is locked. The runtime implemented the clone, but the dispatcher never mounted
its route, so every clone answered `404 ENDPOINT_NOT_FOUND` before the request reached it: from
the API, and from the Clone dialog on Setup's packaged-automation page, for every caller and
every body.

The route is now mounted beside `POST /automation/:name/toggle`, at `/api/v1/automation/:name/clone`
and, when environment scoping is enabled, at `/api/v1/environments/:environmentId/automation/:name/clone`.
It answers what the clone implementation already answered: `200 { flow, notice }` for a legal
clone, `400` for a missing or illegal `name` or `label`, `404` for an unknown source flow,
`409 RESOURCE_CONFLICT` for a name already in use, `401` for an anonymous caller and `403` for a
caller without `manage_metadata`. No request or response shape changed.
4 changes: 2 additions & 2 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// dispatcher domain files.
//
// The population comes from `packages/rest/src/rest-route-ledger.ts` (83 rows
// / 18 families) and `packages/runtime/src/route-ledger.ts` (81 rows / 21
// / 18 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21
// domains) because those two are enumerated from a RUNNING server and guarded
// in both directions by their own conformance tests — so a new family or
// domain cannot be silently absent from them, and therefore cannot be silently
Expand Down Expand Up @@ -241,7 +241,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
covers: ['actions:domains/actions.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/actions'],
note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — #5519 measured `POST /actions/showcase_task/showcase_mark_done/:id` answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' },
{ id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced',
enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the four gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, #10145, plus enablement `POST /:name/toggle` since the #10243 ruling of 2026-08-23, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door',
enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the five gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, #10145, plus enablement `POST /:name/toggle` since the #10243 ruling of 2026-08-23, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment, plus the ADR-0126 §7.1 clone `POST /:name/clone`, which registers flow metadata at environment scope exactly as create does), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
// [2026-08-31] Ledger granularity for the same DOMAIN-WIDE gate named
// above — the property the note already relies on ("gating the DOMAIN
Expand Down
12 changes: 8 additions & 4 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
// `RestServer.getRoutes()` on a booted server and guarded per route by
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
// this table reaches 1.
// `packages/runtime/src/route-ledger.ts`: 81 rows over 21 domains. Its
// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its
// machine contract is DOMAIN-level, by live registry introspection
// (`domainRegistry.list()`), the per-route rows being documentation. It
// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all
Expand Down Expand Up @@ -362,11 +362,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
// route (door ④ — the list is `GET /meta/flow`). It carried
// `domain: '/automation'`, a key other rows still carry, so `reachable`
// moves with `population`, `blindSpot` stays 0 and `keys` stays 21.
population: 81,
reachable: 81,
// [#20676] 81 -> 82: the `POST /automation/:name/clone` row arrived with its
// mount (ADR-0126 §7.1). It carries `domain: '/automation'`, an EXISTING
// key, so `reachable` moves with `population`, `blindSpot` stays 0 and
// `keys` stays 21 (21 distinct domains before and after, re-derived).
population: 82,
reachable: 82,
blindSpot: 0,
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
controls: { "route: '": 81, "domain: '": 81, RouteLedgerEntry: 2 },
controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 },
note:
'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' +
'(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' +
Expand Down
141 changes: 141 additions & 0 deletions packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #20676 — `POST /api/v1/automation/:name/clone` answers over HTTP.
*
* ## What was broken, and why the existing pin could not see it
*
* ADR-0126 §7.1's clone door is how an admin customizes a packaged flow whose
* base is locked. Its domain arm (`runtime/src/domains/automation.ts`) landed
* with #12156, but the dispatcher bridge (`registerAutomationRoutes` in
* `runtime/src/dispatcher-plugin.ts`) mounts every `/automation` route
* explicitly and never mounted this one. So every clone — from the API and
* from Setup's Clone dialog — answered the transport's
* `404 ENDPOINT_NOT_FOUND` before `dispatch()` ran, for every body and every
* caller. `domains/automation-flow-clone.test.ts` stayed green throughout
* because it drives `HttpDispatcher` directly, below the mount.
*
* This file drives the REAL composition instead: `bootStack` boots the CRM app
* with the automation service over the in-process Hono app, so a request here
* crosses exactly the mount a browser crosses.
*
* ## Why each case discriminates the mount
*
* An unmounted path answers 404 to everyone, so none of the verdicts below can
* be produced without the mount: the anonymous floor's `UNAUTHENTICATED` is
* minted inside the automation domain, and so are the 200 with its notice, the
* 409 and the 400. The environment-scoped twin
* (`/environments/:environmentId/automation/:name/clone`) is not mounted by
* this harness at all (it boots the dispatcher without project scoping); it is
* pinned over a real socket in
* `runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts`.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import crmStack from '@objectstack/example-crm';
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
import { bootStack, type VerifyStack } from '@objectstack/verify';

// Read from the implementation rather than restated: the notice is a contract
// value, and a second spelling of it would agree only until one of them moves.
import { FLOW_CLONE_NOTICE } from '../../../runtime/src/flow-clone.js';

/** The CRM app's own shipped flow — a real definition, not one this test injects. */
const SOURCE = 'crm_convert_lead_wizard';
/** A customer-owned machine name no shipped flow uses. */
const CLONE = 'crm_convert_lead_wizard_clone_20676';
const CLONE_LABEL = 'Convert Lead (clone)';

interface ErrorBody { success?: boolean; error?: { code?: string; httpStatus?: number } }

describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher bridge', () => {
let stack: VerifyStack;
let adminToken: string;

beforeAll(async () => {
stack = await bootStack(crmStack as never, { automation: true });
adminToken = await stack.signIn();
}, 180_000);

afterAll(async () => {
await stack?.stop?.();
});

it('an anonymous caller is refused by the automation domain — 401 UNAUTHENTICATED, not the transport 404', async () => {
const res = await stack.api(`/automation/${SOURCE}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'anon_clone_20676', label: 'Anonymous clone' }),
});
const text = await res.clone().text();
expect(res.status, `anonymous clone: ${text}`).toBe(ANONYMOUS_DENY_STATUS);
expect(((await res.json()) as ErrorBody).error?.code).toBe(ANONYMOUS_DENY_CODE);

// Nothing was registered under the anonymous caller's name.
const readBack = await stack.apiAs(adminToken, 'GET', '/automation/anon_clone_20676');
expect(readBack.status).toBe(404);
});

it('a legal clone answers 200 with the flow and FLOW_CLONE_NOTICE, and the clone reads back', async () => {
// Control: the target does not exist before the clone, so the read-back
// below is evidence of THIS request rather than of the fixture.
const before = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
expect(before.status, `pre-clone read of ${CLONE}`).toBe(404);

const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
name: CLONE,
label: CLONE_LABEL,
});
const text = await res.clone().text();
expect(res.status, `legal clone: ${text}`).toBe(200);
const body = (await res.json()) as {
success?: boolean;
data?: { flow?: { name?: string; label?: string; status?: string }; notice?: string };
};
expect(body.success).toBe(true);
expect(body.data?.notice).toBe(FLOW_CLONE_NOTICE);
expect(body.data?.flow).toMatchObject({ name: CLONE, label: CLONE_LABEL, status: 'draft' });

const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
const readText = await readBack.clone().text();
expect(readBack.status, `read-back of ${CLONE}: ${readText}`).toBe(200);
const read = (await readBack.json()) as { data?: { name?: string; label?: string; type?: string } };
expect(read.data).toMatchObject({ name: CLONE, label: CLONE_LABEL, type: 'screen' });

// The source is untouched by its clone.
const source = await stack.apiAs(adminToken, 'GET', `/automation/${SOURCE}`);
expect(source.status).toBe(200);
expect(((await source.json()) as { data?: { name?: string } }).data?.name).toBe(SOURCE);
});

it('an illegal machine name answers 400, and nothing is registered under it', async () => {
const illegal = 'Not A Machine Name';
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
name: illegal,
label: 'Illegal clone',
});
const text = await res.clone().text();
expect(res.status, `illegal-name clone: ${text}`).toBe(400);
expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED');

const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${encodeURIComponent(illegal)}`);
expect(readBack.status).toBe(404);
});

it('a clone with no `name` is refused 400 before anything is registered', async () => {
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { label: 'No name' });
const text = await res.clone().text();
expect(res.status, `name-less clone: ${text}`).toBe(400);
expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED');
});

it('a taken name answers 409 RESOURCE_CONFLICT — the same-name clone ADR-0126 §7.1 refuses', async () => {
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
name: SOURCE,
label: 'Same-name clone',
});
const text = await res.clone().text();
expect(res.status, `same-name clone: ${text}`).toBe(409);
expect(((await res.json()) as ErrorBody).error?.code).toBe('RESOURCE_CONFLICT');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #20676 — the flow clone door is MOUNTED, at both bases `registerAutomationRoutes`
* serves.
*
* ## Why a socket, and why this file beside the dogfood pin
*
* `domains/automation.ts` has answered `POST /:name/clone` (ADR-0126 §7.1)
* since #12156, but the bridge mounts every `/automation` route explicitly and
* never mounted this one, so on a real host the transport's `notFound`
* answered before `dispatch()` ran. A test that calls the handler cannot see
* that; only a request that crosses the mount can.
* `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts` drives the
* clone end to end on a composed app, but its harness boots the dispatcher
* WITHOUT project scoping, so the environment-scoped twin
* (`${prefix}/environments/:environmentId/automation/:name/clone`) is
* unobservable there. This file boots the composition that mounts both.
*
* ## The composition, and the discriminator
*
* `plugin-hono-server` + the dispatcher, `enableProjectScoping: true` under
* `projectResolution: 'auto'` — the branch that mounts the plain AND the
* scoped base. No `createHonoApp`, no service plugins, so a mount is the only
* way in. The discriminator is `dispatcher-plugin.scoped-packages-door.integration.test.ts`'s:
* the automation domain's first statement is the anonymous-deny floor, so a
* credential-less request that REACHES the dispatcher answers
* `ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` (imported, not spelled) — a
* verdict no transport-level sink emits — while a path no mount claims answers
* the transport's own 404. The negative control measures that second direction
* on a sibling path rather than assuming it.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import type { IHttpServer } from '@objectstack/spec/contracts';

import { createDispatcherPlugin } from './dispatcher-plugin.js';

const PREFIX = '/api/v1';
const ENV_ID = 'env_alpha';
const FLOW = 'crm_convert_lead_wizard';

let kernel: LiteKernel | undefined;
let baseUrl = '';

beforeAll(async () => {
kernel = new LiteKernel();
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
kernel.use(createDispatcherPlugin({
prefix: PREFIX,
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
enforceProjectMembership: false,
securityHeaders: false,
}));
await kernel.bootstrap();
const httpServer = kernel.getService<IHttpServer>('http.server');
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
}, 60_000);

afterAll(async () => {
if (!kernel) return;
await Promise.race([
kernel.shutdown(),
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
]);
}, 60_000);

async function post(path: string): Promise<{ status: number; body: any }> {
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'probe_clone_20676', label: 'Probe clone' }),
});
let body: any;
try { body = await res.json(); } catch { body = undefined; }
return { status: res.status, body };
}

/** The anonymous floor answered — minted inside `dispatch()`, never by the transport. */
function dispatcherAnswered(r: { status: number; body: any }): boolean {
return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE;
}

/** The shape "no door answered" takes on this transport. */
function transportRefused(r: { status: number; body: any }): boolean {
const code = r.body?.error?.code;
return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND');
}

describe('#20676 — the discriminator, measured in both directions', () => {
// The control is the EXECUTION door, not `/:name/toggle`, on purpose: it has
// the same two-segment POST shape and the same domain-wide anonymous floor,
// and it sits outside every authoring gate, so its answer here does not
// depend on what the toggle door does to a given flow. The control proves
// one thing only — a mounted `/automation/:name/VERB` reaches `dispatch()`.
it('POSITIVE CONTROL: the sibling `/:name/trigger` mount answers from the domain', async () => {
const r = await post(`${PREFIX}/automation/${FLOW}/trigger`);
expect(dispatcherAnswered(r), `trigger -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
}, 60_000);

it('NEGATIVE CONTROL: an unmounted sibling segment answers the transport, not the domain', async () => {
const r = await post(`${PREFIX}/automation/${FLOW}/no-such-verb`);
expect(dispatcherAnswered(r)).toBe(false);
expect(transportRefused(r), `unmounted sibling -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
}, 60_000);
});

describe('#20676 — POST /automation/:name/clone crosses the mount at both bases', () => {
for (const base of [PREFIX, `${PREFIX}/environments/${ENV_ID}`]) {
it(`POST ${base}/automation/:name/clone answers through the dispatcher`, async () => {
const r = await post(`${base}/automation/${FLOW}/clone`);
expect(dispatcherAnswered(r), `clone at ${base} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
}, 60_000);
}
});
Loading
Loading