diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index b7b4cb71e6..205ed35a77 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -132,6 +132,23 @@ export interface ActionDef { execute?: string; /** Target URL or identifier (for type: 'url', 'modal', 'flow') */ target?: string; + /** + * Action body (spec `ActionSchema.body` — `HookBodySchema`). Opaque here: + * bodies are a SERVER-side execution surface and this runner never + * interprets one. + * + * L2 (`language: 'js'`) is defined as a function body run inside an isolated + * VM enforcing declared capabilities, `timeoutMs` and `memoryMb`. A browser + * has no such isolate, so "enforcing" those client-side would be decoration. + * L1 (`language: 'expression'`) is formula-engine (CEL) source, a different + * dialect from this package's `${…}` ExpressionEvaluator — running it here + * would diverge silently rather than fail. + * + * Consumers dispatch bodies by registering a `script` handler that POSTs to + * `/api/v1/actions/{object}/{action}` (see app-shell's + * `useConsoleActionRuntime`); the server runs the body through its sandbox. + */ + body?: unknown; /** For type: 'url' — where to open `target`. `'new-tab'` forces a new * browser tab/window, `'self'` forces same-page navigation. When omitted, * external URLs open in a new tab and relative URLs navigate in place. @@ -701,8 +718,27 @@ export class ActionRunner { * Supports ${} template expressions referencing data, record, user context. */ private async executeScript(action: ActionDef): Promise { - const script = action.execute || action.target; + // `target` is the canonical binding; `execute` is its deprecated alias + // (@objectstack/spec ActionSchema). Canonical wins when both are present, + // matching the spec's own fold and ActionPreview's `target ?? execute`. + // Spec >=16.1 folds `execute` into `target` and drops it at parse, so this + // only bites on raw, unparsed metadata — where the two readers used to + // disagree. Alias-only authoring still works via the fallback. + const script = action.target || action.execute; if (!script) { + // A spec `body` IS a script — this runner just cannot run one (see the + // `body` field docs). Saying "no script provided" would send the author + // hunting for a missing field they actually wrote, so name the real + // cause and the remedy instead. + if (action.body != null) { + return { + success: false, + error: + 'Action body must be executed server-side — this client runner does not interpret ' + + '`body` (sandboxed JS needs an isolated VM; expression bodies use the formula engine). ' + + 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action}.', + }; + } return { success: false, error: 'No script provided for script action' }; } diff --git a/packages/core/src/actions/__tests__/ActionRunner.test.ts b/packages/core/src/actions/__tests__/ActionRunner.test.ts index e92ac1f69c..b16ec661f1 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.test.ts @@ -200,7 +200,7 @@ describe('ActionRunner', () => { expect(result.data).toBe(101); }); - it('should evaluate script with string target fallback', async () => { + it('should evaluate script from the canonical target field', async () => { const result = await runner.execute({ type: 'script', target: 'data.name', @@ -209,12 +209,58 @@ describe('ActionRunner', () => { expect(result.data).toBe('Test'); }); + it('should prefer canonical target over the deprecated execute alias', async () => { + // Spec >=16.1 folds `execute` into `target` at parse, so the two keys only + // coexist on raw metadata. When they do, canonical wins — the same + // precedence ActionPreview and the spec's own fold already use. + const result = await runner.execute({ + type: 'script', + target: 'data.name', + execute: 'record.id + 100', + }); + expect(result.success).toBe(true); + expect(result.data).toBe('Test'); + }); + it('should fail when no script provided', async () => { const result = await runner.execute({ type: 'script' }); expect(result.success).toBe(false); expect(result.error).toContain('No script provided'); }); + it('should report a server-side body rather than claiming no script was provided', async () => { + // Spec-valid action: `body` IS the script, but bodies run server-side. + // The old message sent authors hunting for a field they had written. + const result = await runner.execute({ + type: 'script', + body: { language: 'expression', source: 'input.amount > 1000' }, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('server-side'); + expect(result.error).not.toContain('No script provided'); + }); + + it('should not interpret a js body client-side', async () => { + // L2 needs an isolated VM enforcing capabilities/timeout/memory — the + // browser has none, so this must refuse rather than approximate. + const result = await runner.execute({ + type: 'script', + body: { language: 'js', source: 'return 1 + 1;', capabilities: [] }, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('server-side'); + }); + + it('should still evaluate a client-side target when a body is also present', async () => { + const result = await runner.execute({ + type: 'script', + target: 'data.name', + body: { language: 'expression', source: 'input.amount > 1000' }, + }); + expect(result.success).toBe(true); + expect(result.data).toBe('Test'); + }); + it('should return data as undefined for expressions referencing missing vars', async () => { const result = await runner.execute({ type: 'script',