Documentation
¶
Overview ¶
Package scriptrunner is the inbound-webhook script runner's engine: one administrator-authored script, evaluated in a fresh goja runtime, with the reference's four sandbox variables and nothing else in scope (block D9d).
It is linked into cmd/script-runner and into nothing else. The auth function never imports it — cmd/auth's TestTheAuthBinaryLinksNoJavaScriptEngine fails the day it does — because the repository owner decided on 2026-09-12 that no JavaScript engine enters the process that holds the signing keys, the session store and the password hashes. The auth function hands script, body and the resolved allowlist across the core's InboundScriptRunner seam; the product's invoker puts them on the wire (internal/scriptrunner/wire) to a Lambda of its own; this package runs them there. The runner's IAM role is the sandbox. This package is not one, and does not pretend to be: what it guarantees is that a script has no primitive with which to reach anything, which is a much smaller claim, and the role is what bounds the rest.
Why goja and not node:vm ¶
Two honest options existed: a Node.js Lambda running the script in node:vm exactly as the reference does (tools.router.ts:269-292), or a Go binary embedding goja. Node is the highest-fidelity sandbox semantics and the lowest-fidelity repository — a second language, a second build and a second CI step in a Go repository whose artifacts are byte-reproducible from one pinned image. goja keeps one toolchain and one build script, and its semantic differences are few enough to pin by test:
- The language a mapping script uses is there: async/await, arrow functions, template literals, object and array spread, destructuring, optional chaining and nullish coalescing, classes with private fields, regular-expression lookbehind (engine_test.go, TestTheLanguageAScriptUsesIsThere).
- What a node:vm context holds beyond ECMAScript is exactly what the reference puts in it — body, actions, result, console — and so it is here. A vm context has no require, no process, no fetch, no timers and no Buffer; neither has this. TestTheScriptHasNoPrimitiveToReachAnything pins the absences one by one.
- What goja lacks that V8 has: Intl (toLocaleString formats with no locale data), WebAssembly, and SharedArrayBuffer/Atomics. A script written against the reference that formats a date or a currency with Intl throws a ReferenceError here and is reported as a script that threw. docs/inbound-webhooks.md lists this under "Porting a script".
The run, in the reference's order ¶
A fresh runtime per run, as vm.createContext makes a fresh context per request (:284-289): no state crosses from one webhook to the next. The body is parsed by the engine's own JSON.parse, so the script sees what express.json would have handed the reference — the same numbers, the same last-wins answer to a duplicated key. The script is wrapped in the reference's exact async IIFE, byte for byte (:269), so a script that works there compiles here and one that does not — a trailing line comment that swallows the closing `})()` is the classic — fails here the same way. `result` is read whether or not the script threw: the reference's `if (sandbox['result'] && typeof …event === 'string')` sits after its catch (:302-304), so a script that assigns result and then throws still tracks, and so does it here.
The await, which the reference means and does not do ¶
This runner awaits the script's promise — drains the job queue until it settles — and only then reads `result`. The reference's code intends the same: it keeps the promise vm.runInContext returns, attaches its .catch "synchronously before any await", and awaits it (tools.router.ts:289-298), and its own documented pattern is `await actions['…'](…); result = {…}` (webhook-store.interface.ts:63-68). What it actually does is different, and it was verified in Node 24 rather than inferred: the async IIFE is compiled in the vm context, so the promise it returns belongs to that context's realm, and `returnValue instanceof Promise` (:290), tested against the outer realm's Promise, is false. The await is skipped, and `result` is read synchronously, before the script's first await has settled. So in the reference a result assigned after any await is never tracked, an asynchronous rejection escapes its .catch as an unhandled rejection, and a result assigned before an await that never settles is tracked at once. This runner does what the code says it means, because that is the only reading under which an awaiting script — every script that calls an action — does anything at all; the difference is registered (deviation inbound-webhook-scripts-run-on-goja), and the reference's own fix is one line (util.types.isPromise, or a thenable check, in place of instanceof).
The deadline, which is not the reference's ¶
vm.runInContext's { timeout: 5_000 } bounds only the synchronous part of the run. Here the whole run is bounded — the core's inbound-webhook-script-runs-out-of-process — by the earlier of the invoker's deadline (wire.Request.DeadlineUnixMs) and this invocation's own Lambda deadline less a margin, and a run that reaches it is interrupted and reported as a failure of the run, not of the script: the handler returns an error, Lambda sets FunctionError, and the core answers the provider 400 so it redelivers. That is the core's contract for a timeout (InboundScriptRunner, "(zero, false, err)"), and it is a registered difference of this product: a script that loops is refused and redelivered here, where the reference's synchronous timeout would have been caught, logged and acknowledged.
Memory is not bounded by the engine at all: goja has no allocation limit, so a script that grows an array forever is stopped by the function's MemorySize, which Lambda reports as a crash — FunctionError, the same 400 and redelivery as a timeout, but without this package's log line naming the webhook, because the process is gone before it can write one.
Index ¶
Constants ¶
const DefaultMargin = 500 * time.Millisecond
DefaultMargin is how long before its own Lambda deadline the runner stops a script, so that the invocation ends with this package's error rather than with the Lambda service killing the process — which would still be a FunctionError, but one whose message says "Task timed out" and nothing about which webhook it was.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Action ¶
type Action struct {
// ID is the stable identifier the administrator's two lists name, e.g.
// "user.suspend". The reference's WebhookActionMeta.id.
ID string
// Label, Category and Description are the reference's display metadata.
// Nothing in this build shows them yet: GET <admin>/api/actions answers an
// empty list in the imported core whatever this table holds, because the
// core has no seam to be told about a registry that lives in another
// process (upstream adminListActions; see docs/inbound-webhooks.md).
Label string
Category string
Description string
// DependsOn is the reference's dependsOn: other ids that must be in the
// effective set for this action to be exposed (webhook-action.ts:111-113).
DependsOn []string
// IAM is the set of IAM actions this action's Fn calls, written as the
// policy would write them ("dynamodb:UpdateItem on the auth table"). It is
// documentation for the second edit above, and it is required to be
// non-empty whenever Fn touches AWS; an action that needs nothing says
// "none".
IAM []string
// Fn runs the action. args are the script's arguments, exported to Go
// values; what it returns is handed back to the script, as its JSON, as
// the fulfilment value of a promise (nil is undefined, and a value JSON
// cannot encode is a failure), and an error rejects that promise with a
// plain Error carrying the error's message — so a script's `await` sees
// what an awaited async function would give it in the reference, as data.
//
// It runs synchronously on the engine's goroutine and must return before
// the script continues. That is a constraint and it is deliberate: the
// sandbox has no event loop, so a promise settled later, from another
// goroutine, would never be observed. ctx carries the run's deadline;
// honour it, because the engine cannot interrupt Go code.
Fn func(ctx context.Context, args []any) (any, error)
}
Action is one entry of the runner's manifest: the reference's RegisteredAction (webhook-action.ts:34-58) with the Go function in place of the bound method, and the IAM it needs written down beside it.
type Manifest ¶
type Manifest []Action
Manifest is the runner's action table.
func Shipped ¶
func Shipped() Manifest
Shipped is the manifest compiled into cmd/script-runner. It is empty, on purpose and for the reason the reference's registry is empty until a host decorates something: which effects an inbound webhook may cause is the deployment's decision, and each one is paid for in IAM (see the file header). A script calling any action on this build meets `actions[id]` undefined and a TypeError, which the runner reports as a script that threw — the reference's answer for the same script against an empty registry.
type Options ¶
type Options struct {
// Manifest is the action table. cmd/script-runner passes Shipped().
Manifest Manifest
// Console decides whether the script's console.log/warn/error reach the
// runner's log. The reference writes them to stderr outside production and
// discards them in it (tools.router.ts:272-282, NODE_ENV); the runner reads
// the same decision from the deployment environment.
Console bool
// Log is the runner's own log: its CloudWatch group, nobody else's. Nil
// discards.
Log *slog.Logger
// Margin is subtracted from the Lambda deadline. Zero is DefaultMargin.
Margin time.Duration
}
Options configures New.
type Runner ¶
type Runner struct {
// contains filtered or unexported fields
}
Runner evaluates inbound-webhook scripts. It holds no per-run state and is safe for concurrent use; each Run builds its own runtime.
func New ¶
New validates the manifest and returns a runner, or the reason the manifest cannot be used.
func (*Runner) Run ¶
Run is the Lambda handler: one request in, one response or one error out.
An error is always a failure of the run and never of the script — the deadline, a body that is not JSON, an engine error that is not a JavaScript exception. A script that threw, or that decided nothing, is a Response with OutcomeNone. See the package comment and wire's for why that split is the whole contract.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package wire is the one thing the auth function and the script runner share: the bytes that cross between them when an inbound webhook's script runs (block D9d).
|
Package wire is the one thing the auth function and the script runner share: the bytes that cross between them when an inbound webhook's script runs (block D9d). |