Skip to content

Commit 96e7244

Browse files
fix(runtime): mount POST /automation/:name/clone on the dispatcher bridge, at both bases (#20779)
Fixes #20676 Clause-②: no ## What was broken ADR-0126 §7.1's flow clone door answered `404 ENDPOINT_NOT_FOUND` on every live server, for every caller and every body. The domain arm (`packages/runtime/src/domains/automation.ts`, `POST /:name/clone`) exists, but `registerAutomationRoutes` in `packages/runtime/src/dispatcher-plugin.ts` mounts every `/automation` route explicitly and never mounted this one, so the transport's `notFound` answered before `dispatch()` ran. The arm's unit test (`domains/automation-flow-clone.test.ts`) stayed green because it drives `HttpDispatcher` directly, below the mount. The route was also missing from `route-ledger.ts`, so the live-mount parity gate had no row to flag. ## What changed - `packages/runtime/src/dispatcher-plugin.ts`: `POST ${base}/automation/:name/clone` mounted beside `/:name/toggle`, dispatching to `POST /automation/:name/clone`. `registerAutomationRoutes` runs for both bases, so the environment-scoped twin (`/api/v1/environments/:environmentId/automation/:name/clone`) is mounted by the same line. Registered after `trigger/:name`: for a flow literally named `clone`, `POST /automation/trigger/clone` still reaches the legacy execution door, and either mount rebuilds the identical dispatch path, which the domain answers `trigger` first. - `packages/runtime/src/route-ledger.ts`: a `POST /automation/:name/clone` row, `server-only`, with its rationale (the operational driver is the Setup page, which calls the platform API directly; the same posture as the `POST /actions/_activation/:object/:action` row). There is no `client.automation.clone` SDK method, and `gap` is ratcheted at 0. Census regenerated with `--fix`: 81 to 82 rows. - `.changeset/20676-mount-flow-clone.md`: `@objectstack/runtime` patch. No domain arm, gate, response shape or spec file changed. `packages/runtime/src/domains/automation.ts` is untouched. ## Sweep: domain arms against bridge mounts Every `handleAutomationRequest` arm, diffed against the `registerAutomationRoutes` mounts on `origin/main` `f284ab26`: | Domain arm | Bridge mount | Verdict | |---|---|---| | `POST /` (create) | `POST /automation` | mounted | | `GET /actions`, `GET /connectors`, `GET /_status` | the three literal mounts, before `/:name` | mounted | | `GET /:name`, `PUT /:name`, `DELETE /:name` | `/automation/:name` x3 | mounted | | `POST /trigger/:name` (legacy) | `/automation/trigger/:name` | mounted | | `POST /:name/trigger` | `/automation/:name/trigger` | mounted | | `POST /:name/toggle` | `/automation/:name/toggle` | mounted | | **`POST /:name/clone`** | none | **mounted by this PR** | | `GET /:name/runs`, `GET /:name/runs/:runId` | both mounted | mounted | | `POST /:name/runs/:runId/resume` | mounted | mounted | | `POST /:name/runs/:runId/cancel`, `/restore-suspension` | both mounted | mounted | | `GET /:name/runs/:runId/screen` | mounted | mounted | | `GET /` (flow list) | none | retired (#19543 door 4), correctly unmounted | The clone door was the only unmounted arm. No undeclared door was found, so nothing was mounted beyond the card. ## Pins - `packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts` boots the CRM app with the automation service through `bootStack` (the real Hono app) and clones the shipped `crm_convert_lead_wizard`. It pins these cases: - an anonymous caller gets `401 UNAUTHENTICATED` (the domain floor, not the transport 404); - a legal clone gets `200` with `data.notice === FLOW_CLONE_NOTICE` (imported, not restated) and `status: 'draft'`, and the clone reads back on `GET /automation/:name`; - an illegal machine name gets `400 VALIDATION_FAILED`, and nothing is registered under it; - a missing `name` gets `400 VALIDATION_FAILED`; - a taken name gets `409 RESOURCE_CONFLICT`. - `packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts` covers the environment-scoped twin, which `bootStack` never mounts because it boots without project scoping. It uses `plugin-hono-server` and the dispatcher with `enableProjectScoping: true` over a real socket. The discriminator is the anonymous floor's `401 UNAUTHENTICATED`, which only the dispatcher mints. Both bases are probed, with a positive control (`/:name/trigger`, changed from `/:name/toggle` in patch round 1 so it holds whichever of this PR and PR #20780 lands first) and a negative control (an unmounted sibling segment answering the transport 404). - With the row in the ledger, `route-ledger-live-mount-parity.dogfood.test.ts` now also guards this mount. ## Reverse verification (ablation, one-off, nothing left in the tree) The fix was committed first. `scripts/ablation-replace.mjs` then renamed the mount path (`automation/:name/clone` became `automation/:name/clone-ablated-20676`, anchor 1 to 0). Runtime was rebuilt, and `ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.cjs`. - The runtime pin went red on the 2 clone cases, each with `404 {"code":"ENDPOINT_NOT_FOUND"}`. Both controls stayed green. - The dogfood pin went red on 5 of 5 cases, each with `404 ENDPOINT_NOT_FOUND`, the card's own symptom byte for byte. - The ledger parity gate went red on 2 cases: `POST /automation/:name/clone — LEDGERED BUT NOT MOUNTED`, and the ablated mount unledgered. Restore: the blob equals the HEAD blob (`b6dc62c9`), whole-tree `git status --porcelain` is empty, runtime was rebuilt, and `--absent` preflight shows the marker absent from all 6 built files. Re-run: runtime pin 4/4 green; dogfood (the clone pin, the ledger parity gate and `automation-toggle-tenant-scope`) 21/21 green. ## Downstream prose this makes true - The `FLOW_DISABLED` refusal ("...or run a clone of it under a new name", `service-automation/src/engine.ts`) and the Setup page copy now point at a door that answers. - `content/docs/capabilities/integrations.mdx` promises "switch it off and clone your own to edit in Studio". The clone half is now true. The **edit in Studio** half is not, as measured on the same harness, one-off and not committed: - after a `200` clone, `GET /api/v1/meta/flow/CLONE` answers `404 RESOURCE_NOT_FOUND`, while the source answers `200`; - after a cold boot on the same database file, `GET /api/v1/automation/CLONE` answers `404`, while the source answers `200`. The clone is engine-only. That is FOLLOW-UPS §8a D18, outside this card, and not fixed here. Carrier: #20761's stage 2. The maintainer's ruling there (`5904938166`) pins "a clone of a shipped flow is saved as a tenant row", and the seat has posted this measurement on that card. ## Acceptance notes - **Fixed here** (a bounded in-place fix, patch round 1): the `/automation` enforcement prose in `packages/qa/dogfood/test/authz-conformance.matrix.ts` said "four gated flow writes". It now names five, adding the ADR-0126 §7.1 clone `POST /:name/clone`. Evidence: `isFlowAuthoringWrite` in `packages/runtime/src/domains/automation.ts` returns true for exactly five route shapes: `POST /` (`parts.length` 0), and `POST /:name/toggle`, `POST /:name/clone`, `PUT /:name` and `DELETE /:name` (`parts.length` 1). - **Fixed here** (a bounded in-place fix, patch round 1): the note on `route-ledger.ts`'s `POST /automation/:name/toggle` row. - BEFORE: 'The enabled bit is not a ROW, so no organization wall scopes it: `toggleFlow` writes an in-process map keyed by flow name only, `getFlowRuntimeStates()` reads it with no caller and no organization, and the automation service is ONE instance per environment'. - AFTER: 'No organization wall scopes the enabled bit: `toggleFlow` writes the ADR-0126 §7.2 activation ledger first — one deployment-wide `sys_metadata_activation` row per flow, keyed by `(metadata_type, name)`, carrying the flow's package id and no organization column — and only then updates the engine's in-process projection, which `getFlowRuntimeStates()` reads with no caller and no organization; the automation service is ONE instance per environment'. - Evidence: `toggleFlow` in `service-automation`'s `engine.ts` calls `flowActivationStore.setActive` before it updates `flowLedgerDisabled`, and core's `metadata-activation-store.ts` has the columns `metadata_type`, `name`, `package_id` and `active`, matched on `(metadata_type, name)`. - "Packaged flows only" is NOT added here: that is PR #20780's behaviour, and whichever of the two PRs lands second adds it. - The dogfood census pins were re-derived by the census file's own method (patch round 1). In `authz-probe-blind-spot.census.ts`, the `route-ledger.ts` probe row went from population/reach 81/81 to 82/82, with the blind spot 0 and keys 21 unchanged; `BLIND_SPOT_TOTAL_STATIC` 67 / `_RUNTIME` 72 are unchanged. The `authz-conformance.matrix.ts` docblock now reads (82 rows / 21 domains). - `#20679` (the packaged-flow lock on PUT/DELETE) is not addressed here. The clone pins use a new, customer-owned name and do not exercise that lock. - `docs/qa/platform-checklist/FOLLOW-UPS.md` §8a D22 ("`POST /automation/:name/clone` is unledgered") goes stale when this lands. The file is outside this card's surface. Carrier: none; noted, not filed. ## Verification **Patch round 1 — final head `99b3cfa4`** (a merge of `origin/main` over `ab7d5015` and `5518c808`): - Runtime pins (`dispatcher-plugin.automation-clone-mount.integration`, `route-ledger.conformance`, `automation-api-contract-mounts`, `domains/automation-flow-clone`): 4 files, 31 tests passed. Runtime and dogfood typecheck are green. - The full dogfood package: 141 files passed and 1 skipped (142); 1155 tests passed and 3 skipped. The two formerly red files (`authz-conformance.test.ts`, and `authz-probe-blind-spot.test.ts`, the shard-3 file) pass. - `dispatch-gates --ran` reconciles 67 of 67 with 0 NOT-MEASURED. `check:route-ledger-census` reads 82, and the array holds 82. **Round 0 (head `0b1c343e`), kept for the record:** Head `0b1c343e`. The full runtime suite ran at `d663c2fe`, whose only difference from `0b1c343e` is one string in the new ledger note. Every suite that reads the ledger was re-run at `0b1c343e`. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` at `d663c2fe`: 291 files passed, 4204 tests passed, 1 skipped. - At `0b1c343e`: `route-ledger.conformance`, `automation-api-contract-mounts`, the new clone-mount pin and `domains/automation-flow-clone`, 4 files and 31 tests passed. Dogfood: the clone pin and the ledger parity gate, 13/13 passed. - `pnpm --filter @objectstack/runtime typecheck` (`tsc --noEmit` plus `check:test-typecheck`) and `pnpm --filter @objectstack/dogfood typecheck`: both green at `0b1c343e`. `tsc --listFiles` confirms each program contains its new test file (1 hit each). - `dispatch-gates --commands` (67 commands) at `d663c2fe`: 64 exited 0. The other three were resolved as follows: - `check:doc-authoring` was a real finding: a tracker id inside the new ledger note string. It is removed in `0b1c343e`, and the gate now exits 0. - `check-plugin-teardown-shape --self-test` refused on the shallow clone. After fetching its pinned fixture commit it passed, 48 cases. - `check:dual-build-cjs-loads` refused with a prerequisite error: 8 packages outside the build closure had no `dist/`. They are now built. The final-head re-run of all 67, and the `--ran` reconciliation, are in the report comment on the card. - Lint, as a proven narrowing and not a full `pnpm lint`. eslint `--format json` over the 4 touched TS files returned 4 results, 0 errors, 0 warnings. Each file is inside eslint's own population (`--print-config` returns 6/6/5/5 rules). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), and its only file reads are two baseline JSONs this diff does not touch, so the diff cannot move any untouched file's verdict. The full-tree `pnpm lint` is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41dcf11 commit 96e7244

7 files changed

Lines changed: 318 additions & 8 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `POST /api/v1/automation/:name/clone` is served over HTTP
6+
7+
Clause-②: no
8+
9+
Cloning a flow under a new machine name (ADR-0126 §7.1) is how an admin customizes a packaged
10+
flow whose base is locked. The runtime implemented the clone, but the dispatcher never mounted
11+
its route, so every clone answered `404 ENDPOINT_NOT_FOUND` before the request reached it: from
12+
the API, and from the Clone dialog on Setup's packaged-automation page, for every caller and
13+
every body.
14+
15+
The route is now mounted beside `POST /automation/:name/toggle`, at `/api/v1/automation/:name/clone`
16+
and, when environment scoping is enabled, at `/api/v1/environments/:environmentId/automation/:name/clone`.
17+
It answers what the clone implementation already answered: `200 { flow, notice }` for a legal
18+
clone, `400` for a missing or illegal `name` or `label`, `404` for an unknown source flow,
19+
`409 RESOURCE_CONFLICT` for a name already in use, `401` for an anonymous caller and `403` for a
20+
caller without `manage_metadata`. No request or response shape changed.

‎packages/qa/dogfood/test/authz-conformance.matrix.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// dispatcher domain files.
2626
//
2727
// The population comes from `packages/rest/src/rest-route-ledger.ts` (83 rows
28-
// / 18 families) and `packages/runtime/src/route-ledger.ts` (81 rows / 21
28+
// / 18 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21
2929
// domains) because those two are enumerated from a RUNNING server and guarded
3030
// in both directions by their own conformance tests — so a new family or
3131
// domain cannot be silently absent from them, and therefore cannot be silently
@@ -241,7 +241,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
241241
covers: ['actions:domains/actions.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/actions'],
242242
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.' },
243243
{ id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced',
244-
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',
244+
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',
245245
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
246246
// [2026-08-31] Ledger granularity for the same DOMAIN-WIDE gate named
247247
// above — the property the note already relies on ("gating the DOMAIN

‎packages/qa/dogfood/test/authz-probe-blind-spot.census.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
// `RestServer.getRoutes()` on a booted server and guarded per route by
107107
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
108108
// this table reaches 1.
109-
// `packages/runtime/src/route-ledger.ts`: 81 rows over 21 domains. Its
109+
// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its
110110
// machine contract is DOMAIN-level, by live registry introspection
111111
// (`domainRegistry.list()`), the per-route rows being documentation. It
112112
// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all
@@ -362,11 +362,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
362362
// route (door ④ — the list is `GET /meta/flow`). It carried
363363
// `domain: '/automation'`, a key other rows still carry, so `reachable`
364364
// moves with `population`, `blindSpot` stays 0 and `keys` stays 21.
365-
population: 81,
366-
reachable: 81,
365+
// [#20676] 81 -> 82: the `POST /automation/:name/clone` row arrived with its
366+
// mount (ADR-0126 §7.1). It carries `domain: '/automation'`, an EXISTING
367+
// key, so `reachable` moves with `population`, `blindSpot` stays 0 and
368+
// `keys` stays 21 (21 distinct domains before and after, re-derived).
369+
population: 82,
370+
reachable: 82,
367371
blindSpot: 0,
368372
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
369-
controls: { "route: '": 81, "domain: '": 81, RouteLedgerEntry: 2 },
373+
controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 },
370374
note:
371375
'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' +
372376
'(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' +
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20676 — `POST /api/v1/automation/:name/clone` answers over HTTP.
5+
*
6+
* ## What was broken, and why the existing pin could not see it
7+
*
8+
* ADR-0126 §7.1's clone door is how an admin customizes a packaged flow whose
9+
* base is locked. Its domain arm (`runtime/src/domains/automation.ts`) landed
10+
* with #12156, but the dispatcher bridge (`registerAutomationRoutes` in
11+
* `runtime/src/dispatcher-plugin.ts`) mounts every `/automation` route
12+
* explicitly and never mounted this one. So every clone — from the API and
13+
* from Setup's Clone dialog — answered the transport's
14+
* `404 ENDPOINT_NOT_FOUND` before `dispatch()` ran, for every body and every
15+
* caller. `domains/automation-flow-clone.test.ts` stayed green throughout
16+
* because it drives `HttpDispatcher` directly, below the mount.
17+
*
18+
* This file drives the REAL composition instead: `bootStack` boots the CRM app
19+
* with the automation service over the in-process Hono app, so a request here
20+
* crosses exactly the mount a browser crosses.
21+
*
22+
* ## Why each case discriminates the mount
23+
*
24+
* An unmounted path answers 404 to everyone, so none of the verdicts below can
25+
* be produced without the mount: the anonymous floor's `UNAUTHENTICATED` is
26+
* minted inside the automation domain, and so are the 200 with its notice, the
27+
* 409 and the 400. The environment-scoped twin
28+
* (`/environments/:environmentId/automation/:name/clone`) is not mounted by
29+
* this harness at all (it boots the dispatcher without project scoping); it is
30+
* pinned over a real socket in
31+
* `runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts`.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import crmStack from '@objectstack/example-crm';
36+
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core';
37+
import { bootStack, type VerifyStack } from '@objectstack/verify';
38+
39+
// Read from the implementation rather than restated: the notice is a contract
40+
// value, and a second spelling of it would agree only until one of them moves.
41+
import { FLOW_CLONE_NOTICE } from '../../../runtime/src/flow-clone.js';
42+
43+
/** The CRM app's own shipped flow — a real definition, not one this test injects. */
44+
const SOURCE = 'crm_convert_lead_wizard';
45+
/** A customer-owned machine name no shipped flow uses. */
46+
const CLONE = 'crm_convert_lead_wizard_clone_20676';
47+
const CLONE_LABEL = 'Convert Lead (clone)';
48+
49+
interface ErrorBody { success?: boolean; error?: { code?: string; httpStatus?: number } }
50+
51+
describe('#20676 — POST /automation/:name/clone is mounted on the dispatcher bridge', () => {
52+
let stack: VerifyStack;
53+
let adminToken: string;
54+
55+
beforeAll(async () => {
56+
stack = await bootStack(crmStack as never, { automation: true });
57+
adminToken = await stack.signIn();
58+
}, 180_000);
59+
60+
afterAll(async () => {
61+
await stack?.stop?.();
62+
});
63+
64+
it('an anonymous caller is refused by the automation domain — 401 UNAUTHENTICATED, not the transport 404', async () => {
65+
const res = await stack.api(`/automation/${SOURCE}/clone`, {
66+
method: 'POST',
67+
headers: { 'Content-Type': 'application/json' },
68+
body: JSON.stringify({ name: 'anon_clone_20676', label: 'Anonymous clone' }),
69+
});
70+
const text = await res.clone().text();
71+
expect(res.status, `anonymous clone: ${text}`).toBe(ANONYMOUS_DENY_STATUS);
72+
expect(((await res.json()) as ErrorBody).error?.code).toBe(ANONYMOUS_DENY_CODE);
73+
74+
// Nothing was registered under the anonymous caller's name.
75+
const readBack = await stack.apiAs(adminToken, 'GET', '/automation/anon_clone_20676');
76+
expect(readBack.status).toBe(404);
77+
});
78+
79+
it('a legal clone answers 200 with the flow and FLOW_CLONE_NOTICE, and the clone reads back', async () => {
80+
// Control: the target does not exist before the clone, so the read-back
81+
// below is evidence of THIS request rather than of the fixture.
82+
const before = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
83+
expect(before.status, `pre-clone read of ${CLONE}`).toBe(404);
84+
85+
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
86+
name: CLONE,
87+
label: CLONE_LABEL,
88+
});
89+
const text = await res.clone().text();
90+
expect(res.status, `legal clone: ${text}`).toBe(200);
91+
const body = (await res.json()) as {
92+
success?: boolean;
93+
data?: { flow?: { name?: string; label?: string; status?: string }; notice?: string };
94+
};
95+
expect(body.success).toBe(true);
96+
expect(body.data?.notice).toBe(FLOW_CLONE_NOTICE);
97+
expect(body.data?.flow).toMatchObject({ name: CLONE, label: CLONE_LABEL, status: 'draft' });
98+
99+
const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${CLONE}`);
100+
const readText = await readBack.clone().text();
101+
expect(readBack.status, `read-back of ${CLONE}: ${readText}`).toBe(200);
102+
const read = (await readBack.json()) as { data?: { name?: string; label?: string; type?: string } };
103+
expect(read.data).toMatchObject({ name: CLONE, label: CLONE_LABEL, type: 'screen' });
104+
105+
// The source is untouched by its clone.
106+
const source = await stack.apiAs(adminToken, 'GET', `/automation/${SOURCE}`);
107+
expect(source.status).toBe(200);
108+
expect(((await source.json()) as { data?: { name?: string } }).data?.name).toBe(SOURCE);
109+
});
110+
111+
it('an illegal machine name answers 400, and nothing is registered under it', async () => {
112+
const illegal = 'Not A Machine Name';
113+
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
114+
name: illegal,
115+
label: 'Illegal clone',
116+
});
117+
const text = await res.clone().text();
118+
expect(res.status, `illegal-name clone: ${text}`).toBe(400);
119+
expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED');
120+
121+
const readBack = await stack.apiAs(adminToken, 'GET', `/automation/${encodeURIComponent(illegal)}`);
122+
expect(readBack.status).toBe(404);
123+
});
124+
125+
it('a clone with no `name` is refused 400 before anything is registered', async () => {
126+
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, { label: 'No name' });
127+
const text = await res.clone().text();
128+
expect(res.status, `name-less clone: ${text}`).toBe(400);
129+
expect(((await res.json()) as ErrorBody).error?.code).toBe('VALIDATION_FAILED');
130+
});
131+
132+
it('a taken name answers 409 RESOURCE_CONFLICT — the same-name clone ADR-0126 §7.1 refuses', async () => {
133+
const res = await stack.apiAs(adminToken, 'POST', `/automation/${SOURCE}/clone`, {
134+
name: SOURCE,
135+
label: 'Same-name clone',
136+
});
137+
const text = await res.clone().text();
138+
expect(res.status, `same-name clone: ${text}`).toBe(409);
139+
expect(((await res.json()) as ErrorBody).error?.code).toBe('RESOURCE_CONFLICT');
140+
});
141+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20676 — the flow clone door is MOUNTED, at both bases `registerAutomationRoutes`
5+
* serves.
6+
*
7+
* ## Why a socket, and why this file beside the dogfood pin
8+
*
9+
* `domains/automation.ts` has answered `POST /:name/clone` (ADR-0126 §7.1)
10+
* since #12156, but the bridge mounts every `/automation` route explicitly and
11+
* never mounted this one, so on a real host the transport's `notFound`
12+
* answered before `dispatch()` ran. A test that calls the handler cannot see
13+
* that; only a request that crosses the mount can.
14+
* `qa/dogfood/test/automation-flow-clone-door.dogfood.test.ts` drives the
15+
* clone end to end on a composed app, but its harness boots the dispatcher
16+
* WITHOUT project scoping, so the environment-scoped twin
17+
* (`${prefix}/environments/:environmentId/automation/:name/clone`) is
18+
* unobservable there. This file boots the composition that mounts both.
19+
*
20+
* ## The composition, and the discriminator
21+
*
22+
* `plugin-hono-server` + the dispatcher, `enableProjectScoping: true` under
23+
* `projectResolution: 'auto'` — the branch that mounts the plain AND the
24+
* scoped base. No `createHonoApp`, no service plugins, so a mount is the only
25+
* way in. The discriminator is `dispatcher-plugin.scoped-packages-door.integration.test.ts`'s:
26+
* the automation domain's first statement is the anonymous-deny floor, so a
27+
* credential-less request that REACHES the dispatcher answers
28+
* `ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` (imported, not spelled) — a
29+
* verdict no transport-level sink emits — while a path no mount claims answers
30+
* the transport's own 404. The negative control measures that second direction
31+
* on a sibling path rather than assuming it.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core';
36+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
37+
import type { IHttpServer } from '@objectstack/spec/contracts';
38+
39+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
40+
41+
const PREFIX = '/api/v1';
42+
const ENV_ID = 'env_alpha';
43+
const FLOW = 'crm_convert_lead_wizard';
44+
45+
let kernel: LiteKernel | undefined;
46+
let baseUrl = '';
47+
48+
beforeAll(async () => {
49+
kernel = new LiteKernel();
50+
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
51+
kernel.use(createDispatcherPlugin({
52+
prefix: PREFIX,
53+
scoping: { enableProjectScoping: true, projectResolution: 'auto' },
54+
enforceProjectMembership: false,
55+
securityHeaders: false,
56+
}));
57+
await kernel.bootstrap();
58+
const httpServer = kernel.getService<IHttpServer>('http.server');
59+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
60+
}, 60_000);
61+
62+
afterAll(async () => {
63+
if (!kernel) return;
64+
await Promise.race([
65+
kernel.shutdown(),
66+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
67+
]);
68+
}, 60_000);
69+
70+
async function post(path: string): Promise<{ status: number; body: any }> {
71+
const res = await fetch(`${baseUrl}${path}`, {
72+
method: 'POST',
73+
headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify({ name: 'probe_clone_20676', label: 'Probe clone' }),
75+
});
76+
let body: any;
77+
try { body = await res.json(); } catch { body = undefined; }
78+
return { status: res.status, body };
79+
}
80+
81+
/** The anonymous floor answered — minted inside `dispatch()`, never by the transport. */
82+
function dispatcherAnswered(r: { status: number; body: any }): boolean {
83+
return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE;
84+
}
85+
86+
/** The shape "no door answered" takes on this transport. */
87+
function transportRefused(r: { status: number; body: any }): boolean {
88+
const code = r.body?.error?.code;
89+
return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND');
90+
}
91+
92+
describe('#20676 — the discriminator, measured in both directions', () => {
93+
// The control is the EXECUTION door, not `/:name/toggle`, on purpose: it has
94+
// the same two-segment POST shape and the same domain-wide anonymous floor,
95+
// and it sits outside every authoring gate, so its answer here does not
96+
// depend on what the toggle door does to a given flow. The control proves
97+
// one thing only — a mounted `/automation/:name/VERB` reaches `dispatch()`.
98+
it('POSITIVE CONTROL: the sibling `/:name/trigger` mount answers from the domain', async () => {
99+
const r = await post(`${PREFIX}/automation/${FLOW}/trigger`);
100+
expect(dispatcherAnswered(r), `trigger -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
101+
}, 60_000);
102+
103+
it('NEGATIVE CONTROL: an unmounted sibling segment answers the transport, not the domain', async () => {
104+
const r = await post(`${PREFIX}/automation/${FLOW}/no-such-verb`);
105+
expect(dispatcherAnswered(r)).toBe(false);
106+
expect(transportRefused(r), `unmounted sibling -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
107+
}, 60_000);
108+
});
109+
110+
describe('#20676 — POST /automation/:name/clone crosses the mount at both bases', () => {
111+
for (const base of [PREFIX, `${PREFIX}/environments/${ENV_ID}`]) {
112+
it(`POST ${base}/automation/:name/clone answers through the dispatcher`, async () => {
113+
const r = await post(`${base}/automation/${FLOW}/clone`);
114+
expect(dispatcherAnswered(r), `clone at ${base} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true);
115+
}, 60_000);
116+
}
117+
});

0 commit comments

Comments
 (0)