Skip to content

Commit 0c90ece

Browse files
os-zhuangclaude
andauthored
fix(actions): mount the object-less /actions//:action shape; bump objectui to e651c936870e (#4005)
Two things, both closing the #3913/#3962 arc at the HTTP surface. 1. The object-less action shape was unreachable. #3913 taught handleActionsRequest to route a single-segment path at the canonical 'global' key, and that code was correct and unit-tested — but nothing delivered the path: the dispatcher mounts routes explicitly, `:object` does not match an empty segment, and no registration covered `POST /actions//:action`. Over real HTTP it fell through to Hono's notFound with a bare {error:'Not found'} and the actions domain never ran, so the exact URL #3913 was filed against still did not dispatch. The tests call handleActions()/dispatch() directly and so could not see it — the same class of bug dispatcher-plugin.routes.test.ts exists for after /mcp and /keys shipped that way. Registered the route (Hono matches the literal `//` without shadowing `:object/:action`, verified against the router) and extended that guard to cover the action shapes. Found by dogfooding the running showcase app. 2. Bumped .objectui-sha a136322f8723 → e651c936870e so the vendored console carries the console-side halves of this work: objectui#2971 (read #3962's single-wrapped responses; PeoplePicker render-phase cursor fix) and objectui#2972 (declared-response-envelope unwrap on the datasource page and api-action runner). Verified in the browser at the new pin: a validation-failing action answers 400 with error.code VALIDATION_FAILED and details.fields[] anchoring issued_on, the console shows a RED error toast and the record stays Draft; the succeeding call answers 200 with data as the handler's return value (single wrap) and shows the green success toast with the record flipped to Sent. Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1c8298 commit 0c90ece

3 files changed

Lines changed: 71 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(actions): the object-less `POST /actions//:action` shape is actually reachable over HTTP (#3913 follow-up)
6+
7+
#3913 taught `handleActionsRequest` to route a single-segment path — the
8+
object-less shape `POST /api/v1/actions//:action` — at the canonical `'global'`
9+
key. That code was correct and unit-tested, and **unreachable**: the dispatcher
10+
mounts its routes explicitly, `:object` does not match an empty path segment,
11+
and no registration covered the `//` form. Over real HTTP the request fell
12+
through to Hono's `notFound` and answered a bare `{error: 'Not found'}` with the
13+
actions domain never running — so the exact URL #3913 was filed against still
14+
did not dispatch.
15+
16+
The tests could not catch it because they call `dispatcher.handleActions()` /
17+
`dispatcher.dispatch()` directly, bypassing the route table. This is the same
18+
class of bug `dispatcher-plugin.routes.test.ts` was created for after `/mcp` and
19+
`/keys` shipped the same way; the guard now covers the action routes too.
20+
21+
Found by dogfooding the running showcase app, not by the suite.
22+
23+
`POST /api/v1/actions//:action` now answers identically to
24+
`POST /api/v1/actions/global/:action` — same envelope, same `'global'` key. The
25+
object-scoped registrations are untouched and unshadowed (Hono matches the
26+
literal `//` without competing with `:object/:action`).

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,25 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
161161
// Non-discovery routes are unaffected by the cession.
162162
expect(routes).toContain('GET /api/v1/packages');
163163
});
164+
165+
// [#3913 follow-up] The exact bug this file's header describes, committed
166+
// again: `handleActionsRequest` learned to route the OBJECT-LESS shape
167+
// `POST /actions//:action` at the canonical 'global' key, and its unit tests
168+
// called `handleActions()` / `dispatch()` DIRECTLY — so they passed while the
169+
// path had no `server.post()` of its own. `:object` does not match an empty
170+
// segment, so over real HTTP the request fell through to Hono's `notFound`
171+
// and answered a bare `{error: 'Not found'}` with the domain never running.
172+
// Found by dogfooding, not by the suite. Assert the registration, which is
173+
// the only thing that makes the branch reachable.
174+
it('mounts the object-less action shape /actions//:action so it reaches dispatch()', async () => {
175+
const { server, routes } = makeFakeServer();
176+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
177+
await plugin.start?.(makeCtx(server));
178+
179+
expect(routes).toContain('POST /api/v1/actions//:action');
180+
// The object-scoped shapes must keep their registrations — the empty-segment
181+
// route is additive and must not replace or shadow them.
182+
expect(routes).toContain('POST /api/v1/actions/:object/:action');
183+
expect(routes).toContain('POST /api/v1/actions/:object/:action/:recordId');
184+
});
164185
});

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,30 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
10531053
// `req.params` and is picked up by `prepareResolverHints`, exactly as
10541054
// the automation routes handle it.
10551055
const registerActionRoutes = (base: string) => {
1056+
// [#3913 follow-up] The OBJECT-LESS shape, with the object
1057+
// segment left empty: `POST /actions//:action`. This is the URL
1058+
// an SDK that has no object to name emits, and it is the exact
1059+
// one #3913 was filed against. `handleActionsRequest` has
1060+
// routed it at the canonical `'global'` key since #3913 — but
1061+
// ONLY when something delivers the path, and nothing did:
1062+
// `:object` does not match an empty segment, so the request
1063+
// fell through to Hono's `notFound` and answered a bare
1064+
// `{error: 'Not found'}` without the domain ever running. The
1065+
// unit tests call `handleActions()` / `dispatch()` directly,
1066+
// which is precisely why they could not catch this — found by
1067+
// dogfooding the real HTTP surface.
1068+
//
1069+
// Registered FIRST and with the empty segment spelled out.
1070+
// Hono matches the literal `//` and does not let this shadow
1071+
// the two-segment route below (verified against the router).
1072+
server!.post(`${base}/actions//:action`, async (req: any, res: any) => {
1073+
try {
1074+
const result = await dispatcher.dispatch('POST', `/actions//${req.params.action}`, req.body, req.query, { request: req });
1075+
sendResult(result, res);
1076+
} catch (err: any) {
1077+
errorResponse(err, res);
1078+
}
1079+
});
10561080
server!.post(`${base}/actions/:object/:action`, async (req: any, res: any) => {
10571081
try {
10581082
const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}`, req.body, req.query, { request: req });

0 commit comments

Comments
 (0)