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.

// 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.

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;
}): 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
::/128 ::1/128 fc00::/7 fe80::/10
::ffff:0:0/96 64:ff9b::/96 64:ff9b:1::/48 2002::/16

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. 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.