From 5675303164e65dee63287d049304934c633dbeea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:06:00 +0000 Subject: [PATCH 1/3] fix(core): interpolate api targets before a registered api handler A registered `api` handler (the console's) replaces `executeAPI`, the only caller of `interpolateTarget` on the api path, so `${ctx.recordId}` went out literally. The runner now hands the handler an interpolated target through the same substitution and encoding a url target uses. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTDpMW8KaXLg1DFU6f --- ui/packages/core/src/actions/ActionRunner.ts | 20 +++- ...ctionRunner.apiHandlerTarget-26722.test.ts | 109 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index f3cec56e813..325977785f3 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1680,7 +1680,19 @@ export class ActionRunner { // Check for a registered custom handler first if (actionType && this.handlers.has(actionType)) { const handler = this.handlers.get(actionType)!; - return handler(action, this.context); + // An `api` target is interpolated HERE, by the runner, before a host + // handler sees it — the same `interpolateTarget` the built-in + // `executeAPI` and `executeUrl` run. A registered `api` handler (the + // console's) replaces `executeAPI` wholesale, so without this the spec's + // documented `${ctx.recordId}` went out on the wire as the literal + // placeholder while a `url` target with the same token resolved. One + // substitution, one vocabulary, one encoding, whoever sends the request. + return handler( + actionType === 'api' && typeof action.target === 'string' + ? { ...action, target: this.interpolateTarget(action.target, action) } + : action, + this.context, + ); } // Built-in action execution by type. @@ -2802,7 +2814,8 @@ export class ActionRunner { /** * Substitute `${param.X}` and `${ctx.X}` tokens in an action target. * - * Used by executeUrl and executeAPI so metadata can produce dynamic + * Used by executeUrl and executeAPI — and on an `api` target before a + * registered `api` handler receives it — so metadata can produce dynamic * URLs without coupling to the wider ExpressionEvaluator surface * (which would also evaluate operators, member access, etc. — overkill * and unsafe for URL-position values). @@ -2867,7 +2880,8 @@ export class ActionRunner { // // `result` (the handler's own return value) is passed by exactly one caller: // the `ActionSchema.onSuccess` post-success hop, where a response exists. - // Every other caller — `executeUrl`, `executeAPI` — interpolates a target + // Every other caller — `executeUrl`, `executeAPI`, the registered `api` + // handler hand-off in `dispatchCollected` — interpolates a target // BEFORE the request, so there is no result to read; admitting `${result.*}` // there would widen the authorable surface with nothing behind it and // silently blank the token instead of leaving the author's mistake visible. diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts new file mode 100644 index 00000000000..3b39e1e182c --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An `api` action's `target` is interpolated before a REGISTERED `api` handler + * receives it, through the same substitution a `url` target goes through. + * + * The console registers its own `api` handler, which replaces the built-in + * `executeAPI` wholesale. Only `executeAPI` called `interpolateTarget`, so on a + * record page `target: "/api/v1/data/gapdata_item/${ctx.recordId}"` reached the + * wire as `…/$%7Bctx.recordId%7D` for PATCH and POST alike, while a `url` + * action with the same token opened the resolved address. + * + * Pinned here: what the handler receives for PATCH and POST, that the encoding + * is the url target's (one opaque segment per value), that the built-in + * executor sends the same path, that a `url` action is unchanged, and that a + * non-`api` handler still receives its target as authored. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ActionRunner, type ActionContext, type ActionDef, type ActionRunnerHandler } from '../ActionRunner'; + +const RECORD_PAGE: ActionContext = { + record: { id: 'gd_001', name: 'Gap item' }, + objectName: 'gapdata_item', +}; + +const TARGET = '/api/v1/data/gapdata_item/${ctx.recordId}'; + +function recordingApiHandler() { + const seen: ActionDef[] = []; + const handler: ActionRunnerHandler = async (action) => { + seen.push(action); + return { success: true, silent: true }; + }; + return { seen, handler }; +} + +describe('api targets interpolate before a registered api handler (objectstack#26722)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(['PATCH', 'POST'])('a %s api action hands the handler the resolved record path', async (method) => { + const runner = new ActionRunner(RECORD_PAGE); + const { seen, handler } = recordingApiHandler(); + runner.registerHandler('api', handler); + + const result = await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method }); + + expect(result.success).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0].target).toBe('/api/v1/data/gapdata_item/gd_001'); + expect(seen[0].method).toBe(method); + }); + + it('encodes each value the way a url target does', async () => { + const context: ActionContext = { record: { id: 'a/b c' }, objectName: 'gapdata_item' }; + const runner = new ActionRunner(context); + const { seen, handler } = recordingApiHandler(); + runner.registerHandler('api', handler); + const navigate = vi.fn(); + runner.setNavigationHandler(navigate); + + await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method: 'PATCH' }); + await runner.execute({ type: 'url', name: 'open', target: TARGET }); + + expect(seen[0].target).toBe('/api/v1/data/gapdata_item/a%2Fb%20c'); + expect(navigate.mock.calls[0][0]).toContain('/api/v1/data/gapdata_item/a%2Fb%20c'); + }); + + it('the built-in executor sends the same resolved path', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) }); + vi.stubGlobal('fetch', fetchMock); + const runner = new ActionRunner(RECORD_PAGE); + + await runner.execute({ type: 'api', name: 'export_tab', target: TARGET, method: 'POST' }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/data/gapdata_item/gd_001', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('a url action with the same token is unchanged', async () => { + const runner = new ActionRunner(RECORD_PAGE); + const navigate = vi.fn(); + runner.setNavigationHandler(navigate); + + const result = await runner.execute({ type: 'url', name: 'open', target: TARGET }); + + expect(result.success).toBe(true); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate.mock.calls[0][0]).toContain('/api/v1/data/gapdata_item/gd_001'); + expect(navigate.mock.calls[0][0]).not.toContain('${'); + }); + + it('a registered non-api handler still receives its target as authored', async () => { + const runner = new ActionRunner(RECORD_PAGE); + const seen: ActionDef[] = []; + runner.registerHandler('flow', async (action) => { + seen.push(action); + return { success: true, silent: true }; + }); + + await runner.execute({ type: 'flow', name: 'run_flow', target: 'flow_${ctx.recordId}' }); + + expect(seen[0].target).toBe('flow_${ctx.recordId}'); + }); +}); From d7a518fec84454ca44c7fb3409ef345a6161eb7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:13:27 +0000 Subject: [PATCH 2/3] test(core): pin api target interpolation for a registered handler; changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTDpMW8KaXLg1DFU6f --- .changeset/26722-api-target-interpolation.md | 10 ++++++++++ .../ActionRunner.apiHandlerTarget-26722.test.ts | 12 +++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 .changeset/26722-api-target-interpolation.md diff --git a/.changeset/26722-api-target-interpolation.md b/.changeset/26722-api-target-interpolation.md new file mode 100644 index 00000000000..dd8a31a71cc --- /dev/null +++ b/.changeset/26722-api-target-interpolation.md @@ -0,0 +1,10 @@ +--- +'@object-ui/core': patch +--- + +fix(core): an `api` action's target is interpolated before a registered `api` handler sends it + +Clause-②: no +Metadata-protocol: none + +An `api` action whose `target` carries `${ctx.recordId}` (or any other `${ctx.X}` / `${param.X}` token) went out on the wire with the literal placeholder (`/api/v1/data/gapdata_item/$%7Bctx.recordId%7D`) when a host had registered its own `api` handler, as the Console does, so a record-level PATCH or POST hit a record that does not exist. A `url` action with the same token already resolved it. `ActionRunner` now runs its one target substitution on an `api` target before handing the action to a registered `api` handler, with the same scopes and the same per-value `encodeURIComponent` a `url` target gets. The built-in executor already did this and is unchanged; a registered handler for any other type still receives its target as authored. diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts index 3b39e1e182c..c83a811f9f3 100644 --- a/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts @@ -25,6 +25,9 @@ const RECORD_PAGE: ActionContext = { }; const TARGET = '/api/v1/data/gapdata_item/${ctx.recordId}'; +// A url target under `/api/` leaves through `window.location`, not the host's +// navigation handler, so the url control uses a route path with the same token. +const URL_TARGET = '/gapdata_item/${ctx.recordId}'; function recordingApiHandler() { const seen: ActionDef[] = []; @@ -62,10 +65,10 @@ describe('api targets interpolate before a registered api handler (objectstack#2 runner.setNavigationHandler(navigate); await runner.execute({ type: 'api', name: 'call_api', target: TARGET, method: 'PATCH' }); - await runner.execute({ type: 'url', name: 'open', target: TARGET }); + await runner.execute({ type: 'url', name: 'open', target: URL_TARGET }); expect(seen[0].target).toBe('/api/v1/data/gapdata_item/a%2Fb%20c'); - expect(navigate.mock.calls[0][0]).toContain('/api/v1/data/gapdata_item/a%2Fb%20c'); + expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/a%2Fb%20c'); }); it('the built-in executor sends the same resolved path', async () => { @@ -86,12 +89,11 @@ describe('api targets interpolate before a registered api handler (objectstack#2 const navigate = vi.fn(); runner.setNavigationHandler(navigate); - const result = await runner.execute({ type: 'url', name: 'open', target: TARGET }); + const result = await runner.execute({ type: 'url', name: 'open', target: URL_TARGET }); expect(result.success).toBe(true); expect(navigate).toHaveBeenCalledTimes(1); - expect(navigate.mock.calls[0][0]).toContain('/api/v1/data/gapdata_item/gd_001'); - expect(navigate.mock.calls[0][0]).not.toContain('${'); + expect(navigate.mock.calls[0][0]).toBe('/gapdata_item/gd_001'); }); it('a registered non-api handler still receives its target as authored', async () => { From 333eef546cb9d2cb71b9231a2c5506c02fc225ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:14:59 +0000 Subject: [PATCH 3/3] fix(core): hand an untokenized api action to its handler unchanged Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTDpMW8KaXLg1DFU6f --- ui/packages/core/src/actions/ActionRunner.ts | 11 +++++------ .../ActionRunner.apiHandlerTarget-26722.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 325977785f3..ae548bfa6f6 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1687,12 +1687,11 @@ export class ActionRunner { // documented `${ctx.recordId}` went out on the wire as the literal // placeholder while a `url` target with the same token resolved. One // substitution, one vocabulary, one encoding, whoever sends the request. - return handler( - actionType === 'api' && typeof action.target === 'string' - ? { ...action, target: this.interpolateTarget(action.target, action) } - : action, - this.context, - ); + // A target with no token reaches the handler as the same object. + const target = actionType === 'api' && typeof action.target === 'string' + ? this.interpolateTarget(action.target, action) + : action.target; + return handler(target === action.target ? action : { ...action, target }, this.context); } // Built-in action execution by type. diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts index c83a811f9f3..abe5b4dc24c 100644 --- a/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.apiHandlerTarget-26722.test.ts @@ -56,6 +56,17 @@ describe('api targets interpolate before a registered api handler (objectstack#2 expect(seen[0].method).toBe(method); }); + it('an api target with no token reaches the handler as the same action object', async () => { + const runner = new ActionRunner(RECORD_PAGE); + const { seen, handler } = recordingApiHandler(); + runner.registerHandler('api', handler); + const action: ActionDef = { type: 'api', name: 'plain', target: '/api/v1/data/gapdata_item', method: 'POST' }; + + await runner.execute(action); + + expect(seen[0]).toBe(action); + }); + it('encodes each value the way a url target does', async () => { const context: ActionContext = { record: { id: 'a/b c' }, objectName: 'gapdata_item' }; const runner = new ActionRunner(context);