Skip to content

Hooks · routes · cron

Server-side JavaScript that runs alongside the records API — record events, custom HTTP routes, and scheduled jobs.

Vaultbase ships a JavaScript runtime for server-side code: hooks, custom routes, cron jobs and queue workers. Write them in the admin UI (a Monaco editor with TypeScript IntelliSense over a typed ctx object) or as files in a hooks folder.

Code typed into the admin UI is the body of an async (ctx) => { … } function: reach everything through ctx (ctx.record, ctx.helpers, …) and return a route’s response directly. The examples on this page are written that way.

Code is compiled and cached on save. Inline code that doesn’t compile is refused when you save it, and nothing is stored: the admin API answers 422 with Code does not compile: …, and the MCP tools return a tool error saying the code does not compile. Errors at run time abort the operation (hooks) or return a 500 (routes) — visible in the Logs page.

An enabled hook that still can’t be compiled (one saved before 0.24.0 added this check) isn’t skipped. A before* hook blocks the write with a 422 and a _hook error naming the hook; an after* hook logs the error.

Instead of keeping code in the database, you can keep it as TypeScript files. Create a vb_hooks/ folder next to the data directory (or point VAULTBASE_HOOKS_DIR at a folder, created if missing). Each file defines one hook, route, cron job or queue worker:

vb_hooks/hooks/slug.ts
import { defineHook } from "vaultbase";
import { slugify } from "../lib/text.ts";
export default defineHook({
collection: "posts",
event: "beforeCreate",
async run(ctx) { ctx.record.slug = slugify(ctx.record.title); },
});
HelperAdmin UI creates it inSettings
defineHookhooks/collection, event, enabled, run(ctx)
defineRouteroutes/method, path, requireAuth, enabled, handle(ctx)
defineJobjobs/cron, mode, enabled, run(ctx)
defineWorkerworkers/queue, concurrency, retries, backoff, retryDelayMs, enabled, run(ctx)
  • Layout. Every .ts/.js file in the folder is loaded, whatever subfolder it is in, except files under lib/ (shared code) and node_modules/. The admin UI puts new files in the folders above.
  • Imports. Files can import each other (put shared code in lib/) and npm packages installed in the folder.
  • Reloads. The folder is watched; a changed file reloads within a second. A file that fails to load keeps its previous version running, and the admin UI shows the error.
  • One source of truth. The admin UI and the MCP tools edit the same files: creating a hook writes a new file, and settings changed in the form are written into it. A save is refused with 409 if the file changed on disk since it was opened.
  • Types. vaultbase.d.ts in the folder types the ctx argument for your editor.

File code has the same privileges as code typed into the admin UI: anyone who can write to the folder can run code on the server.

Six events × every collection:

  • beforeCreate, afterCreate
  • beforeUpdate, afterUpdate
  • beforeDelete, afterDelete

before* hooks run synchronously in the same request — throwing helpers.abort("...") aborts with a 422. after* hooks run async, fire-and-forget. Several hooks on one event run oldest first, in the same order on every server.

A before* hook that throws sends its message to the client as _hook (throw new Error("Titles need a capital")). A database error from helpers.sql is the exception: the client gets “<hook name> failed (details in the server log)”, since the error names tables and columns.

beforeCreate on an auth collection runs for every way an account is made (since 0.32.2), and afterCreate once it exists:

Sign-upctx.recordNotes
Registerthe body (email and fields)Runs before the address is looked up, also for an address that has an account (the sign-up is then dropped quietly): put side effects in afterCreate.
Invitationthe email and data
OAuth / native sign-in, new accountthe email and the provider’s name (left out when the collection’s name field refuses it)Only what the hook changes is validated, without required. An address it sets is used when free (409 otherwise), and is verified only if it’s the provider’s verified one.
Guest sign-inthe generated addressValidated without required. The hook may refuse guests or set fields; the address can’t be changed.
Guest upgrade (/promote)the bodyctx.existing is the guest’s record and ctx.auth the guest’s session. Runs before the address is checked, so side effects happen even when the upgrade then ends in 409.

On register, invitations and guest upgrade, what the hook returns is validated as the body is. Where the hook may set the address, it’s trimmed and becomes the account’s (a change in case only, too). Throwing refuses the sign-up with a 422.

// beforeCreate on `posts`
ctx.record.title = ctx.helpers.slug(ctx.record.title);
if (!ctx.auth) ctx.helpers.abort("Login required");
// afterUpdate on `users`
ctx.helpers.log(`User ${ctx.record.email} updated their profile`);

ctx.record is typed for the collection — IntelliSense knows your fields. ctx.existing is populated for beforeUpdate, beforeDelete, afterUpdate, afterDelete.

Mount any HTTP handler under /api/v1/custom/<path>. Methods, path params, query, body all available on ctx.

// route: GET /api/v1/custom/health/:service
const svc = ctx.params.service;
const ok = await ctx.helpers.fetch(`https://${svc}/healthz`);
ctx.set.status = ok.ok ? 200 : 503;
return { service: svc, healthy: ok.ok };

Routes fire before built-in route resolution — so they can’t be shadowed by /api/<collection> patterns. They still go through the rate limiter, security headers and CORS like every other API response, and an uncaught error answers a plain 500 Internal error (the details go to the log).

Routes are public unless you turn on Require sign-in (require_auth) in the route editor: then callers without a valid user or admin token — or with only a guest (anonymous) session — get 401 before your code runs. Otherwise check ctx.auth yourself.

Five-field UTC cron expressions (minute hour day month weekday) or names like @hourly, ticked every 30 seconds. A job runs at most once a minute, so a sixth (seconds) field is refused. The admin UI renders human descriptions via cronstrue and links to crontab.guru.

// cron: 0 3 * * * (every day at 03:00 UTC)
const stale = await ctx.helpers.query("sessions", {
filter: 'created < ' + (Date.now()/1000 - 7*24*3600),
perPage: 1000,
});
ctx.helpers.log(`Cleaning ${stale.totalItems} stale sessions`);
// ...

Each job tracks last_run_at, next_run_at, last_status, last_error — visible as columns in the Cron tab.

A worker runs the jobs of one queue. Jobs are added with helpers.enqueue(queue, payload, opts) and run with retries and backoff; a job that runs out of attempts is dead and can be retried from Queues in the admin UI.

Unique keys. uniqueKey allows one queued-or-running job per key. A partial unique index in the database enforces it, so concurrent enqueues (from any number of requests or servers) can’t both get in: the second gets the first job’s id with deduped: true. Keys are global across queues, not per queue, so prefix them with the queue name if two queues may use the same key. Retrying a dead job answers 409 while another queued or running job holds its key. Upgrading to 0.24.0 settles existing duplicates: for each key one active job keeps it (a running job first, then the oldest) and the others lose their key and run as ordinary jobs.

Leases. A running job is leased to the process running it for 60 seconds, renewed every 15 seconds. Every 15 seconds each server puts jobs whose lease ran out back on the queue (the lost run counts as an attempt). Only the attempt that holds the lease records its result. So a server that restarts, or several servers on one Postgres database, never re-run a job another process is still running.

Delivery is still at least once: a process that stalls for more than 60 seconds loses its job to another, and a crash after the work but before the result is recorded runs it again. Write handlers to be idempotent.

Every run of a hook, custom route, cron job or queue worker has a time limit (since 0.33.0). When it runs out:

ScriptDefaultWhat the caller gets
Before hook (before*)10 s422 with _hook: "hook '<name>' timed out after … ms"; the write doesn’t happen.
After hook (after*)30 sNothing (the response already went out); the server log says so.
Custom route30 s504 { "error": "route '<name>' timed out after … ms" }.
Cron job5 minThe run is recorded as failed, with that message.
Queue worker5 minThe job fails, and is retried as the worker’s retry settings say.

When a run is stopped:

  • Its helpers.fetch and helpers.http requests are aborted.
  • A transaction it opened with helpers.sql.transaction is rolled back.
  • Every later helpers call throws, and any helper it’s waiting on stops being waited for.
  • A queue job keeps its worker’s slot (its concurrency) while the script goes on, so stuck runs don’t pile up, and a cron job doesn’t start again beside one still going. If the script still hasn’t ended one more time limit later (a 5 min limit: 10 min after the start), the slot is released and the server log warns about it (since 0.37.0); before, a script that never ended held the slot until the server restarted.

What isn’t stopped: a helper call already under way (an email being sent finishes), and code that doesn’t go through helpers (the global fetch, Bun.*). Work a cron job or queue job did before it was stopped stays done; a retry starts over.

Change the limits in the admin under Settings → Script time limits, or with the settings in the settings reference; 0 turns a limit off. Hooks, routes, jobs and workers registered in code by an app that embeds Vaultbase have no limit, and neither do the server’s own workers.

Scripts run in the server’s own thread, so a limit can only stop waiting for one. A script that loops without ever awaiting can’t be interrupted, and holds the server until it ends: keep long CPU work out of hooks, or move it to a queue worker.

ctx.helpers is the JS-side standard library — a typed surface available inside every hook, route, and cron job. Grouped into namespaces:

helpers.slug(s: string): string;
// 'Hello World!' → 'hello-world'
helpers.uuid(): string;
helpers.id(): string;
// fresh UUIDv4. `id()` is an alias — same function, commoner name.
helpers.abort(message: string): never;
// throws a 422-mapped error (before* hooks only)
helpers.find<T>(collection: string, id: string): Promise<T | null>;
helpers.query<T>(collection: string, opts?: {
filter?: string; sort?: string; perPage?: number;
}): Promise<{ data: T[]; totalItems: number }>;
helpers.log(...args: unknown[]): void;
// server-side log — appears as a HOOK row in the Logs page
helpers.fetch(input: string | URL, init?: RequestInit): Promise<Response>;
// raw outbound HTTP (Web Fetch). For retries+timeout, prefer helpers.http.
helpers.email(opts: { to: string; subject: string; body: string }): Promise<void>;
// legacy convenience — for cc/bcc/attachments use helpers.mails.send.
helpers.enqueue(queue: string, payload: unknown, opts?: {
delay?: number; // seconds before the job may run
uniqueKey?: string;
// per-job overrides of the worker's retry settings
retries?: number; // max attempts (worker's retry_max)
backoff?: "exponential" | "fixed";
retryDelayMs?: number; // the wait between tries is capped at one day
}): Promise<{ jobId: string; deduped: boolean }>;
// uniqueKey: one queued-or-running job per key, across all queues; a
// duplicate returns the existing job with deduped: true (see Queue workers).
// Finished jobs (succeeded / failed / dead) are kept 30 days by default, then
// pruned (Settings → Data retention, `maintenance.retention_days`, 0 = forever).
helpers.recordRule(opts: {
rule: string; collection?: string; expression?: string | null;
outcome: "allow" | "deny" | "filter"; reason: string;
}): void;
// attaches a custom policy decision to the active request log so it shows
// up in `entry.rules[]` and the Logs page's Rule outcome filter.
helpers.notify(userId: string, payload: {
title: string; body: string; data?: Record<string, unknown>;
}, opts?: {
providers?: ("onesignal" | "fcm")[];
inbox?: boolean; // default true — insert vb_notifications row
push?: boolean; // default true — fan out to enabled providers
}): Promise<{
inboxRowId: string | null;
enqueued: { provider: "onesignal" | "fcm"; jobId: string; deduped: boolean }[];
}>;
// see Push notifications for setup, drivers, and bootstrap details.

JWT + crypto primitives backed by WebCrypto / jose.

hash(alg: "sha256"|"sha384"|"sha512", data: string|Uint8Array): Promise<string>;
hmac(alg, key: string|Uint8Array, data): Promise<string>;
randomString(byteLen: number, alphabet?: "hex"|"base64url"): string;
randomBytes(len: number): Uint8Array;
jwtSign(payload, secret: string, opts?: {
algorithm?: "HS256" | "RS256" | "ES256"; // default HS256
expiresIn?: number | string; // "1h" | "7d" | seconds
issuer?: string; audience?: string;
}): Promise<string>; // RS256 / ES256: secret is a PKCS#8 private key PEM
jwtVerify(token, secret, opts?: {
algorithm?: "HS256" | "RS256" | "ES256";
issuer?: string; audience?: string;
}): Promise<Record<string, unknown>>; // RS256 / ES256: secret is an SPKI public key PEM
aesEncrypt(plaintext: string): Promise<string>; // uses VAULTBASE_ENCRYPTION_KEY
aesDecrypt(ciphertext: string): Promise<string>;
constantTimeEqual(a: string, b: string): boolean;

Outbound HTTP with retries (5xx / 429), per-attempt timeout, and JSON convenience.

helpers.http.request({
url: string;
method?: "GET"|"POST"|"PUT"|"PATCH"|"DELETE"|"HEAD"|"OPTIONS";
headers?: Record<string, string>;
body?: string | Uint8Array | object | null; // object → JSON-encoded
json?: boolean; // default true for objects
retries?: number; // total attempts, incl. the first; default 1 (no retry), max 10
retryDelayMs?: number; // exponential, default 250
timeoutMs?: number; // per-attempt, default 30000
}): Promise<{ status; ok; headers; text; json? }>;
helpers.http.getJson<T>(url, headers?): Promise<T>;
helpers.http.postJson<T>(url, body, headers?): Promise<T>;

Every helpers.http.* and helpers.fetch request — and every redirect it follows — runs through a CIDR check before connecting. Every address the host name resolves to is checked, a name that doesn’t resolve is blocked, and an IPv6 address that carries an IPv4 one (::ffff:a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16) is judged as that IPv4 address. Webhook deliveries use the same check. By default the following ranges are blocked:

0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8
169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 198.18.0.0/15
224.0.0.0/4 240.0.0.0/4
::/128 ::1/128 fc00::/7 fe80::/10
fec0::/10 ff00::/8 2001::/32
::ffff:0:0/96 64:ff9b::/96 64:ff9b:1::/48 2002::/16

(198.18.0.0/15, multicast, reserved and broadcast, IPv6 site-local and multicast, and Teredo 2001::/32 are blocked since 0.36.0.) A redirect is followed only with redirect: "follow"; with redirect: "error" a redirect rejects, as fetch does.

Cloud instance-metadata endpoints (169.254.169.254, fd00:ec2::254, the ECS/EKS, Alibaba and Azure equivalents) are blocked always — the allow list and hooks.http.deny = off don’t reopen them.

A blocked call throws EgressBlockedError synchronously — retries do not fire (the deny is permanent for that URL). Override via the admin Settings → Hook egress tab, which writes two settings keys:

KeyEffect
hooks.http.denyComma-separated CIDR list. Replaces the default deny list. Set to off to disable the list (cloud metadata stays blocked; NOT recommended for public deployments).
hooks.http.allowComma-separated CIDR list. Allow exceptions evaluated after deny — useful to permit one internal subnet without dropping the rest.

The cache TTL is 5 seconds; saving from the admin UI invalidates it immediately.

Known limitation: filtering happens after DNS resolution; a malicious DNS server can race-rebind a hostname between resolution and connect. Run alongside an operator-level firewall (iptables / VPC NACL) for full defense-in-depth.

Node-compatible file system (sandbox-free — admins author hooks).

fs.read(path) : Promise<string>;
fs.readBytes(path) : Promise<Uint8Array>;
fs.write(path, data) : Promise<void>; // creates parent dirs
fs.append(path, data) : Promise<void>;
fs.exists(path) : Promise<boolean>;
fs.stat(path) : Promise<{ size; isFile; isDirectory; mtime }>;
fs.list(dir) : Promise<string[]>; // sorted
fs.mkdir(dir, { recursive? }?) : Promise<void>;
fs.remove(path, { recursive? }?): Promise<void>;
fs.copy(src, dst) : Promise<void>;
fs.mimeOf(path) : string;
path.join(...parts) : string;
path.basename(p, ext?) : string;
path.dirname(p) : string;
path.ext(p) : string; // includes leading dot, "" if none
path.normalize(p) : string;

Direct SQL, asynchronous, with ? placeholders and an array of parameters. The same on every database Vaultbase runs on.

sql.query<T>(sql, params?) : Promise<T[]>;
sql.queryOne<T>(sql, params?) : Promise<T | null>;
sql.exec(sql, params?) : Promise<{ changes }>;
sql.transaction(fn) : Promise<T>; // commit on return, roll back on throw
const [row] = await ctx.helpers.sql.query("SELECT COUNT(*) AS n FROM vb_posts WHERE author = ?", [ctx.auth.id]);
const { amount, from, to } = ctx.body;
await ctx.helpers.sql.transaction(async () => {
await ctx.helpers.sql.exec("UPDATE vb_accounts SET balance = balance - ? WHERE id = ?", [amount, from]);
await ctx.helpers.sql.exec("UPDATE vb_accounts SET balance = balance + ? WHERE id = ?", [amount, to]);
});

Deprecated; use helpers.sql. Synchronous SQL via the underlying bun:sqlite connection, kept for existing hooks; it is SQLite-only. On Postgres, code that uses it is refused when saved or enabled. Bind params positionally (?) or named (:name). Named params take one object whose keys include the prefix — db.query("… WHERE id = :id", { ":id": id }); a key without it binds NULL.

db.query<T>(sql, ...params) : T[];
db.queryOne<T>(sql, ...params) : T | null;
db.exec(sql, ...params) : { changes; lastInsertRowid };
db.execMulti(sql) : void; // multiple statements; no params

Lightweight string templating — {{var}} substitution and a single layer of {{#if path}}…{{/if}} blocks. Output is not HTML-escaped — use escapeHtml explicitly when rendering into HTML.

template.render(tpl: string, vars: Record<string, unknown>): string;
template.escapeHtml(s: string): string;
os.env(name) : string; // "" if unset
os.cwd() : string;
os.platform() : "linux" | "darwin" | "win32" | …;
os.arch() : "x64" | "arm64" | …;
os.hostname() : string;

exec is intentionally omitted — shell-out is an easy footgun even for admins. If you genuinely need it, write a custom route that wraps Bun.spawn with a tight allow-list.

Rich SMTP send — cc / bcc / replyTo / from override / attachments.

helpers.mails.send({
to: string; cc?: string; bcc?: string; replyTo?: string;
from?: string; // overrides smtp.from
subject: string;
text?: string; html?: string;
attachments?: Array<{ filename; content: string|Uint8Array; contentType? }>;
}): Promise<{ messageId: string }>;

Programmatic scheduled-job control — equivalent to the admin Cron tab, callable from inside hooks/routes when you need to spin jobs up dynamically.

helpers.cron.add({
name: string; // unique per server; replaces an existing entry
schedule: string; // standard 5-field UTC cron
code: string; // body of `async (ctx) => { ... }`
enabled?: boolean;
}): Promise<{ id: string }>;
helpers.cron.remove(name: string): Promise<boolean>;
helpers.cron.list(): Promise<Array<{ id; name; schedule; enabled }>>;
util.sleep(ms: number) : Promise<void>; // capped at 60s
util.unmarshal<T>(s: string) : T | null; // safe JSON.parse
util.readerToString(input) : Promise<string>; // stream/Response → string

Read feature flags from hook code — same evaluation the SDK and /api/v1/flags use, so a rollout behaves identically everywhere.

flags.isEnabled(key: string, context?: Record<string, unknown>): Promise<boolean>;
flags.getString(key: string, fallback: string, context?): Promise<string>;
flags.getNumber(key: string, fallback: number, context?): Promise<number>;
flags.getJson<T>(key: string, fallback: T, context?): Promise<T>;

context is the targeting payload — pass the user id or attributes the flag’s segments match on. See Feature flags.

if (await ctx.helpers.flags.isEnabled("new-pricing", { userId: ctx.auth?.id })) {
ctx.record.plan = "v2";
}

Fire a webhook event from hook code. Enqueues one delivery per subscription matching the event name, with the same retry and signing behaviour as the events Vaultbase emits itself.

webhooks.dispatch(event: string, data?: unknown): Promise<{ enqueued: number }>;
await ctx.helpers.webhooks.dispatch("order.shipped", {
orderId: ctx.record.id,
carrier: ctx.record.carrier,
});

See Webhooks for subscription setup and signature verification.

interface HookContext {
record: ThisCollectionRecord; // mutable in before*
existing: ThisCollectionRecord | null; // null on create
auth: { id, type, email? } | null;
helpers: HookHelpers;
}
interface RouteContext {
req: Request;
method: string;
path: string; // inner path (after /api/v1/custom)
params: Record<string, string>; // from :name segments
query: Record<string, string>;
body: any; // parsed JSON
auth: { id, type, email? } | null;
helpers: HookHelpers;
set: { status: number; headers: Record<string, string> };
}
interface JobContext {
helpers: HookHelpers;
scheduledAt: number; // unix seconds
}
interface WorkerContext { // queue workers
payload: unknown; // the enqueued payload, JSON-decoded
attempt: number; // 1 on the first run, +1 per retry
queue: string;
jobId: string;
helpers: HookHelpers;
}
  • Hooks bypass realtime broadcasts. helpers.find/query reads but doesn’t broadcast — write through the records API or expose a custom route.
  • Hooks bypass API rules. They run in a privileged context — no evaluateRule between them and the data.