Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/12037-flow-action-door.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/guide/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
23 changes: 17 additions & 6 deletions e2e/live/screen-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
Expand All @@ -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();
Expand Down
29 changes: 24 additions & 5 deletions packages/app-shell/src/flow-envelope.ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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*\(/);
}
});
});
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown>) {
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<string, unknown>,
opts: { objects?: any[]; objectName?: string; store?: any } = {},
) {
let result: any;
function Probe() {
const { execute } = useAction();
return (
<button onClick={() => { void execute({ ...action } as any).then((r) => { result = r; }); }}>
run-flow
</button>
);
}
const tree = (
<ConsoleActionRuntimeProvider dataSource={{}} objects={opts.objects ?? OBJECTS} objectName={opts.objectName ?? OBJECT}>
<Probe />
</ConsoleActionRuntimeProvider>
);
render(opts.store ? <MetadataCtx.Provider value={opts.store}>{tree}</MetadataCtx.Provider> : 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);
});
});
Loading
Loading