Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -701,8 +718,27 @@ export class ActionRunner {
* Supports ${} template expressions referencing data, record, user context.
*/
private async executeScript(action: ActionDef): Promise<ActionResult> {
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' };
}

Expand Down
48 changes: 47 additions & 1 deletion packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading