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.
Record event hooks
Section titled “Record event hooks”Six events × every collection:
beforeCreate,afterCreatebeforeUpdate,afterUpdatebeforeDelete,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.
Custom HTTP routes
Section titled “Custom HTTP routes”Mount any HTTP handler under /api/v1/custom/<path>. Methods, path params,
query, body all available on ctx.
// route: GET /api/v1/custom/health/:serviceconst 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.
Cron jobs
Section titled “Cron jobs”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.
The helpers object
Section titled “The helpers object”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.helpers.security
Section titled “helpers.security”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>; // HS256jwtVerify(token, secret, opts?): Promise<Record<string, unknown>>;aesEncrypt(plaintext: string): Promise<string>; // uses VAULTBASE_ENCRYPTION_KEYaesDecrypt(ciphertext: string): Promise<string>;constantTimeEqual(a: string, b: string): boolean;helpers.http
Section titled “helpers.http”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>;Egress filter (SSRF guard)
Section titled “Egress filter (SSRF guard)”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/8169.254.0.0/16 172.16.0.0/12 192.168.0.0/16::1/128 fc00::/7 fe80::/10A 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:
| Key | Effect |
|---|---|
hooks.http.deny | Comma-separated CIDR list. Replaces the default deny list. Set to off to disable filtering entirely (NOT recommended for public deployments). |
hooks.http.allow | Comma-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.
helpers.fs
Section titled “helpers.fs”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 dirsfs.append(path, data) : Promise<void>;fs.exists(path) : Promise<boolean>;fs.stat(path) : Promise<{ size; isFile; isDirectory; mtime }>;fs.list(dir) : Promise<string[]>; // sortedfs.mkdir(dir, { recursive? }?) : Promise<void>;fs.remove(path, { recursive? }?): Promise<void>;fs.copy(src, dst) : Promise<void>;fs.mimeOf(path) : string;helpers.path
Section titled “helpers.path”path.join(...parts) : string;path.basename(p, ext?) : string;path.dirname(p) : string;path.ext(p) : string; // includes leading dot, "" if nonepath.normalize(p) : string;helpers.db
Section titled “helpers.db”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 paramshelpers.template
Section titled “helpers.template”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;helpers.os
Section titled “helpers.os”os.env(name) : string; // "" if unsetos.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.
helpers.mails
Section titled “helpers.mails”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 }>;helpers.cron
Section titled “helpers.cron”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 }>>;helpers.util
Section titled “helpers.util”util.sleep(ms: number) : Promise<void>; // capped at 60sutil.unmarshal<T>(s: string) : T | null; // safe JSON.parseutil.readerToString(input) : Promise<string>; // stream/Response → stringHook context shapes
Section titled “Hook context shapes”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}Caveats
Section titled “Caveats”- Hooks bypass realtime broadcasts.
helpers.find/queryreads 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
evaluateRulebetween them and the data. - Batch ops bypass per-collection hooks today. A pre-existing limitation tracked in the parity doc’s Follow-ups.