From 66291b17cf2f33483ad62333650776dba2ab4f14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:48:40 +0000 Subject: [PATCH 1/2] fix(app-shell): a declared flow action's button starts its flow through the action door (objectui#12037) Both console flow-launch handlers started every flow through POST /api/v1/automation/:flow/trigger, so a declared flow action's own server-side gates (requiredPermissions per ADR-0066 D4, activation, param contract, caller-scoped subject load) never ran for the click. Triage ruling B: a click whose object and name resolve to a declared type 'flow' action with the same target, in the object metadata the console already holds, goes through POST /api/v1/actions/:object/:action (request built by core's createServerActionHandler, answer read by the flow rule). Every other flow start (an inline page button with no or a non-matching name, a dashboard header action) stays on the trigger route: it has no action gate to apply, and ruling A's elevation refusal holds there. One module-internal launcher, utils/flowLaunch, serves both handlers. The record page's related-list toolbar flow action (no row) no longer sends the parent page's id against the child object. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/12037-flow-action-door.md | 13 + content/docs/guide/architecture-overview.md | 2 +- .../src/flow-envelope.ratchet.test.ts | 29 +- ...ctionRuntime.flowActionDoor-12037.test.tsx | 217 ++++++++++++++ .../src/hooks/useConsoleActionRuntime.tsx | 81 +++-- packages/app-shell/src/utils/flowLaunch.ts | 193 ++++++++++++ ...rdDetailView.flowActionDoor-12037.test.tsx | 277 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 88 +++--- 8 files changed, 826 insertions(+), 74 deletions(-) create mode 100644 .changeset/12037-flow-action-door.md create mode 100644 packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx create mode 100644 packages/app-shell/src/utils/flowLaunch.ts create mode 100644 packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx diff --git a/.changeset/12037-flow-action-door.md b/.changeset/12037-flow-action-door.md new file mode 100644 index 0000000000..660ee310b1 --- /dev/null +++ b/.changeset/12037-flow-action-door.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +A console click on a declared `type: 'flow'` action now starts its flow through the action endpoint, `POST /api/v1/actions/:object/:action`, so the server-side gates that action declares apply to the click (objectui#12037). + +Both flow-launch handlers used to start every flow through `POST /api/v1/automation/:flow/trigger`: the list and page runtime (`useConsoleActionRuntime`) and the record page (`RecordDetailView`). That route starts a flow by name, so the action's own `requiredPermissions` (ADR-0066 D4, whose console half only hides the button), its activation switch, its param contract and the caller-scoped load of its subject record never ran for the click. + +- **Which door.** A click goes through the action endpoint exactly when its object and `name` resolve to a declared action of type `flow` with the same `target`, in the object metadata the console already holds. A refusal comes back with the endpoint's own code (for example `403 PERMISSION_DENIED` for a missing capability) and shows as the action's error toast. A paused screen run opens the flow runner as before and resumes under the declared `target`. A refused end and a failed run show as before. +- **What stays on the trigger route.** A flow start that names no declared action keeps the trigger route: an inline page button with no `name` or a non-matching one, and a dashboard header action. Such a start has no action gate to apply, and the trigger route refuses an elevated self-triggered flow just as the action endpoint does (objectstack-ai/objectstack#22424). An object-less declared action, and a standalone action row that no object definition embeds, are not in the object metadata, so a click on one keeps the trigger route, as every flow click did before. +- **A related list's toolbar flow action no longer sends the parent record's id.** On a record page, a child object's flow action launched from its related list's toolbar has no row. It used to be sent with the page's own record id against the child object. It now goes out with no record, as an object-level start. A row action still addresses the child row. + +Nothing published changes: no export, prop or language-pack key is added. diff --git a/content/docs/guide/architecture-overview.md b/content/docs/guide/architecture-overview.md index ae1a9fa7d5..5396938cb4 100644 --- a/content/docs/guide/architecture-overview.md +++ b/content/docs/guide/architecture-overview.md @@ -258,7 +258,7 @@ Executes action schemas and returns directives: | `url` | Navigate to a URL | | `api` | Make an API request (AJAX) | | `modal` | Open a modal with a nested schema | -| `flow` | Execute a multi-step action sequence | +| `flow` | Start a server-side flow by name (`target`). In the console, a click on a declared `type: 'flow'` action goes through the action endpoint, `POST /api/v1/actions/{object}/{action}`, so every server-side gate the action declares applies; a flow start that names no declared action (an inline page button, a dashboard header action) goes through `POST /api/v1/automation/{flow}/trigger`. A run that pauses at a `screen` node opens the flow runner | ### Server Action Dispatch (`serverActionHandler.ts`) diff --git a/packages/app-shell/src/flow-envelope.ratchet.test.ts b/packages/app-shell/src/flow-envelope.ratchet.test.ts index 77adedd106..79d87672f0 100644 --- a/packages/app-shell/src/flow-envelope.ratchet.test.ts +++ b/packages/app-shell/src/flow-envelope.ratchet.test.ts @@ -18,7 +18,9 @@ * * If this fails: don't hand-roll the check. Import `interpretFlowResponse` from * `utils/flowResponse` — it classifies transport failure / flow failure / - * screen pause / terminal success, and guarantees a string error. + * screen pause / terminal success, and guarantees a string error. A flow + * LAUNCH goes through `launchConsoleFlow` (`utils/flowLaunch`), which already + * applies it on both doors (objectui#12037). */ import { describe, it, expect } from 'vitest'; @@ -81,15 +83,32 @@ describe('#2958 ratchet — flow trigger/resume callers use interpretFlowRespons expect(offenders).toEqual([]); }); - it('still guards something — the three known callers are present', () => { + it('still guards something — the known callers are present', () => { // A ratchet that matches nothing passes vacuously forever. Pin that the - // copies it exists for are actually being scanned. + // copies it exists for are actually being scanned. Since objectui#12037 + // the two LAUNCH handlers no longer build the request themselves: both + // call `launchConsoleFlow` (`utils/flowLaunch`), which picks the door + // and owns the trigger-route call, so it is the launch caller scanned + // here; `FlowRunner` is the resume caller. expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual( expect.arrayContaining([ - path.join('hooks', 'useConsoleActionRuntime.tsx'), - path.join('views', 'RecordDetailView.tsx'), + path.join('utils', 'flowLaunch.ts'), path.join('views', 'FlowRunner.tsx'), ]), ); }); + + it('both flow-launch handlers delegate to the one launch module', () => { + // The other half of the move above: a handler that went back to a + // hand-rolled fetch would name the route again and be caught by the + // offenders assertion, and one that dropped the delegation entirely is + // caught here. + for (const rel of [ + path.join('hooks', 'useConsoleActionRuntime.tsx'), + path.join('views', 'RecordDetailView.tsx'), + ]) { + const src = stripComments(readFileSync(path.join(appShellSrc, rel), 'utf8')); + expect(src, `${rel} should launch flows via launchConsoleFlow`).toMatch(/launchConsoleFlow\s*(<[^>]*>)?\s*\(/); + } + }); }); diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx new file mode 100644 index 0000000000..80f1667b19 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionDoor-12037.test.tsx @@ -0,0 +1,217 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12037 — which door a LIST / PAGE flow click starts its flow through. + * + * Triage ruling B: a click whose object and `name` resolve to a DECLARED + * `type: 'flow'` action with the same target goes through the action door, + * `POST /api/v1/actions/:object/:action`, so the server-side gates that action + * declares (ADR-0066 D4 `requiredPermissions` first) apply to the click. Every + * other flow click — an inline page button with no or a non-matching `name`, a + * dashboard header's synthesized `name: actionUrl` — stays on the trigger + * route, where ruling A's elevated-start refusal holds. + * + * Driven end to end through the real runtime: `ConsoleActionRuntimeProvider` + * (the real `ActionProvider` / `ActionRunner` and its toast sink), the real + * flow handler and launch module, and the real `FlowRunner`. Only the network + * (`authFetch`), the router and the toast library are stubbed. Server answers + * are the envelopes the two doors serve (ADR-0112). + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import React from 'react'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + fn.info = vi.fn(); + fn.warning = vi.fn(); + return { toast: fn }; +}); + +import { toast } from 'sonner'; +import { ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime'; +import { MetadataCtx, useAction } from '@object-ui/react'; + +const OBJECT = 'showcase_task'; +const FLOW = 'showcase_reassign_wizard'; +/** The declared action, as the object metadata carries it. */ +const DECLARED = { + name: 'showcase_bulk_reassign', + label: 'Reassign…', + type: 'flow', + target: FLOW, + locations: ['list_item', 'list_toolbar'], + requiredPermissions: ['showcase.reassign'], +}; +const OBJECTS = [{ name: OBJECT, label: 'Task', fields: {}, actions: [DECLARED] }]; +/** The door's own refusal for a caller missing the capability (`actionPermissionError`). */ +const GATE_MESSAGE = + "Action 'showcase_bulk_reassign' on 'showcase_task' requires capability [showcase.reassign] — caller is missing [showcase.reassign]"; +/** Ruling A's refusal, as the trigger route serves it (`ELEVATED_START_REFUSAL`). */ +const ELEVATED_MESSAGE = + 'This caller may not start this flow. A flow that runs on its own trigger starts there, ' + + "or as a sub-flow from a parent flow's `subflow` node."; + +function refusal(status: number, code: string, message: string) { + return { ok: false, status, json: async () => ({ success: false, error: { code, message, httpStatus: status } }) }; +} +function answered(data: Record) { + return { ok: true, status: 200, json: async () => ({ success: true, data }) }; +} + +beforeEach(() => { + authFetchSpy.mockReset(); + (toast as any).mockClear(); + (toast as any).error.mockClear(); + (toast as any).success.mockClear(); +}); + +/** Mount the runtime, click once through the real runner, resolve to the handler's result. */ +async function click( + action: Record, + opts: { objects?: any[]; objectName?: string; store?: any } = {}, +) { + let result: any; + function Probe() { + const { execute } = useAction(); + return ( + + ); + } + const tree = ( + + + + ); + render(opts.store ? {tree} : tree); + fireEvent.click(screen.getByText('run-flow')); + await waitFor(() => expect(result).toBeDefined()); + const [url, init] = authFetchSpy.mock.calls[0]; + return { result, url: String(url), body: JSON.parse(String(init.body)) }; +} + +const ROW_CLICK = { ...DECLARED, params: { _rowRecord: { id: 'task-7', name: 'Write report' } } }; + +describe('objectui#12037 — a declared flow action is started through the action door', () => { + it("is refused by the server on click with the door's code, and the console shows that refusal", async () => { + authFetchSpy.mockResolvedValue(refusal(403, 'PERMISSION_DENIED', GATE_MESSAGE)); + + const { result, url, body } = await click(ROW_CLICK); + + expect(url).toBe(`/api/v1/actions/${OBJECT}/showcase_bulk_reassign`); + expect(body).toEqual({ recordId: 'task-7', params: {} }); + expect(result).toEqual({ success: false, error: GATE_MESSAGE }); + // The runner's post-execution hook toasts the server's refusal. + expect((toast as any).error).toHaveBeenCalledWith(GATE_MESSAGE); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('control: an allowed click runs, and its paused screen resumes under the declaration target', async () => { + authFetchSpy.mockResolvedValueOnce(answered({ + success: true, + status: 'paused', + runId: 'run-1', + flowLabel: 'Reassign', + screen: { nodeId: 'collect', title: 'New Assignee', fields: [] }, + })); + + const { result, url } = await click(ROW_CLICK); + + expect(url).toBe(`/api/v1/actions/${OBJECT}/showcase_bulk_reassign`); + expect(result).toEqual({ success: true, silent: true }); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByRole('heading', { name: 'New Assignee' })).toBeTruthy(); + + authFetchSpy.mockResolvedValueOnce(answered({ success: true, status: 'completed' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(2)); + expect(String(authFetchSpy.mock.calls[1][0])).toBe(`/api/v1/automation/${FLOW}/runs/run-1/resume`); + }); + + it("reads the console's store when the caller holds no objects, and loads it before deciding", async () => { + authFetchSpy.mockResolvedValue(refusal(403, 'PERMISSION_DENIED', GATE_MESSAGE)); + const ensureType = vi.fn(async () => OBJECTS); + const store = { + objects: OBJECTS, apps: [], dashboards: [], reports: [], pages: [], loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, ensureType, + getItem: async () => null, getItemsByType: () => [], + }; + + const { url } = await click(ROW_CLICK, { objects: [], store }); + + expect(ensureType).toHaveBeenCalledWith('object'); + expect(url).toBe(`/api/v1/actions/${OBJECT}/showcase_bulk_reassign`); + }); +}); + +describe('objectui#12037 — a flow start that names no declared action stays on the trigger route', () => { + it('an inline page button with no name', async () => { + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + + const { result, url, body } = await click({ type: 'flow', label: 'Run', target: FLOW }); + + expect(url).toBe(`/api/v1/automation/${FLOW}/trigger`); + expect(body).toEqual({ objectName: OBJECT, params: {} }); + expect(result).toMatchObject({ success: true }); + }); + + it("a dashboard header action's synthesized `name: actionUrl`", async () => { + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + + const { url } = await click({ name: 'convert_lead_wizard', type: 'flow', target: 'convert_lead_wizard' }); + + expect(url).toBe('/api/v1/automation/convert_lead_wizard/trigger'); + }); + + it('a name that matches a declaration of another target, or of another type', async () => { + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + const { url: otherTarget } = await click({ ...ROW_CLICK, target: 'some_other_flow' }); + expect(otherTarget).toBe('/api/v1/automation/some_other_flow/trigger'); + + authFetchSpy.mockClear(); + cleanup(); + const scriptTyped = [{ ...OBJECTS[0], actions: [{ ...DECLARED, type: 'script' }] }]; + const { url: otherType } = await click({ ...ROW_CLICK }, { objects: scriptTyped }); + expect(otherType).toBe(`/api/v1/automation/${FLOW}/trigger`); + }); + + it('control: an object-less declared action is absent from object metadata, so it keeps the trigger route', async () => { + // A nav item resolves an object-less action from `action` metadata and + // dispatches it with no `objectName`, under a runtime bound to no object. + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + + const { url } = await click({ ...ROW_CLICK }, { objectName: '' }); + + expect(url).toBe(`/api/v1/automation/${FLOW}/trigger`); + }); + + it('an elevated self-triggered target is still refused there, and the console shows it', async () => { + authFetchSpy.mockResolvedValue(refusal(403, 'PERMISSION_DENIED', ELEVATED_MESSAGE)); + + const { result, url } = await click({ type: 'flow', label: 'Run', target: 'nightly_sync' }); + + expect(url).toBe('/api/v1/automation/nightly_sync/trigger'); + expect(result).toEqual({ success: false, error: ELEVATED_MESSAGE }); + expect((toast as any).error).toHaveBeenCalledWith(ELEVATED_MESSAGE); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 09acb8569b..a0b3fc0f22 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -50,7 +50,7 @@ import { resolveActionParams, withKnownObjects } from '../utils/resolveActionPar import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../environment/EnvironmentEntitlementDialog.js'; import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements.js'; import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js'; -import { interpretFlowResponse, judgeFlowLaunch } from '../utils/flowResponse.js'; +import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js'; import { actionContextOrg } from '../utils/actionContextOrg.js'; @@ -586,9 +586,30 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } }, [dataSource, objApiName, objects, metadata, authFetch, activeOrganization, refresh, openEntitlementDialog, t]); - // Flow action handler — POST to /api/v1/automation/{name}/trigger. - // `context` is the shared ActionRunner context (registered handlers are - // invoked as `handler(action, runnerContext)`). + /** + * Flow action handler — starts the flow through one of two doors + * (objectui#12037, triage ruling B; the rule and its reasons live in + * `utils/flowLaunch`): + * + * - a click whose object and `name` resolve to a DECLARED `type: 'flow'` + * action with the same target goes through the action door, + * `POST /api/v1/actions/:object/:action`, so every server-side gate that + * action declares applies to the click (ADR-0066 D4); + * - every other flow click — an inline page button with no or a + * non-matching `name`, a dashboard header's synthesized + * `name: actionUrl` — stays on `POST /api/v1/automation/:flow/trigger`. + * An undeclared flow start has no action gate to apply, and ruling A's + * elevation refusal already holds on the trigger route. + * + * The lookup reads the object metadata this hook already holds (the + * caller's `objects`, then the console's store, asked to load first as the + * param dialog asks). An object-less declared action or a standalone action + * row no object embeds is absent from it, so such a click keeps taking the + * trigger route, as every flow click did before. + * + * `context` is the shared ActionRunner context (registered handlers are + * invoked as `handler(action, runnerContext)`). + */ const flowHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise => { const flowName = action.target || action.name; if (!flowName) { @@ -620,36 +641,34 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } } if (recordId != null && params.recordId == null) params.recordId = recordId; - const res = await authFetch( - `${baseUrl}/api/v1/automation/${encodeURIComponent(flowName)}/trigger`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recordId, - objectName: action.objectName || objApiName, - params, - }), - }, - ); - const json = await res.json().catch(() => null); - // Single source for the flow-response rule AND for what a launch does - // with it — shared with RecordDetailView's copy of this handler (and the - // interpretation with FlowRunner's resume). Each launch copy once held - // its own branch set, and each time a kind was missing it fell into the - // terminal-success tail: a failed run toasted green (#2958), and a run - // that ended `refused` without pausing toasted the action's - // `successMessage` and refreshed while the refusal was never shown - // (objectui#9973). See utils/flowResponse. - const judged = judgeFlowLaunch( - interpretFlowResponse(res, json, `Flow "${flowName}"`), - action.refreshAfter, - ); + // Ask the store for the object type BEFORE the lookup, as the param + // dialog does (`6cc910b6d`): it answers from cache once warm, and it is + // what makes "this click names no declared action" an answer rather + // than a race against a cold store. + await metadata.ensureType('object').catch(() => []); + // Single source for the door choice, the request, the flow-response + // rule AND what a launch does with it — shared with RecordDetailView's + // copy of this handler (and the interpretation with FlowRunner's + // resume). Each launch copy once held its own branch set, and each time + // a kind was missing it fell into the terminal-success tail: a failed + // run toasted green (#2958), and a run that ended `refused` without + // pausing toasted the action's `successMessage` and refreshed while the + // refusal was never shown (objectui#9973). See utils/flowLaunch. + const launch = await launchConsoleFlow({ + fetch: authFetch, + baseUrl, + action, + objectName: action.objectName || objApiName, + recordId, + triggerParams: params, + objects: withKnownObjects(objects, metadata.objects), + }); + const judged = launch.judged; // Paused at a `screen` node: FlowRunner renders the form + resumes, and // refreshes on completion. if (judged.followUp?.kind === 'screen') { setScreenFlow({ - flowName, + flowName: launch.flowName, flowLabel: judged.followUp.flowLabel, runId: judged.followUp.runId, screen: judged.followUp.screen, @@ -665,7 +684,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } catch (error) { return { success: false, error: (error as Error).message }; } - }, [authFetch, objApiName, refresh]); + }, [authFetch, objApiName, objects, metadata, refresh]); // Server-side action handler — POST /api/v1/actions/{object}/{action}, built // from @object-ui/core's `createServerActionHandler` via the shared console diff --git a/packages/app-shell/src/utils/flowLaunch.ts b/packages/app-shell/src/utils/flowLaunch.ts new file mode 100644 index 0000000000..ceac2d8c9a --- /dev/null +++ b/packages/app-shell/src/utils/flowLaunch.ts @@ -0,0 +1,193 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * How the console STARTS a flow from a click, and which door it knocks on + * (objectui#12037). Module-internal: the two flow-launch handlers — + * `useConsoleActionRuntime`'s and `RecordDetailView`'s — call + * {@link launchConsoleFlow}, and nothing outside this package can import it + * (it is not re-exported from `utils/index` or the package entry). + * + * ## Two doors, chosen by a lookup, not a guess + * + * A flow can be started two ways, and they do not apply the same gates: + * + * - **the action door**, `POST /api/v1/actions/:object/:action` — the server + * resolves the DECLARED action by name and runs every gate the declaration + * carries before the flow starts: its `requiredPermissions` (ADR-0066 D4, + * whose UI half only hides the button), its activation switch, its param + * contract, and a caller-scoped load of the subject record; + * - **the trigger route**, `POST /api/v1/automation/:flow/trigger` — starts a + * flow by name. Ruling A (objectstack-ai/objectstack#22424) refuses a + * non-system caller an elevated self-triggered flow there exactly as at the + * action door; nothing else about an action applies, because there is none. + * + * A click goes through the action door EXACTLY when its object and its `name` + * resolve to a declared action of type `flow` with the same target, in the + * object metadata the console already holds ({@link findDeclaredFlowAction}). + * Every other flow click stays on the trigger route. That is not a fallback + * for a door that failed: a flow start that names no declared action has no + * action gate to apply, so the trigger route IS its door. The spec authors two + * such starts on purpose (triage ruling B on objectui#12037): + * + * - an inline page `action:button` (and its `action:icon` / `action:group` / + * `action:menu` siblings) with `actionType: 'flow'` and a `target`, whose + * `name` is optional ("an inline page button is not a registered object + * action") or names no declared action; + * - a dashboard header action `{ actionType: 'flow', actionUrl }`, which the + * dashboard renderer dispatches as `{ name: actionUrl, target: actionUrl }`. + * + * ⚠️ **What the lookup cannot see, said rather than assumed.** The object + * metadata carries an object's own `actions[]`, which since `defineStack`'s + * bound-action merge includes every `defineAction({ objectName })` declared + * beside it. It does NOT carry an object-LESS declared action, nor a + * standalone authored action row that no object definition embeds. A click on + * either takes the trigger route — what every flow click did before this + * module existed, so it is no regression, and the elevation refusal above + * still holds for it. Seeing those would take a click-time `action` metadata + * fetch, which this module deliberately does not make. + * + * ## Who owns which half of the door's exchange + * + * The `/actions` REQUEST belongs to `@object-ui/core`'s dispatcher — + * `createServerActionHandler` owns name-only identity (ADR-0110 D1), the + * `{ recordId, params }` body and the `_rowRecord` strip, and app-shell names + * that route nowhere else (`actions-envelope.ratchet.test.ts`). Its ANSWER is + * not an action envelope, though: it is the flow's `AutomationResult`, and it + * is read by the flow rule (`interpretFlowResponse` / `judgeFlowLaunch`), so a + * paused screen run still opens `FlowRunner`, a refused end still opens the + * Close-only notice, and a `FLOW_FAILED` still prefers the author's + * `errorMessage`. The dispatcher's own reading (`interpretActionResponse`) has + * none of those arms, so the transport is injected and the raw answer is read + * here; the dispatcher's `ActionResult` is used only when no answer arrived. + */ + +import { createServerActionHandler } from '@object-ui/core'; +import type { ActionDef, ActionResult, ServerActionFetch } from '@object-ui/core'; +import { interpretFlowResponse, judgeFlowLaunch, type FlowLaunchJudgement } from './flowResponse.js'; + +/** The door a flow click went through. */ +export type ConsoleFlowLaunchDoor = 'action' | 'trigger'; + +/** The keys of a declared action the lookup reads. */ +interface DeclaredActionShape { + name?: unknown; + type?: unknown; + target?: unknown; +} + +/** + * The declared action a flow click names, or `undefined` when it names none. + * + * A match needs all four: the object is in `objects`, it declares an action + * of this `name`, that action's `type` is `'flow'`, and its `target` is the + * flow the click would start. A same-named action of another type, or one + * aimed at another flow, is not the click's declaration — routing the click + * to it would run something the click did not ask for. + */ +export function findDeclaredFlowAction( + objects: readonly unknown[] | undefined, + objectName: string | undefined, + actionName: string | undefined, + flowName: string, +): (DeclaredActionShape & { name: string; target: string }) | undefined { + if (!objectName || !actionName || !Array.isArray(objects)) return undefined; + const objectDef = objects.find( + (o) => (o as { name?: unknown } | null | undefined)?.name === objectName, + ) as { actions?: unknown } | undefined; + const actions: unknown[] = Array.isArray(objectDef?.actions) ? objectDef.actions : []; + const declared = actions.find( + (a) => (a as DeclaredActionShape | null | undefined)?.name === actionName, + ) as DeclaredActionShape | undefined; + if (!declared || declared.type !== 'flow' || declared.target !== flowName) return undefined; + return declared as DeclaredActionShape & { name: string; target: string }; +} + +export interface ConsoleFlowLaunchInput { + /** The host's authenticated transport. */ + fetch: ServerActionFetch; + baseUrl: string; + /** The clicked action. Its flow is `target`, else its `name`. */ + action: ActionDef; + /** The object the click acts on — the action's own, else the host's. */ + objectName: string | undefined; + /** The record the click acts on, already resolved by the host. */ + recordId: unknown; + /** The trigger route's params bag, `_rowRecord` already stripped. */ + triggerParams: Record; + /** The object metadata the host already holds. */ + objects: readonly unknown[] | undefined; +} + +export interface ConsoleFlowLaunch { + door: ConsoleFlowLaunchDoor; + /** + * The flow the run belongs to — what `FlowRunner` resumes it under. On the + * action door it is the declaration's `target`, which the lookup required to + * equal the click's flow. + */ + flowName: string; + judged: FlowLaunchJudgement; +} + +/** Start the flow a click names, through the door {@link findDeclaredFlowAction} picks. */ +export async function launchConsoleFlow( + input: ConsoleFlowLaunchInput, +): Promise> { + const { action, objectName, recordId, baseUrl } = input; + const clickedFlow = String(action.target || action.name || ''); + const declared = findDeclaredFlowAction(input.objects, objectName, action.name, clickedFlow); + + if (declared) { + const flowName = declared.target; + let answered: { res: { ok: boolean; status: number }; json: unknown } | undefined; + const keepAnswer: ServerActionFetch = async (url, init) => { + const res = await input.fetch(url, init); + const json = await res.json().catch(() => null); + answered = { res: { ok: res.ok, status: res.status }, json }; + return { ok: res.ok, status: res.status, json: async () => json }; + }; + const dispatch = createServerActionHandler({ + fetch: keepAnswer, + baseUrl, + // The host already resolved the record (its own selection and + // record-page rules); the dispatcher must not re-derive it. + resolveRecordId: () => ({ recordId }), + }); + const dispatched: ActionResult = await dispatch({ ...action, objectName }); + if (!answered) { + return { door: 'action', flowName, judged: { result: dispatched, refresh: false } }; + } + return { + door: 'action', + flowName, + judged: judgeFlowLaunch( + interpretFlowResponse(answered.res, answered.json, `Flow "${flowName}"`), + action.refreshAfter, + ), + }; + } + + const res = await input.fetch( + `${baseUrl}/api/v1/automation/${encodeURIComponent(clickedFlow)}/trigger`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recordId, objectName, params: input.triggerParams }), + }, + ); + const json = await res.json().catch(() => null); + return { + door: 'trigger', + flowName: clickedFlow, + judged: judgeFlowLaunch( + interpretFlowResponse(res, json, `Flow "${clickedFlow}"`), + action.refreshAfter, + ), + }; +} diff --git a/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx b/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx new file mode 100644 index 0000000000..e7d362c540 --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.flowActionDoor-12037.test.tsx @@ -0,0 +1,277 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12037 — which door a RECORD-PAGE flow click starts its flow + * through, including a related list's row and toolbar actions on a CHILD. + * + * Triage ruling B: a click whose object and `name` resolve to a DECLARED + * `type: 'flow'` action with the same target goes through the action door, + * `POST /api/v1/actions/:object/:action`; every other flow click stays on the + * trigger route. A related-list row action addresses the CHILD: `:object` is + * the child object, `:action` the child action's declared name, `recordId` the + * child row. A related list's toolbar action has no row, and must not borrow + * the parent page's id for the child object. + * + * Harness (shared with `RecordDetailView.refusedLaunch-9973.test.tsx`): the + * real `RecordDetailView` renders against a stub data source; the + * `ActionProvider` it mounts is captured on the way through (and still + * rendered), and its context, toast sink and handlers are wired into a real + * `ActionRunner` exactly as `ActionProvider` wires them. The dispatches below + * are the shapes `RelatedRecordActionsBridge.runRowAction` builds. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup, within, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +const captured: any[] = []; +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: (props: any) => { + captured.push(props); + return React.createElement(actual.ActionProvider as any, props); + }, + SchemaRenderer: () => null, + }; +}); + +import { toast } from 'sonner'; +import { MetadataCtx } from '@object-ui/react'; +import { ActionRunner } from '@object-ui/core'; +import { RecordDetailView } from './RecordDetailView'; + +const PARENT = 'lead'; +const RECORD_ID = 'lead-1'; +const CHILD = 'lead_task'; +const CHILD_ROW = 'task-9'; +/** Declared on the CHILD object, as its related list on this page offers it. */ +const CHILD_ACTION = { + name: 'escalate_task', + label: 'Escalate', + type: 'flow', + target: 'escalate_task_flow', + locations: ['list_item'], + requiredPermissions: ['lead_task.escalate'], +}; +/** Declared on this page's object, offered in the header. */ +const HEADER_ACTION = { + name: 'qualify_lead', + label: 'Qualify', + type: 'flow', + target: 'qualify_lead_wizard', + locations: ['record_header'], +}; +const OBJECTS = [ + { + name: PARENT, + label: 'Lead', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + actions: [HEADER_ACTION], + }, + { + name: CHILD, + label: 'Lead Task', + fields: { id: { type: 'text', label: 'Id' }, lead: { type: 'lookup', reference: PARENT } }, + actions: [CHILD_ACTION], + }, +]; +const METADATA = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; +const GATE_MESSAGE = + "Action 'escalate_task' on 'lead_task' requires capability [lead_task.escalate] — caller is missing [lead_task.escalate]"; +const ELEVATED_MESSAGE = + 'This caller may not start this flow. A flow that runs on its own trigger starts there, ' + + "or as a sub-flow from a parent flow's `subflow` node."; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +} +function refusal(status: number, code: string, message: string) { + return { ok: false, status, json: async () => ({ success: false, error: { code, message, httpStatus: status } }) }; +} +function answered(data: Record) { + return { ok: true, status: 200, json: async () => ({ success: true, data }) }; +} + +beforeEach(() => { + cleanup(); + captured.length = 0; + authFetchSpy.mockReset(); + authFetchSpy.mockImplementation(async () => jsonResponse({ data: [] })); + (toast as any).success.mockClear(); + (toast as any).error.mockClear(); + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function recordPageProps() { + return [...captured].reverse().find((c) => c.handlers && c.context?.record?.id === RECORD_ID); +} + +/** Mount the record page and hand back a real runner wired to its runtime. */ +async function mountRunner() { + render( + + + ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'Acme Corp' })), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any} + objects={OBJECTS} + onEdit={() => {}} + objectNameOverride={PARENT} + recordIdOverride={RECORD_ID} + embedded + /> + + , + ); + await waitFor(() => expect(recordPageProps()).toBeTruthy()); + const props = recordPageProps(); + authFetchSpy.mockReset(); + const runner = new ActionRunner(props.context); + runner.setToastHandler(props.onToast); + for (const [type, handler] of Object.entries(props.handlers)) runner.registerHandler(type, handler as any); + return runner; +} + +async function run(runner: ActionRunner, action: Record) { + let result: any; + await act(async () => { + result = await runner.execute({ ...action } as any); + }); + const [url, init] = authFetchSpy.mock.calls[0]; + return { result, url: String(url), body: JSON.parse(String(init.body)) }; +} + +/** `RelatedRecordActionsBridge.runRowAction(CHILD, row, action)`'s dispatch. */ +const ROW_DISPATCH = { + ...CHILD_ACTION, + objectName: CHILD, + recordId: CHILD_ROW, + params: { _rowRecord: { id: CHILD_ROW, lead: RECORD_ID } }, +}; + +describe('objectui#12037 — record-page flow clicks on a declared action use the action door', () => { + it("a related-list row action on the child is refused by the server with the door's code, and the console shows it", async () => { + const runner = await mountRunner(); + authFetchSpy.mockResolvedValue(refusal(403, 'PERMISSION_DENIED', GATE_MESSAGE)); + + const { result, url, body } = await run(runner, ROW_DISPATCH); + + expect(url).toBe(`/api/v1/actions/${CHILD}/escalate_task`); + expect(body).toEqual({ recordId: CHILD_ROW, params: {} }); + expect(result).toEqual({ success: false, error: GATE_MESSAGE }); + expect((toast as any).error).toHaveBeenCalledWith(GATE_MESSAGE); + }); + + it("a related list's toolbar action (no row) does not borrow the parent page's id for the child", async () => { + const runner = await mountRunner(); + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + + const { url, body } = await run(runner, { ...CHILD_ACTION, locations: ['list_toolbar'], objectName: CHILD }); + + expect(url).toBe(`/api/v1/actions/${CHILD}/escalate_task`); + expect(body).toEqual({ params: {} }); + }); + + it('control: an allowed header click runs, and its paused screen resumes under the declaration target', async () => { + const runner = await mountRunner(); + authFetchSpy.mockResolvedValueOnce(answered({ + success: true, + status: 'paused', + runId: 'run-1', + flowLabel: 'Qualify', + screen: { nodeId: 'confirm', title: 'Confirm', fields: [] }, + })); + + const { result, url, body } = await run(runner, HEADER_ACTION); + + expect(url).toBe(`/api/v1/actions/${PARENT}/qualify_lead`); + expect(body).toEqual({ recordId: RECORD_ID, params: {} }); + expect(result).toEqual({ success: true, silent: true }); + const dialog = await screen.findByRole('dialog'); + authFetchSpy.mockResolvedValueOnce(answered({ success: true, status: 'completed' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(2)); + expect(String(authFetchSpy.mock.calls[1][0])).toBe('/api/v1/automation/qualify_lead_wizard/runs/run-1/resume'); + }); +}); + +describe('objectui#12037 — a record-page flow start that names no declared action stays on the trigger route', () => { + it('an undeclared header flow start, and an elevated self-triggered target is still refused there', async () => { + const runner = await mountRunner(); + authFetchSpy.mockResolvedValue(refusal(403, 'PERMISSION_DENIED', ELEVATED_MESSAGE)); + + const { result, url, body } = await run(runner, { type: 'flow', label: 'Sync', target: 'nightly_sync' }); + + expect(url).toBe('/api/v1/automation/nightly_sync/trigger'); + expect(body).toEqual({ recordId: RECORD_ID, objectName: PARENT, params: {} }); + expect(result).toEqual({ success: false, error: ELEVATED_MESSAGE }); + expect((toast as any).error).toHaveBeenCalledWith(ELEVATED_MESSAGE); + }); + + it("an undeclared child toolbar flow keeps the trigger route and sends no parent id", async () => { + const runner = await mountRunner(); + authFetchSpy.mockResolvedValue(answered({ success: true, status: 'completed' })); + + const { url, body } = await run(runner, { type: 'flow', label: 'Bulk', target: 'bulk_tasks', objectName: CHILD }); + + expect(url).toBe('/api/v1/automation/bulk_tasks/trigger'); + expect(body).toEqual({ objectName: CHILD, params: {} }); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 0d3dd8ac4a..fcfde81c42 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -38,7 +38,7 @@ import { resolveActionParams } from '../utils/resolveActionParams.js'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js'; import { actionContextOrg } from '../utils/actionContextOrg.js'; -import { interpretFlowResponse, judgeFlowLaunch } from '../utils/flowResponse.js'; +import { launchConsoleFlow } from '../utils/flowLaunch.js'; import { useRecordBreadcrumbTitle } from '../context/NavigationContext.js'; // Audit provenance renders as the one-line ; the other // framework-injected bookkeeping columns are hidden from the body outright. @@ -1115,11 +1115,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // Sheet / Drawer by `placement`) and render arbitrary SchemaNode content. const { modalHandler, modalElement, resolveModalTarget } = useActionModal(dataSource); - // Flow action handler — POST to /api/v1/automation/{name}/trigger. - // Triggered when an Action with `type: 'flow'` is invoked from a record-level - // location (record_header, record_more, …). The server-side automation - // engine resolves `{name}` against the registered flow definitions and - // returns `{success, output, durationMs}`. + /** + * Flow action handler — triggered when an Action with `type: 'flow'` is + * invoked from a record-level location (record_header, record_more, …) or + * from a related list on this page. It starts the flow through one of two + * doors (objectui#12037, triage ruling B; the rule and its reasons live in + * `utils/flowLaunch`): + * + * - a click whose object and `name` resolve to a DECLARED `type: 'flow'` + * action with the same target, in this page's `objects`, goes through the + * action door, `POST /api/v1/actions/:object/:action`, so every + * server-side gate that action declares applies to the click (ADR-0066 + * D4). A related-list row action addresses the CHILD object and the + * child action's declared name, with the child row as `recordId`; + * - every other flow click stays on `POST /api/v1/automation/:flow/trigger`. + * An undeclared flow start has no action gate to apply, and ruling A's + * elevation refusal already holds on the trigger route. An object-less + * declared action is absent from `objects`, so it keeps that route too, + * as every flow click did before. + */ const flowHandler = useCallback(async (action: ActionDef) => { const flowName = action.target || action.name; if (!flowName) { @@ -1136,40 +1150,40 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri : {}), }; delete flowParams._rowRecord; - const res = await authFetch( - `${baseUrl}/api/v1/automation/${encodeURIComponent(flowName)}/trigger`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - // Related-list row actions retarget the flow at a CHILD record via - // an explicit `recordId` / `objectName`; fall back to this page's - // record when the action carries none (header/more actions). - recordId: (action as any).recordId ?? pureRecordId, - objectName: action.objectName ?? objectName, - params: flowParams, - }), - }, - ); - const json = await res.json().catch(() => null); - // Single source for the flow-response rule AND for what a launch does - // with it — shared with useConsoleActionRuntime's copy of this handler - // (and the interpretation with FlowRunner's resume). This copy once - // checked only the transport envelope and treated everything else as - // terminal success, so a run that failed on its first node fired a green - // toast (#2958) and passed the nested `{code, message}` error through raw - // (React #31); later, a run that ended `refused` without pausing toasted - // the action's `successMessage` and refreshed while the refusal was never - // shown (objectui#9973). See utils/flowResponse. - const judged = judgeFlowLaunch( - interpretFlowResponse(res, json, `Flow "${flowName}"`), - action.refreshAfter, - ); + // Related-list row actions retarget the flow at a CHILD record via an + // explicit `recordId` / `objectName`. This page's record stands in only + // for an action on THIS page's object (header/more actions): a child + // action with no row — a related list's toolbar — is object-level, and + // handing it the PARENT's id addressed a child record that does not + // exist (the action door refuses that subject load with a 404). + const targetObject = action.objectName ?? objectName; + const recordId = (action as any).recordId + ?? (targetObject === objectName ? pureRecordId : undefined); + // Single source for the door choice, the request, the flow-response + // rule AND what a launch does with it — shared with + // useConsoleActionRuntime's copy of this handler (and the interpretation + // with FlowRunner's resume). This copy once checked only the transport + // envelope and treated everything else as terminal success, so a run + // that failed on its first node fired a green toast (#2958) and passed + // the nested `{code, message}` error through raw (React #31); later, a + // run that ended `refused` without pausing toasted the action's + // `successMessage` and refreshed while the refusal was never shown + // (objectui#9973). See utils/flowLaunch. + const launch = await launchConsoleFlow({ + fetch: authFetch, + baseUrl, + action, + objectName: targetObject, + recordId, + triggerParams: flowParams, + objects, + }); + const judged = launch.judged; // Paused at a `screen` node: FlowRunner renders the form + resumes, and // refreshes on completion. if (judged.followUp?.kind === 'screen') { setScreenFlow({ - flowName, + flowName: launch.flowName, flowLabel: judged.followUp.flowLabel, runId: judged.followUp.runId, screen: judged.followUp.screen, @@ -1185,7 +1199,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } catch (error) { return { success: false, error: (error as Error).message }; } - }, [authFetch, pureRecordId, objectName]); + }, [authFetch, pureRecordId, objectName, objects]); // Server-side action handler — POST /api/v1/actions/{object}/{action}, // built from @object-ui/core's `createServerActionHandler` via the shared From 418a24870bef47d3c282fd0440d7a3f33d2299cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:16:48 +0000 Subject: [PATCH 2/2] test(e2e): the live screen-flow spec asserts the declared flow action starts through the action door (objectui#12037) The live lane's `screen-flow.spec.ts` asserted a POST to /api/v1/automation/:flow/trigger. Under triage ruling B the declared `showcase_bulk_reassign` row action now starts its run through POST /api/v1/actions/showcase_task/showcase_bulk_reassign, so the run paused, rendered its screen, resumed and wrote the row, and only that request-shape assertion failed. The spec now asserts the action-door launch and that no trigger-route start happened; the resume and persisted-data assertions are unchanged. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- e2e/live/screen-flow.spec.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/e2e/live/screen-flow.spec.ts b/e2e/live/screen-flow.spec.ts index b0a9fd69f5..6713a1875a 100644 --- a/e2e/live/screen-flow.spec.ts +++ b/e2e/live/screen-flow.spec.ts @@ -4,7 +4,7 @@ import { expectToast } from './helpers'; /** * Live e2e for the screen-flow round trip (framework ADR-0019). * - * A `type: 'flow'` action triggers the run; when it pauses at a `screen` node + * A `type: 'flow'` action starts the run; when it pauses at a `screen` node * the console must render that screen and POST the collected values back to * `/automation/{flow}/runs/{runId}/resume`. Nothing covered this seam before: * the unit tests stub the runner out of the action runtime, and the runner's @@ -16,15 +16,22 @@ import { expectToast } from './helpers'; * Drives `showcase_bulk_reassign` (list row action) → `showcase_reassign_wizard`, * whose `screen` node collects `new_assignee` and writes it back with * `update_record`, so the assertion runs all the way to persisted data. + * + * The run starts through the ACTION door, `POST /actions/showcase_task/ + * showcase_bulk_reassign`, not the trigger route (objectui#12037): the click + * names an action the object declares with `type: 'flow'` and this target, so + * the server applies that action's own gates before the flow starts. A flow + * start that names no declared action keeps `/automation/{flow}/trigger`. */ test('a row flow action renders its screen and resumes the run', async ({ page }) => { const assignee = `e2e-${Date.now()}@example.com`; - // Capture the automation traffic: the bug class here is a MISSING request, - // so the resume POST is asserted directly rather than inferred from the UI. + // Capture the flow traffic on both doors: the bug class here is a MISSING + // request, so the launch and resume POSTs are asserted directly rather than + // inferred from the UI. const automation: Array<{ url: string; body: unknown }> = []; page.on('request', (r) => { - if (r.method() === 'POST' && r.url().includes('/api/v1/automation/')) { + if (r.method() === 'POST' && /\/api\/v1\/(automation|actions)\//.test(r.url())) { let body: unknown = null; try { body = r.postDataJSON(); } catch { /* non-JSON body — keep null */ } automation.push({ url: r.url(), body }); @@ -49,8 +56,12 @@ test('a row flow action renders its screen and resumes the run', async ({ page } await expectToast(page, /Done|reassign/i); await expect(page.getByRole('dialog')).toHaveCount(0); - const trigger = automation.find((r) => /\/automation\/[^/]+\/trigger$/.test(r.url)); - expect(trigger, 'the flow action never triggered a run').toBeTruthy(); + const launch = automation.find((r) => /\/actions\/showcase_task\/showcase_bulk_reassign$/.test(r.url)); + expect(launch, 'the declared flow action never started its run through the action door').toBeTruthy(); + expect( + automation.filter((r) => /\/automation\/[^/]+\/trigger$/.test(r.url)), + 'a declared flow action must not start its flow by name on the trigger route', + ).toHaveLength(0); const resume = automation.find((r) => /\/runs\/[^/]+\/resume$/.test(r.url)); expect(resume, 'Submit did not call the resume endpoint — the run is stranded').toBeTruthy();