Skip to content

Hooks · routes · cron

Vaultbase ships a JavaScript runtime for three kinds of code that live in the admin UI: hooks, custom routes, and cron jobs. All three use a Monaco editor with TypeScript IntelliSense over a typed ctx object.

Code is compiled and cached on save. Errors abort the operation (hooks) or return a 500 (routes) — visible in the Logs page.

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.

UTC cron expressions, ticked every 30 seconds. 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.

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.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; uniqueKey?: string; retries?: number;
backoff?: "exponential" | "fixed"; retryDelayMs?: number;
}): Promise<{ jobId: string; deduped: boolean }>;
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?: {
expiresIn?: number | string; // "1h" | "7d" | seconds
issuer?: string; audience?: string;
}): Promise<string>; // HS256
jwtVerify(token, secret, opts?): Promise<Record<string, unknown>>;
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; // default 1
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.* request runs through a CIDR check before fetch(). By default the following private / link-local / loopback 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
::1/128 fc00::/7 fe80::/10

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 filtering entirely (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 via the underlying bun:sqlite connection. Bind params positionally (?) or named (:name).

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
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
}
  • 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.
  • Batch ops bypass per-collection hooks today. A pre-existing limitation tracked in the parity doc’s Follow-ups.