Skip to content

Commit c2bbd97

Browse files
authored
fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913) (#3930)
Object-less actions register under the literal 'global' (AppPlugin's `action.object || 'global'`, ObjectQLPlugin.actionObjectKey) but the REST fallback probed '*' — and engine.executeAction is an exact-string Map lookup with no wildcard semantics, so that probe could only miss. /actions/global/:action worked by accident (the path segment happened to spell the registration key); /actions//:action never worked, and neither did falling back from an object-scoped route to a global handler. 'global' is now canonical (GLOBAL_ACTION_OBJECT_KEY). One probe order — [routed object, 'global', '*'] via actionHandlerObjectKeys — serves both the REST route and the MCP run_action bridge, with the legacy '*' kept last so a handler registered directly against it still resolves. A single-segment path routes at 'global' instead of 400-ing. The not-found exit also called deps.success(), so "no such action" went out as HTTP 200 {success:true, data:{success:false, error}} and read as a success to any caller that skipped the inner envelope. Nothing DISPATCHED there, so it is a 404 now, joining the pre-dispatch answers this route already gives a status (403 denied, 400 wrong action type, 503 unavailable), and it names the routed object rather than whichever probe ran last. A handler that RAN and rejected is unchanged: HTTP 200 with {success:false, error, code?, fields?} — a business outcome, not a transport error, per #3937. The line is "did a handler run". client.actions.invoke/invokeGlobal still do not throw; the added catch is load-bearing since client.fetch throws on every non-2xx.
1 parent 789ad63 commit c2bbd97

10 files changed

Lines changed: 590 additions & 65 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/client": minor
4+
---
5+
6+
fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913)
7+
8+
**1 — the registration key and the lookup key disagreed.** Both writers
9+
register an objectName-less action under the literal `'global'`: `AppPlugin`
10+
(`action.object || 'global'`) and `ObjectQLPlugin.actionObjectKey`. The REST
11+
route's fallback probed `'*'`, and `engine.executeAction` is an exact-string
12+
`Map` lookup with no wildcard semantics — so the probe could only ever miss:
13+
14+
```
15+
Action 'log_call' on object '*' not found
16+
```
17+
18+
`POST /api/v1/actions/global/log_call` worked by **accident** (the path segment
19+
happened to spell the registration key); `POST /api/v1/actions//log_call` never
20+
worked at all, and neither did falling back from an object-scoped route to a
21+
global handler. `'global'` is now the canonical key
22+
(`GLOBAL_ACTION_OBJECT_KEY`), the probe order is
23+
`[<routed object>, 'global', '*']` for both the REST route and the MCP
24+
`run_action` bridge (`actionHandlerObjectKeys` — one list, two surfaces), and a
25+
single-segment path (`/actions//:action`) routes at `'global'` instead of
26+
400-ing. A handler registered directly under `'*'` still resolves; the doc
27+
comments that called `'global'` a "wildcard" are corrected at every site.
28+
29+
**2 — "no such action" was reported as a success.** The not-found exit called
30+
`deps.success(...)`, which always emits `{status: 200, body: {success: true,
31+
data}}`, so a request naming an action that does not exist came back as:
32+
33+
```json
34+
{"success":true,"data":{"success":false,"error":"Action 'log_call' on object '*' not found"}}
35+
```
36+
37+
Every caller that did not hand-unwrap the INNER envelope read the outer
38+
`success: true` and reported a success that never happened — including the
39+
shipped console, which showed a green toast (fixed on that side in
40+
objectui#2963). Nothing **dispatched** there, so it is a **404** now, joining
41+
the answers this route already gives a status: 403 denied, 400 wrong action
42+
type, 503 unavailable. The miss also names the **routed** object rather than
43+
whichever probe ran last (the old fallback said `on object '*'`, an object the
44+
caller never asked for).
45+
46+
A handler that **ran and rejected** is unchanged: HTTP 200 with
47+
`data: {success: false, error, code?, fields?}`. That is a business outcome,
48+
not a transport error, and #3937 pins it. The line is "did a handler run" —
49+
below it the payload, above it the status.
50+
51+
`client.actions.invoke` / `invokeGlobal` still do **not** throw. `client.fetch`
52+
throws on every non-2xx, so `invoke` now catches and folds a dispatch failure
53+
into the same `{ success, data?, error? }` result with `error` as a plain
54+
string — otherwise the routes that just gained a status would have started
55+
propagating exceptions into callers that only ever checked `result.success`.

content/docs/api/client-sdk.mdx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -391,16 +391,18 @@ if (run.status === 'paused') {
391391
await client.automation.getScreen('convert_lead', runId);
392392

393393
// Actions — Invoke server-registered action handlers
394-
// (engine.registerAction). The result envelope is the HANDLER's own
395-
// `{ success, data | error }` — a business failure comes back as
396-
// `success: false` + `error` instead of a thrown exception, so it can be
397-
// surfaced as a toast.
394+
// (engine.registerAction). The result is `{ success, data | error }` — a
395+
// failure comes back as `success: false` + `error` instead of a thrown
396+
// exception, so it can be surfaced as a toast. This surface is the one place
397+
// a non-2xx does NOT throw: a handler that rejects answers 200 with the inner
398+
// envelope, a request that never dispatched answers 404 / 403 / 400 / 503,
399+
// and the SDK folds both into the same result.
398400
const res = await client.actions.invoke('crm_lead', 'convert', {
399401
recordId,
400402
params: { create_opportunity: true },
401403
});
402404
if (!res.success) console.warn(res.error);
403-
// Global (object-less) actions dispatch to the wildcard handler:
405+
// Global (object-less) actions dispatch to the server's 'global' handler key:
404406
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
405407

406408
// API Keys — the raw secret is returned exactly ONCE; store it immediately.

content/docs/ui/actions.mdx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,21 @@ curl -b cookies.txt -X POST \
229229
https://your-app.example.com/api/v1/actions/todo_task/complete_task \
230230
-H "Content-Type: application/json" \
231231
-d '{ "recordId": "rec_123", "params": {} }'
232-
# → { "success": true, "data": ... } (errors: { "success": false, "error": ... })
232+
#200 { "success": true, "data": { "success": true, "data": ... } }
233233
```
234234

235-
Global (object-less) actions post to `/api/v1/actions/global/:action`. For
236-
credentials, see [API Authentication](/docs/api#authentication).
235+
Failures split by **whether a handler ran**:
236+
237+
- **It ran and rejected** — HTTP **200**, `data: { "success": false, "error",
238+
"code"?, "fields"? }`. An action that fails is a normal business outcome, not
239+
a transport error, so it rides the payload. Always branch on `data.success`.
240+
- **It never dispatched** — a real status: **404** no such action, **403**
241+
denied, **400** wrong action type or a param-contract violation, **503**
242+
unavailable — with `{ "success": false, "error": { "message", "code" } }`.
243+
244+
Global (object-less) actions post to `/api/v1/actions/global/:action`, or to
245+
`/api/v1/actions//:action` with the object segment left empty. For credentials,
246+
see [API Authentication](/docs/api#authentication).
237247

238248
The endpoint dispatches on the **declared `type`**, exactly like the MCP
239249
`run_action` toolso the same URL invokes a script handler or a flow:

packages/client/src/actions-surface.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('client.actions', () => {
6262
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: {} });
6363
});
6464

65-
it('invokeGlobal targets the wildcard shape /actions/global/:action', async () => {
65+
it('invokeGlobal targets the object-less shape /actions/global/:action', async () => {
6666
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
6767
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
6868
expect(String(fetchMock.mock.calls[0][0])).toBe(
@@ -72,9 +72,10 @@ describe('client.actions', () => {
7272
});
7373

7474
it('surfaces the handler business failure without throwing (inner success:false)', async () => {
75-
// The dispatcher reports handler throws as HTTP 200 with an inner
76-
// `{ success:false, error }` — a toastable business failure, not a
77-
// transport error (http-dispatcher.handleActions catch branch).
75+
// A handler that RAN and rejected is a business outcome, reported in
76+
// the payload at HTTP 200 — unchanged by #3913, which only moved the
77+
// DISPATCH failures to a status. The inner envelope is authoritative
78+
// on a 2xx.
7879
const { client } = createMockClient({
7980
success: true,
8081
data: { success: false, error: 'Lead is already converted' },
@@ -83,4 +84,32 @@ describe('client.actions', () => {
8384
expect(result.success).toBe(false);
8485
expect(result.error).toBe('Lead is already converted');
8586
});
87+
88+
it('reports a dispatch failure (404) as the same non-throwing envelope (#3913)', async () => {
89+
// An unregistered action never dispatched, so the server answers a real
90+
// status with `{success:false, error:{message,code}}`. `client.fetch`
91+
// throws on any non-2xx, but this surface's contract is to REPORT, not
92+
// throw — callers toast `result.error` rather than wrapping every call
93+
// in a try/catch, and that must not change just because these routes
94+
// gained a status.
95+
const { client } = createMockClient(
96+
{ success: false, error: { message: "Action 'log_call' on object 'global' not found", code: 404 } },
97+
404,
98+
);
99+
const result = await client.actions.invokeGlobal('log_call');
100+
expect(result.success).toBe(false);
101+
// The message, not `[object Object]` — the error body's `error` is an
102+
// object on this shape.
103+
expect(result.error).toBe("Action 'log_call' on object 'global' not found");
104+
});
105+
106+
it('reports a 403 denial the same way', async () => {
107+
const { client } = createMockClient(
108+
{ success: false, error: { message: 'Missing capability can_convert', code: 403 } },
109+
403,
110+
);
111+
const result = await client.actions.invoke('crm_lead', 'convert');
112+
expect(result.success).toBe(false);
113+
expect(result.error).toBe('Missing capability can_convert');
114+
});
86115
});

packages/client/src/index.ts

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,38 @@ async function* parseEventStream(res: Response): AsyncIterable<AiStreamChunk> {
312312
yield* emit(decoder.decode());
313313
}
314314

315+
/** Best human-readable text for an action failure, whatever shape carried it. */
316+
function actionErrorMessage(e: any): string {
317+
if (typeof e === 'string' && e) return e;
318+
if (typeof e?.message === 'string' && e.message) return e.message;
319+
return 'Action failed';
320+
}
321+
322+
/**
323+
* Fold a 2xx `POST /api/v1/actions/...` body into the `{ success, data?,
324+
* error? }` shape `client.actions.invoke` has always returned.
325+
*
326+
* `payload` is what `unwrapResponse` produced — the INNER envelope. Two shapes
327+
* arrive on a 2xx and a caller must not have to tell them apart:
328+
*
329+
* - `{ success: true, data }` — the action ran and returned.
330+
* - `{ success: false, error, code?, fields? }` — the handler RAN and
331+
* rejected. That is a business outcome, so it rides the payload at HTTP
332+
* 200 (#3937) and the inner envelope is authoritative.
333+
*
334+
* The non-2xx case never reaches here: those are DISPATCH failures (404 / 403
335+
* / 400 / 503), `fetch` throws on them, and `invoke` catches — this surface
336+
* does not throw either way.
337+
*/
338+
function normalizeActionResult<T>(payload: any): { success: boolean; data?: T; error?: string } {
339+
if (payload && typeof payload === 'object' && 'success' in payload) {
340+
return payload.success === false
341+
? { success: false, error: actionErrorMessage(payload.error) }
342+
: { success: true, data: payload.data as T };
343+
}
344+
return { success: true, data: payload as T };
345+
}
346+
315347
export class ObjectStackClient {
316348
private baseUrl: string;
317349
private token?: string;
@@ -2879,37 +2911,58 @@ export class ObjectStackClient {
28792911
*
28802912
* The dispatcher accepts the record id either in the URL or in the body;
28812913
* this client always sends it in the body (`{ recordId, params }`), which
2882-
* both server shapes honor. The HTTP envelope is
2883-
* `{ success, data: { success, data | error } }` — the OUTER envelope is
2884-
* transport success; the INNER `success:false` + `error` is the action
2885-
* handler's own (business) failure, deliberately not thrown so callers can
2886-
* surface it as a toast rather than a crash.
2914+
* both server shapes honor.
2915+
*
2916+
* Result shape is `{ success, data | error }` and this surface does NOT
2917+
* throw — a failed business action is a toast, not a crash.
2918+
*
2919+
* Two server answers fold into that one result:
2920+
* - a handler that RAN and rejected → HTTP 200 with the inner
2921+
* `{success: false, error}` (unchanged — a business outcome is reported
2922+
* in the payload, not as a transport error);
2923+
* - a request that never DISPATCHED → a real status (404 unregistered, 403
2924+
* denied, 400 wrong action type, 503 unavailable) with
2925+
* `{success: false, error: {message, code}}`. `fetch` throws on those, so
2926+
* `invoke` catches and reports instead of propagating — #3913, where an
2927+
* unregistered action came back as a 200 and read as a success.
28872928
*/
28882929
actions = {
28892930
/**
28902931
* Invoke a server-registered action on an object.
2891-
* Falls back to the server's wildcard ('*') handler when no
2932+
* Falls back to the server's object-less ('global') handler when no
28922933
* object-specific handler is registered.
28932934
*/
28942935
invoke: async <T = any>(
28952936
objectName: string,
28962937
actionName: string,
28972938
opts?: { recordId?: string; params?: Record<string, unknown> },
28982939
): Promise<{ success: boolean; data?: T; error?: string }> => {
2899-
const res = await this.fetch(
2900-
`${this.baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`,
2901-
{
2902-
method: 'POST',
2903-
body: JSON.stringify({ recordId: opts?.recordId, params: opts?.params ?? {} }),
2904-
},
2905-
);
2906-
return this.unwrapResponse(res) as Promise<{ success: boolean; data?: T; error?: string }>;
2940+
try {
2941+
const res = await this.fetch(
2942+
`${this.baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`,
2943+
{
2944+
method: 'POST',
2945+
body: JSON.stringify({ recordId: opts?.recordId, params: opts?.params ?? {} }),
2946+
},
2947+
);
2948+
return normalizeActionResult<T>(await this.unwrapResponse<any>(res));
2949+
} catch (err: any) {
2950+
// [#3913] `fetch` throws on every non-2xx, and a DISPATCH
2951+
// failure is a non-2xx now (404 unregistered / 403 denied / 400
2952+
// wrong type / 503 unavailable) — but this surface's contract is
2953+
// to report, not throw, so it must not start propagating on the
2954+
// routes that just gained a status. `fetch` has already lifted
2955+
// the server's human-readable message onto `err.message` (with
2956+
// `err.code` / `err.httpStatus` kept for programmatic use), so
2957+
// the toast text is unchanged.
2958+
return { success: false, error: actionErrorMessage(err) };
2959+
}
29072960
},
29082961

29092962
/**
29102963
* Invoke a global (object-less) action — the server's
2911-
* `POST /actions/global/:action` shape, dispatched to the wildcard
2912-
* handler registry.
2964+
* `POST /actions/global/:action` shape, dispatched to the `'global'`
2965+
* handler-registry key.
29132966
*/
29142967
invokeGlobal: async <T = any>(
29152968
actionName: string,

packages/objectql/src/plugin.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,7 +1340,14 @@ export class ObjectQLPlugin implements Plugin {
13401340
* Resolve the engine object key an action registers under. Standalone
13411341
* `action` metadata declares `objectName` (spec `ActionSchema`); bundle
13421342
* collectors attach `object`; object-less actions register under the
1343-
* `'global'` wildcard key, matching AppPlugin's bundle registration.
1343+
* `'global'` key, matching AppPlugin's bundle registration.
1344+
*
1345+
* `'global'` is the CANONICAL object-less key (#3913) — not a wildcard.
1346+
* `executeAction` is an exact-string `Map` lookup, so every reader has to
1347+
* probe this literal; the runtime's `actionHandlerObjectKeys` does, and the
1348+
* runtime's `standaloneActionObjectName` must stay in lockstep with this
1349+
* method or the declaration the MCP surface resolves stops matching the
1350+
* handler that actually runs.
13441351
*/
13451352
private actionObjectKey(action: any): string {
13461353
if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName;

0 commit comments

Comments
 (0)