Skip to content

Commit 52ec79d

Browse files
authored
fix(actions): one source for the /actions envelope rule, and redirectUrl finally works (#2967)
objectstack#3913 follow-up. The /actions response wraps TWICE — the route's own {success, data} inside the dispatcher's — and a failure has three shapes, only one of which res.ok catches. That rule was hand-rolled in two places (useConsoleActionRuntime.serverActionHandler and RecordDetailView's copy of the same handler) and the two drifted. Four hand-rolled copies produced three distinct bugs: a failed action reported as success (the green-toast symptom, on the console's MAIN action path), a React #31 crash from the nested {message} object reaching toast.error() as a child, and redirectUrl never firing. Both handlers now call interpretActionResponse from utils/actionResponse, and a ratchet test fails if a third hand-rolled copy appears. The ratchet also asserts it is still scanning the two known callers, so it cannot start passing vacuously if the files move. redirectUrl was unreachable, not merely wrong. A script action can return { redirectUrl } to ask the console to open a URL; both handlers read it off body.data — the ACTION envelope — but that level is constructed by the server and only ever holds success/data, so body.data.redirectUrl was always undefined and no handler could work around it. An opensInNewTab action was worse than a no-op: it pre-opens a tab on a spinner page for popup-blocker safety, and with no redirect to drive it to, that tab sat on the spinner forever. ActionResult.data still carries the action envelope, unchanged — resultDialog field paths in the wild may have adapted to that depth, so it is not silently re-pointed here.
1 parent fc0272a commit 52ec79d

7 files changed

Lines changed: 388 additions & 41 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(actions): one source for the `/actions` envelope rule, and `redirectUrl` finally works (objectstack#3913 follow-up)
6+
7+
The `/actions` response wraps **twice** — the route's own `{success, data}`
8+
inside the dispatcher's — and a failure has three shapes, only one of which
9+
`res.ok` catches. That rule was hand-rolled in two places
10+
(`useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s copy of
11+
the same handler), and the two drifted. Four hand-rolled copies produced three
12+
distinct bugs:
13+
14+
1. **A failed action reported as success** — the copy that didn't inspect the
15+
inner envelope was the console's *main* action path, so a failure fired the
16+
green "completed" toast on every list and page surface (fixed in #2963).
17+
2. **React #31 crash** — the nested `{message, code}` object handed to
18+
`toast.error()` as a React child (fixed in #2963).
19+
3. **`redirectUrl` never fired***fixed here.*
20+
21+
Both handlers now call `interpretActionResponse` from `utils/actionResponse`,
22+
and a ratchet test (`actions-envelope.ratchet.test.ts`) fails if a third
23+
hand-rolled copy appears.
24+
25+
## `redirectUrl` was unreachable
26+
27+
A script action can return `{ redirectUrl: 'https://…' }` to ask the console to
28+
open a URL. Both handlers read it off `body.data` — the **action** envelope,
29+
one level too shallow:
30+
31+
```
32+
{ success: true, data: { success: true, data: { redirectUrl: '…' } } }
33+
^^^^ read here ^^^^ actually lives here
34+
```
35+
36+
`body.data` is constructed by the server and only ever holds `success` / `data`,
37+
so `body.data.redirectUrl` was **always** undefined — the convention could never
38+
fire, and no handler could work around it. An `opensInNewTab` action was worse
39+
than a no-op: it pre-opens a tab on a spinner page for popup-blocker safety, and
40+
with no redirect to drive it to, that tab sat on the spinner forever.
41+
42+
`ActionResult.data` still carries the **action envelope**, unchanged — some
43+
`resultDialog` field paths in the wild may have adapted to that depth, so it is
44+
not silently re-pointed here.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#3913 ratchet — every `/api/v1/actions` caller goes through
6+
* `interpretActionResponse`.
7+
*
8+
* The bug this guards: the `/actions` response wraps TWICE (the route's own
9+
* `{success, data}` inside the dispatcher's), and a failure has three shapes
10+
* only one of which `res.ok` catches. Two copies of that rule existed — the
11+
* shared console runtime and RecordDetailView's — and they drifted: one learned
12+
* to inspect the inner envelope, the other did not. The one that did not was
13+
* the console's MAIN action path, so a failed action fired a green "completed"
14+
* toast on every list and page surface while the real error was swallowed.
15+
* `marketplaceApi.installPackage` had the same hole and could report a package
16+
* as installed when it was not.
17+
*
18+
* Reading the envelope by hand is the anti-pattern, not any particular way of
19+
* reading it wrong: four hand-rolled copies produced three different bugs
20+
* (missed inner failure, a `{message}` object handed to `toast.error()` as a
21+
* React child → React #31, and `redirectUrl` read one level too shallow so it
22+
* never fired). One helper, one rule.
23+
*
24+
* If this fails: don't hand-roll the check. Import `interpretActionResponse`
25+
* from `utils/actionResponse` — or, better, call the action through
26+
* `@objectstack/client`, which folds every shape into `{ success, data?, error? }`.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { readdirSync, readFileSync, statSync } from 'node:fs';
31+
import path from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
34+
const here = path.dirname(fileURLToPath(import.meta.url));
35+
const appShellSrc = here;
36+
37+
/** The helper that owns the rule — exempt from its own guard. */
38+
const OWNER = path.join(appShellSrc, 'utils', 'actionResponse.ts');
39+
40+
/** Files allowed to name the route without going through the helper. */
41+
const EXEMPT = new Set<string>([
42+
OWNER,
43+
// The cloud marketplace install posts to a *cloud* origin's action route
44+
// and consumes `InstallResponse`, not `ActionResult`. It still has to
45+
// honour the envelope, and does — covered by its own tests — but it is not
46+
// an ActionRunner handler, so it does not use this helper.
47+
path.join(appShellSrc, 'console', 'marketplace', 'marketplaceApi.ts'),
48+
]);
49+
50+
function walk(dir: string, out: string[] = []): string[] {
51+
for (const entry of readdirSync(dir)) {
52+
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') continue;
53+
const full = path.join(dir, entry);
54+
if (statSync(full).isDirectory()) walk(full, out);
55+
else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
56+
}
57+
return out;
58+
}
59+
60+
/** A source file that POSTs to the action route. */
61+
const ROUTE = /\/api\/v1\/actions\//;
62+
63+
/**
64+
* Comments name the route freely while explaining the routing rules (e.g.
65+
* ConsoleShell's note on why `modal` stays client-side). Only CODE counts.
66+
*/
67+
function stripComments(src: string): string {
68+
return src
69+
.replace(/\/\*[\s\S]*?\*\//g, '')
70+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
71+
}
72+
73+
describe('objectstack#3913 ratchet — /actions callers use interpretActionResponse', () => {
74+
const callers = walk(appShellSrc)
75+
.filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8'))));
76+
77+
const offenders = callers
78+
.filter((f) => !readFileSync(f, 'utf8').includes('interpretActionResponse'))
79+
.map((f) => path.relative(appShellSrc, f));
80+
81+
it('no app-shell file interprets an /actions response by hand', () => {
82+
expect(offenders).toEqual([]);
83+
});
84+
85+
it('still guards something — the known callers are present', () => {
86+
// A ratchet that matches nothing passes vacuously forever. Pin that the
87+
// two handlers it exists for are actually being scanned.
88+
expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual(
89+
expect.arrayContaining([
90+
path.join('hooks', 'useConsoleActionRuntime.tsx'),
91+
path.join('views', 'RecordDetailView.tsx'),
92+
]),
93+
);
94+
});
95+
});

packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,32 @@ describe('useConsoleActionRuntime — authenticated handlers', () => {
358358
expect(res.error).toBe("Action 'log_call' on object 'global' not found");
359359
});
360360

361+
it('serverActionHandler opens a handler-returned redirectUrl (read through BOTH envelopes)', async () => {
362+
// The action route wraps twice: `{success, data:{success, data: <handler>}}`.
363+
// This used to read `redirectUrl` off the ACTION envelope — a level where
364+
// only `success`/`data` ever live — so the convention never fired and an
365+
// `opensInNewTab` action left its pre-opened tab on the spinner forever.
366+
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as any);
367+
authFetchSpy.mockResolvedValue({
368+
ok: true,
369+
status: 200,
370+
json: async () => ({
371+
success: true,
372+
data: { success: true, data: { redirectUrl: 'https://example.test/sso' } },
373+
}),
374+
});
375+
const { result } = renderHook(() =>
376+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
377+
);
378+
379+
await act(async () => {
380+
await result.current.serverActionHandler({ type: 'script', name: 'open_env' } as any);
381+
});
382+
383+
expect(openSpy).toHaveBeenCalledWith('https://example.test/sso', '_blank');
384+
openSpy.mockRestore();
385+
});
386+
361387
it('serverActionHandler still reports success when the inner envelope says so', async () => {
362388
// The success wire is unchanged by objectstack#3913 — guard against the
363389
// inner-envelope check turning a good action into a failure.

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../en
4949
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements';
5050
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
5151
import { actionErrorDetail } from '../utils/actionErrorDetail';
52+
import { interpretActionResponse } from '../utils/actionResponse';
5253

5354
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
5455

@@ -587,32 +588,28 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
587588
},
588589
);
589590
const json = await res.json().catch(() => null);
590-
// A server older than objectstack#3913 reports a script action that THREW
591-
// as HTTP 200 `{success: true, data: {success: false, error}}` — transport
592-
// success wrapping a business failure. Reading only `res.ok` and the OUTER
593-
// `success` mistook that for a completed action and fired the green
594-
// "completed" toast while swallowing the real error. Inspect the INNER
595-
// envelope too (RecordDetailView's copy of this handler already did).
596-
// Current servers answer with a real status, so `!res.ok` catches it
597-
// first; this branch is what keeps the console honest against a server
598-
// that has not been upgraded yet.
599-
const inner = json?.data;
600-
const innerFailed = !!inner && typeof inner === 'object' && (inner as any).success === false;
601-
if (!res.ok || (json && json.success === false) || innerFailed) {
602-
const errMsg = innerFailed
603-
? errorDetail(inner, `Action "${targetName}" failed`)
604-
: errorDetail(json, `Action "${targetName}" failed (HTTP ${res.status})`);
591+
// Single source for the `/actions` envelope rule — shared with
592+
// RecordDetailView, whose copy of this handler drifted from it and caused
593+
// objectstack#3913's console symptom. See utils/actionResponse.
594+
const outcome = interpretActionResponse(res, json, `Action "${targetName}"`);
595+
if (!outcome.ok) {
605596
if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } }
606597
// Don't toast here — the ActionRunner's post-execution hook surfaces
607598
// `error` as a toast (see apiHandler/flowHandler, which likewise only
608599
// return). Toasting here too double-fires the error (two identical toasts).
609-
return { success: false, error: errMsg };
600+
return { success: false, error: outcome.error };
610601
}
611602
const shouldRefresh = action.refreshAfter !== false;
612603
if (shouldRefresh) refresh();
613-
const data = json?.data;
614-
const redirectUrl = (data && typeof data === 'object' && typeof (data as any).redirectUrl === 'string')
615-
? (data as any).redirectUrl as string
604+
const data = outcome.envelope;
605+
// Read `redirectUrl` off the HANDLER's return value, not the action
606+
// envelope wrapping it. This used to read one level too shallow, where
607+
// only `success`/`data` ever live — so an action returning
608+
// `{ redirectUrl }` was silently ignored and an `opensInNewTab` action
609+
// left its pre-opened tab parked on the spinner page forever.
610+
const payload = outcome.payload;
611+
const redirectUrl = (payload && typeof payload === 'object' && typeof (payload as any).redirectUrl === 'string')
612+
? (payload as any).redirectUrl as string
616613
: null;
617614
if (redirectUrl) {
618615
if (preOpenedTab) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* The `/actions` envelope rule, once (objectstack#3913). Four hand-rolled
6+
* copies of this produced three distinct bugs; these cases pin all three.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { interpretActionResponse, readActionPayload } from '../actionResponse';
11+
12+
const ok = { ok: true, status: 200 };
13+
14+
describe('interpretActionResponse — failure shapes', () => {
15+
it('catches a business rejection hiding under HTTP 200 (the reported bug)', () => {
16+
// `res.ok` is TRUE and the OUTER `success` is TRUE. Only the inner
17+
// envelope reports the failure — miss it and the ActionRunner fires a
18+
// green "completed" toast over a failed action.
19+
const out = interpretActionResponse(ok, {
20+
success: true,
21+
data: { success: false, error: "Action 'log_call' on object '*' not found" },
22+
}, 'Action "log_call"');
23+
24+
expect(out.ok).toBe(false);
25+
expect(out.error).toBe("Action 'log_call' on object '*' not found");
26+
});
27+
28+
it('catches a dispatch failure and resolves the nested {message} to a STRING', () => {
29+
// Handing the `{message, code}` OBJECT to toast.error() as a React
30+
// child crashes the page (React #31).
31+
const out = interpretActionResponse({ ok: false, status: 404 }, {
32+
success: false,
33+
error: { message: "Action 'log_call' on object 'global' not found", code: 404 },
34+
}, 'Action "log_call"');
35+
36+
expect(out.ok).toBe(false);
37+
expect(typeof out.error).toBe('string');
38+
expect(out.error).toBe("Action 'log_call' on object 'global' not found");
39+
});
40+
41+
it('catches a transport-level success:false', () => {
42+
const out = interpretActionResponse(ok, { success: false, error: 'nope' }, 'Action "x"');
43+
expect(out).toMatchObject({ ok: false, error: 'nope' });
44+
});
45+
46+
it('falls back to a labelled message when the body carries none', () => {
47+
const out = interpretActionResponse({ ok: false, status: 500 }, null, 'Action "x"');
48+
expect(out.ok).toBe(false);
49+
expect(out.error).toBe('Action "x" failed (HTTP 500)');
50+
});
51+
});
52+
53+
describe('interpretActionResponse — success', () => {
54+
it('reports ok and exposes both envelope levels', () => {
55+
const out = interpretActionResponse(ok, {
56+
success: true,
57+
data: { success: true, data: { id: 'call_1' } },
58+
}, 'Action "log_call"');
59+
60+
expect(out.ok).toBe(true);
61+
// `envelope` is the ACTION envelope — the level ActionResult.data has
62+
// always carried.
63+
expect(out.envelope).toEqual({ success: true, data: { id: 'call_1' } });
64+
// `payload` is what the handler actually returned.
65+
expect(out.payload).toEqual({ id: 'call_1' });
66+
});
67+
});
68+
69+
describe('readActionPayload — the double-wrap trap', () => {
70+
it('reaches the handler value through BOTH envelopes', () => {
71+
// The bug: reading one level lands on `{success, data}`, where only
72+
// `success` and `data` ever live — so an action returning
73+
// `{ redirectUrl }` was silently ignored, because
74+
// `body.data.redirectUrl` is never set by anything.
75+
const envelope = { success: true, data: { redirectUrl: 'https://example.test/sso' } };
76+
77+
expect((envelope as any).redirectUrl).toBeUndefined(); // the old read
78+
expect(readActionPayload(envelope)).toEqual({ redirectUrl: 'https://example.test/sso' });
79+
});
80+
81+
it('passes a non-enveloped body through rather than inventing undefined', () => {
82+
expect(readActionPayload({ id: 'x' })).toEqual({ id: 'x' });
83+
expect(readActionPayload(null)).toBeNull();
84+
});
85+
86+
it('does not mistake an array for an envelope', () => {
87+
expect(readActionPayload([1, 2])).toEqual([1, 2]);
88+
});
89+
});

0 commit comments

Comments
 (0)