Skip to content

Commit 0dc4bc2

Browse files
committed
feat: implement L1/L2 hook and action body execution via QuickJS sandbox
1 parent c6d79a8 commit 0dc4bc2

35 files changed

Lines changed: 2708 additions & 22 deletions
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
---
2+
title: Hook & Action Bodies (L1 / L2)
3+
description: How hook handlers and script-action bodies travel through ObjectStack as pure metadata, and the spec they must conform to.
4+
---
5+
6+
# Hook & Action Bodies
7+
8+
ObjectStack treats every hook handler and every `type: 'script'` action as **pure metadata**. There is no separate `.mjs` file shipped alongside the project artifact, no dynamic `import()` at runtime, and no filesystem dependency on the cloud. A body is either:
9+
10+
- **L1 — Expression**    a formula-engine string, side-effect-free.
11+
- **L2 — Sandboxed JS**    a JavaScript source string executed inside an isolated VM with declared capabilities.
12+
13+
A third "compiled module" form (L3) was considered and explicitly **disabled** — it broke the cloud-parity guarantee that every artifact is a single self-contained JSON.
14+
15+
## TL;DR
16+
17+
```ts
18+
// Authoring (TS source — packages/myapp/src/objects/account.hook.ts)
19+
export const beforeInsert = defineHook({
20+
name: 'normalize_account',
21+
object: 'account',
22+
events: ['beforeInsert'],
23+
handler: async (ctx) => {
24+
if (ctx.input.website) {
25+
ctx.input.website = ctx.input.website.toLowerCase();
26+
}
27+
},
28+
});
29+
```
30+
31+
```jsonc
32+
// Build artifact (account.hook.json — what objectos actually loads)
33+
{
34+
"name": "normalize_account",
35+
"object": "account",
36+
"events": ["beforeInsert"],
37+
"body": {
38+
"language": "js",
39+
"source": "if (ctx.input.website) ctx.input.website = ctx.input.website.toLowerCase();",
40+
"capabilities": []
41+
}
42+
}
43+
```
44+
45+
The CLI builder extracts the function body, AST-checks it, and emits the metadata above. No `runtimeModule`, no `bundle.functions[normalize_account]` — the artifact is self-contained.
46+
47+
## Why metadata-only?
48+
49+
| Constraint | Implication |
50+
|---|---|
51+
| **Cloud parity.** objectos in production receives projects through the `cloud-artifact-api`. | The transport must be a single JSON. |
52+
| **Edge runtime support.** objectos must run on Cloudflare Workers, Vercel Edge, Deno Deploy. | No native modules, no Node-only filesystem APIs in the execution path. |
53+
| **Hot-reloadable.** Studio in-browser editor must save handler edits and have them take effect on next request. | Bodies must be data, not code that requires a build step. |
54+
| **Audit & multi-tenancy.** Every body should be inspectable, sandboxable, and scoped per tenant. | Bodies travel through the same RBAC pipe as data. |
55+
56+
This is the same trade-off ServiceNow made (Business Rules), Salesforce made (Formulas + Apex Triggers stored as metadata), Retool made (transformer JS strings), and Airtable made (Scripting blocks). It's the standard low-code shape.
57+
58+
## L1 — Expression bodies
59+
60+
Pure formula. No IO, no mutation. Used for:
61+
62+
- Hook `condition` (run-this-hook? predicate)
63+
- Action `body` for trivial computed values
64+
- Validation rules
65+
66+
```jsonc
67+
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }
68+
```
69+
70+
Evaluated by the same formula engine that powers field formulas — see [Formula Reference](./formula).
71+
72+
## L2 — Sandboxed JS bodies
73+
74+
A JavaScript **function body** (not a full module) executed inside QuickJS.
75+
76+
```jsonc
77+
{
78+
"language": "js",
79+
"source": "const total = await ctx.api.object('opportunity').count({ account_id: ctx.input.id }); ctx.input.opportunity_count = total;",
80+
"capabilities": ["api.read"],
81+
"timeoutMs": 250,
82+
"memoryMb": 32
83+
}
84+
```
85+
86+
### Sandbox surface
87+
88+
The script sees only what the surrounding `ctx` object exposes:
89+
90+
| Field | Description | Capability required |
91+
|---|---|---|
92+
| `ctx.input` | Mutable record being inserted/updated/etc. | none |
93+
| `ctx.previous` | Pre-update record (update events only). | none |
94+
| `ctx.user` / `ctx.session` | Identity context. | none |
95+
| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` |
96+
| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` |
97+
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
98+
| `ctx.crypto.hash(algo, data)` | Sha-256/512 etc. | `crypto.hash` |
99+
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
100+
| `ctx.connector(name).<method>(...)` | Outbound HTTP / SaaS calls. | (separate Connector spec) |
101+
102+
### What the sandbox forbids
103+
104+
The CLI builder **rejects** any source that uses:
105+
106+
- `import` / `require` / dynamic `import()`
107+
- `fetch`, `XMLHttpRequest`, `WebSocket`
108+
- `process`, `globalThis`, `Buffer`, `setImmediate`
109+
- `eval`, `new Function`, `Function` constructor
110+
- references to identifiers from value-only top-level imports
111+
112+
Need outbound HTTP? Define a **Connector recipe** as metadata and call it via `ctx.connector(...)`. (Connector spec is tracked separately and ships after L1+L2 stabilises.)
113+
114+
### Signature conventions
115+
116+
| Surface | TS authoring | Sandbox invocation |
117+
|---|---|---|
118+
| Hook | `(ctx: HookContext) => Promise<void>` | `(ctx) => Promise<void>` |
119+
| Action | `(input: I, ctx: ActionContext) => Promise<O>` | `(input, ctx) => Promise<O>` |
120+
121+
Hooks mutate `ctx.input`/`ctx.result`; actions return their output value explicitly.
122+
123+
### Engine
124+
125+
The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
126+
127+
Per-invocation timeouts default to **250ms** for hooks and **5000ms** for actions; per-invocation memory caps at **32 MB**. Both are overridable per body.
128+
129+
## L3 — Compiled modules (intentionally disabled)
130+
131+
An earlier design allowed the CLI to emit a sibling `objectstack-runtime.<hash>.mjs` that `objectos` would `import()` at runtime. We removed that path because:
132+
133+
1. It meant cloud-deployed `objectos` had to download a JS module out-of-band, adding another transport, another cache, another vector for tenant cross-talk.
134+
2. It bypassed the sandbox entirely — a misbehaving module could call any Node API on the host.
135+
3. It made hot-reload from Studio impossible; you cannot edit a baked `.mjs` from a browser.
136+
137+
If you have a body that genuinely cannot be expressed in L1+L2 (typically: it needs an npm package's behaviour), the right escape hatch is a **plugin** — install it on the host, register a service via DI, and call it from L2 with a capability-gated proxy. Bodies stay metadata; the heavy lifting moves to a place where it's auditable and shared.
138+
139+
## Build pipeline
140+
141+
`objectstack build` walks `*.hook.ts` and `*.action.ts` in your package:
142+
143+
1. Parse with the TypeScript compiler API.
144+
2. Find each handler arrow / function expression.
145+
3. AST allow-list check the body (see "What the sandbox forbids" above).
146+
4. **Pass:** emit `body: { language: 'js', source: <printed body>, capabilities: <inferred> }`.
147+
5. **Fail:** the build aborts with a precise diagnostic — for example: `account.hook.ts:42 — fetch() is not allowed in hook bodies. Define a Connector recipe instead.`
148+
149+
Capabilities are inferred from the AST (e.g. `ctx.api.object(...).insert(...)``api.write`). You can override with a directive comment when the inference is wrong.
150+
151+
## Migration
152+
153+
If you have an existing project that uses the old `handler: 'function_name'` + `bundle.functions[name]` shape, both forms are accepted during the transition:
154+
155+
| Phase | Status |
156+
|---|---|
157+
| Phase 1 *(now)* | Both `handler` and `body` accepted. Loader prefers `body`. |
158+
| Phase 2 | Build emits a deprecation warning when `handler` is present without `body`. |
159+
| Phase 3 | `handler` removed. `body` becomes the only accepted form. |
160+
161+
The CLI extractor handles the conversion automatically — you don't need to rewrite TS source files. Run `objectstack build` and your project artifact is in the new shape.
162+
163+
## Bundle format
164+
165+
The compiled artifact `dist/objectstack.json` carries every hook and `type='script'` action body inline. The shape is identical for both:
166+
167+
```json
168+
{
169+
"hooks": [
170+
{
171+
"name": "account_protection",
172+
"object": "account",
173+
"events": ["beforeInsert", "beforeUpdate"],
174+
"priority": 200,
175+
"handler": "account_protection",
176+
"body": {
177+
"language": "js",
178+
"source": "const { event, input } = ctx; if (event === 'beforeInsert' || event === 'beforeUpdate') { … }",
179+
"capabilities": ["api.read"]
180+
}
181+
}
182+
],
183+
"actions": [
184+
{
185+
"name": "send_quote",
186+
"type": "script",
187+
"target": "global_send_quote",
188+
"body": {
189+
"language": "js",
190+
"source": "await ctx.api.object('quote').update(input.id, { sent_at: new Date().toISOString() }); return { ok: true };",
191+
"capabilities": ["api.write"]
192+
}
193+
}
194+
]
195+
}
196+
```
197+
198+
`handler` / `target` strings still refer to entries in the sibling `objectstack-runtime.{hash}.mjs` bundle. That bundle is **only used as a back-compat fallback** for runtimes that haven't yet enabled the QuickJS interpreter — once the deprecation phase ends (Phase 3 above) the bundle disappears entirely and the artifact becomes a single self-contained JSON file. This is the cloud-deployable shape: `cloud-artifact-api` ships only the JSON; the QuickJS runtime in `@objectstack/runtime` rehydrates every body inside its sandbox at boot.
199+
200+
### Capability inference
201+
202+
The extractor scans each body for known patterns and adds the matching capability tokens to `body.capabilities`:
203+
204+
| Pattern in source | Inferred capability |
205+
|---|---|
206+
| `*.object(…).find / findOne / count / aggregate / get / list` | `api.read` |
207+
| `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` |
208+
| `ctx.crypto.randomUUID` | `crypto.uuid` |
209+
| `ctx.crypto.hash` | `crypto.hash` |
210+
| `ctx.log.info / warn / error / debug` | `log` |
211+
212+
To override the inference, add a directive comment as the first line of your handler body:
213+
214+
```ts
215+
handler: async (ctx) => {
216+
// @capabilities api.read api.write log
217+
218+
}
219+
```
220+
221+
### Build pipeline at a glance
222+
223+
```
224+
objectstack.config.ts
225+
└── defineStack({...}) ← functions live in JS
226+
└── normalizeStackInput() ← shape normalisation only
227+
└── lowerCallables() ← extracts body + builds fn map
228+
├── body:{...} ← shipped in dist/objectstack.json
229+
└── handler:"ref" ← bundled into objectstack-runtime.{hash}.mjs
230+
└── ObjectStackDefinitionSchema.safeParse()
231+
└── writeFile(dist/objectstack.json)
232+
```
233+
234+
## See also
235+
236+
- [Formula Reference](./formula) — the L1 expression engine.
237+
- [Business Logic](./business-logic) — broader patterns for hooks, actions, flows.
238+
- [Cloud Deployment](./cloud-deployment) — how artifacts travel from Studio to objectos.
239+
- `packages/spec/src/data/hook-body.zod.ts` — canonical Zod schema.
240+
- `packages/runtime/src/sandbox/script-runner.ts` — engine decision rationale.
241+
- `packages/cli/src/utils/extract-hook-body.ts` — extractor + capability inference.

content/docs/guides/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"plugins",
1414
"plugin-development",
1515
"business-logic",
16+
"hook-bodies",
1617
"authentication",
1718
"auth-sso",
1819
"security",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
---
2+
title: Hook Body
3+
description: Hook Body protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
7+
8+
Capability tokens a script body may request.
9+
10+
The runtime sandbox enforces these — if a body uses a `ctx` API that requires
11+
12+
a capability it did not declare, the call throws at invocation time.
13+
14+
- `api.read``ctx.api.object(...).find / findOne / count / aggregate`
15+
16+
- `api.write``ctx.api.object(...).insert / update / delete`
17+
18+
- `crypto.uuid``ctx.crypto.randomUUID()`
19+
20+
- `crypto.hash``ctx.crypto.hash(algo, data)`
21+
22+
- `log``ctx.log.info / warn / error`
23+
24+
`http.fetch` is intentionally absent — outbound calls go through Connector
25+
26+
recipes (separate spec) so they remain auditable and replayable.
27+
28+
<Callout type="info">
29+
**Source:** `packages/spec/src/data/hook-body.zod.ts`
30+
</Callout>
31+
32+
## TypeScript Usage
33+
34+
```typescript
35+
import { ExpressionBody, HookBody, HookBodyCapability, ScriptBody } from '@objectstack/spec/data';
36+
import type { ExpressionBody, HookBody, HookBodyCapability, ScriptBody } from '@objectstack/spec/data';
37+
38+
// Validate data
39+
const result = ExpressionBody.parse(data);
40+
```
41+
42+
---
43+
44+
## ExpressionBody
45+
46+
L1 expression body — pure formula, no IO
47+
48+
### Properties
49+
50+
| Property | Type | Required | Description |
51+
| :--- | :--- | :--- | :--- |
52+
| **language** | `string` || |
53+
| **source** | `string` || Formula expression source |
54+
55+
56+
---
57+
58+
## HookBody
59+
60+
Hook/Action body — expression (L1) or sandboxed JS (L2)
61+
62+
### Union Options
63+
64+
This schema accepts one of the following structures:
65+
66+
#### Option 1
67+
68+
L1 expression body — pure formula, no IO
69+
70+
### Properties
71+
72+
| Property | Type | Required | Description |
73+
| :--- | :--- | :--- | :--- |
74+
| **language** | `string` || |
75+
| **source** | `string` || Formula expression source |
76+
77+
---
78+
79+
#### Option 2
80+
81+
L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
82+
83+
### Properties
84+
85+
| Property | Type | Required | Description |
86+
| :--- | :--- | :--- | :--- |
87+
| **language** | `string` || |
88+
| **source** | `string` || Function body source |
89+
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` || Granted capability tokens |
90+
| **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) |
91+
| **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) |
92+
93+
---
94+
95+
96+
---
97+
98+
## HookBodyCapability
99+
100+
### Allowed Values
101+
102+
* `api.read`
103+
* `api.write`
104+
* `crypto.uuid`
105+
* `crypto.hash`
106+
* `log`
107+
108+
109+
---
110+
111+
## ScriptBody
112+
113+
L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
114+
115+
### Properties
116+
117+
| Property | Type | Required | Description |
118+
| :--- | :--- | :--- | :--- |
119+
| **language** | `string` || |
120+
| **source** | `string` || Function body source |
121+
| **capabilities** | `Enum<'api.read' \| 'api.write' \| 'crypto.uuid' \| 'crypto.hash' \| 'log'>[]` || Granted capability tokens |
122+
| **timeoutMs** | `integer` | optional | Per-invocation timeout (ms) |
123+
| **memoryMb** | `integer` | optional | Per-invocation memory cap (MB) |
124+
125+
126+
---
127+

content/docs/references/data/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This section contains all protocol schemas for the data layer of ObjectStack.
1919
<Card href="/docs/references/data/field" title="Field" description="Source: packages/spec/src/data/field.zod.ts" />
2020
<Card href="/docs/references/data/filter" title="Filter" description="Source: packages/spec/src/data/filter.zod.ts" />
2121
<Card href="/docs/references/data/hook" title="Hook" description="Source: packages/spec/src/data/hook.zod.ts" />
22+
<Card href="/docs/references/data/hook-body" title="Hook Body" description="Source: packages/spec/src/data/hook-body.zod.ts" />
2223
<Card href="/docs/references/data/mapping" title="Mapping" description="Source: packages/spec/src/data/mapping.zod.ts" />
2324
<Card href="/docs/references/data/object" title="Object" description="Source: packages/spec/src/data/object.zod.ts" />
2425
<Card href="/docs/references/data/query" title="Query" description="Source: packages/spec/src/data/query.zod.ts" />

content/docs/references/data/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"field",
1515
"filter",
1616
"hook",
17+
"hook-body",
1718
"mapping",
1819
"misc",
1920
"notification",

0 commit comments

Comments
 (0)