Plugins
voidbase composes its server from plugins. Auth, realtime, the request limits, backups, the API description, mail, payments and the rest each arrive as one, and a plugin somebody else wrote installs the same way, by name, at the commit its marketplace approved. This page is what a plugin is, the eight that ship with voidbase and the official ones you add by name, how one reaches an instance, and what is not built.
What a plugin is
pb_ files, the way an instance's own are, plus a declaration. manifest.json says the name, version, tier, the voidbase range it works against, the interfaces it provides and requires, the collections it owns or extends, and its config. Beside it sit pb_hooks, pb_migrations and pb_public when it has them, and main.js when it does more than hooks: plain JavaScript, nothing compiled, whose default export is what the plugin does. apply receives the kernel: ctx.app is the instance's Hono app, so a route mounts there before the instance serves anything, and info, if present, is what it reports about itself.
{
"name": "echo",
"version": "0.1.0",
"tier": "community",
"voidbase": ">=1.0.0",
"provides": [],
"requires": []
}// main.js: plain JavaScript, nothing compiled. The default export is what the plugin does; the instance attaches
// manifest.json to it when it loads it.
export default {
apply(ctx) {
// ctx.app is the instance's Hono app; routes mount before the instance serves anything
ctx.app.get("/api/echo", (c) => c.text("echo"));
},
};Three tiers, differing in what happens if you do nothing. A core plugin ships with voidbase and is on unless you turn it off; there are eight, below. Two of them, auth and observability, are ones the instance is not usable without, and an instance running without a provider of auth@1 loads, runs with nobody signed in, and says what it is missing at boot and on /api/plugins. Taking one out is deliberate: the CLI refuses without --yes and says what stops working, and the installer route answers 409 unless the call carries force. The same guard covers a plugin another installed plugin depends on. An official plugin is ours, versioned with voidbase and listed on the marketplace, and you add it by name. A community plugin is somebody else's, from the marketplace or a marketplace of your own.
Interfaces, not names. A plugin depends on what another plugin does rather than on which plugin it is, through versioned names. voidbase defines twelve: auth@1, observability@1, realtime@1, hardening@1, backups@1, installer@1, openapi@1, mail@1, payments@1, tax@1, shipping@1 and commerce@1. A name outside that list loads, so two plugins can agree on a capability before voidbase does; a near-miss of one it defines (payjments@1) is refused, because a typo is a plugin that never meets its provider. Before a single plugin is applied the whole graph is checked and everything wrong is reported at once: two providers of one interface (both named), a missing requirement (who wanted it), a cycle (the circle printed), a plugin outside its voidbase range, a second owner of a collection. A plugin that requires an interface is applied once a provider exists, and re-applied against a replacement when the provider changes.
// a plugin that provides an interface, and one that requires it
import { serve, using } from "@voidbase-cloud/voidbase/kernel";
import type { Plugin } from "@voidbase-cloud/voidbase/plugins";
import type { Payments } from "@voidbase-cloud/voidbase/interfaces";
export const provider: Plugin = {
manifest: { name: "pay", version: "0.1.0", tier: "community", voidbase: "*", provides: ["payments@1"] },
apply(ctx) { serve<Payments>(ctx, "payments@1", implementation); },
};
export const shop: Plugin = {
manifest: { name: "shop", version: "0.1.0", tier: "community", voidbase: "*", requires: ["payments@1"] },
apply(ctx) {
const payments = using<Payments>(ctx, "payments@1"); // whichever plugin provides it
ctx.app.post("/api/shop/checkout", async (c) => c.json(await payments.checkout(c.env, await c.req.json())));
},
};Owning collections. A manifest names the collections a plugin owns, which is what lets the loader refuse a second owner. Owning one also means creating it: onBootstrap runs work once per isolate with the bindings, after voidbase's own bootstrap, and ensureCollections creates what is missing from the definition the collections API takes. When the collection exists a newer definition is reconciled forward: a field, an index or a rule the plugin now declares is added, and nothing the instance has is dropped, so a column with data in it survives a plugin update. A name the manifest does not own is refused before the database is touched.
import { onBootstrap } from "@voidbase-cloud/voidbase/kernel";
import { ensureCollections } from "@voidbase-cloud/voidbase/plugins/collections";
const plugin = {
manifest: { name: "shop", version: "1.0.0", tier: "community", voidbase: "*", collections: ["orders"] },
apply(ctx) {
onBootstrap(ctx, (env) => ensureCollections(plugin, env.DB, [
{ name: "orders", type: "base", fields: [{ name: "total", type: "number" }] },
]));
ctx.app.get("/api/shop/orders", ...);
},
};What ships
Eight plugins ship with voidbase, all tier core, one line each. Every one is loaded unless the project turned it off; the last column is the knob that makes it do something, read from the instance's environment or pb_secrets. GET /api/plugins, for a superuser, says which are loaded, which provide what, where each came from, and what each one reports.
| Plugin | What it does | What turns it on |
|---|---|---|
auth | Provides auth@1: who is making a request, the fields an auth record answers to in a rule, which collections hold accounts. Owns the five auth collections and mounts every auth route (password, OAuth2, refresh, the flows, passkeys). | On by default, tier core. voidbase plugins remove auth refuses without --yes, because it is an instance with nobody signed in. |
observability | Provides observability@1: one data point per request, and GET /api/observability/summary, /errors and /logs for a superuser. The summary answers from the Analytics Engine dataset when the account id and a token with Analytics Read are set, and from the instance's own request log otherwise, saying which. | On by default, tier core. VOIDBASE_OBSERVABILITY_SAMPLE lowers the sampling; --analytics adds the dataset. |
realtime | Provides realtime@1: the subscriptions, built per request over the hub binding on Workers, so the write path knows whether a change has anywhere to go. | On by default. |
hardening | Provides hardening@1: the body limit, the rate limit and the response policy, the headers every answer carries. Naming CORS origins also turns on the CSRF rule for cookie-carrying writes. Remove it and there is no policy at all.VOIDBASE_CSP_ROUTES gives a path its own policy, and VOIDBASE_CSRF=double-submit adds the token a cookie-authenticated write must echo; a bearer request is exempt. voidbase check --security <url> reads a running instance from outside and says pass, warn or fail per line. | On by default. The policy knobs (VOIDBASE_CORS_ORIGINS, VOIDBASE_HSTS, VOIDBASE_CSP_ROUTES, VOIDBASE_CSRF, VOIDBASE_CSP and the rest) are off unless set. |
backups | PocketBase's backups routes plus verify: full, data and schema archives with a manifest, each read back and verified after it is written, restore per kind read as one stream so an archive larger than memory loads, retention, and a copy to any S3-compatible bucket outside the account. | On by default. VOIDBASE_BACKUP_KIND, VOIDBASE_BACKUP_KEEP and VOIDBASE_BACKUP_S3_* shape the schedule and the off-site copy. |
installer | How an instance changes its own plugins: POST /api/plugins/install, remove, update and GET /api/plugins/available, for superusers. Below, "Installing one". | On by default. VOIDBASE_PROJECT_REPO and VOIDBASE_GH_TOKEN on the Worker, with VOIDBASE_AUTO_MERGE=on, make a change a commit. |
openapi | GET /api/openapi.json, an OpenAPI 3.1 document generated from the collections and scoped to the token that asked, and GET /api/docs, Scalar over it. voidbase types generates the typed client from the same document. | On by default. |
mail | Provides mail@1, tier core and on by default: outbound mail from the instance's own domain through Cloudflare's Email Service. The deploy adds the binding; a sender off the domain falls back to SMTP or is refused with the reason on /api/plugins. | VOIDBASE_MAIL_DOMAIN=example.com |
Removing a shipped plugin turns it off for the project; installing one with a shipped plugin's name takes its place.
The official plugins, added by name
The rest of ours are official plugins on the marketplace, each a directory of voidbase's own repository (packages/plugin-*) listed at a commit, through the same door anyone's goes through. voidbase plugins add stripe is all it takes; the knob in the last column is then what turns it on.
| Plugin | What it does | What turns it on |
|---|---|---|
mcp | POST /api/mcp, a stateless MCP server whose tools are the routes the caller's token may call, derived per request from the same scoped document; every call runs through the instance's own route and rules. | voidbase plugins add mcp; on once added. |
seo | robots.txt, sitemap.xml from public records, llms.txt, GET /api/seo/meta (canonical, title, JSON-LD, OpenGraph and Twitter tags, a ready head fragment) and share cards rendered on request at /api/seo/og. A real file in pb_public wins. The cards are SVG by default; the PNG knob carries a WebAssembly rasteriser and a subset font into the Worker, about a megabyte compressed, which is why it is a choice rather than a default. | Added by name. VOIDBASE_SITEMAP, VOIDBASE_SEO, VOIDBASE_SITE_URL, VOIDBASE_ROBOTS_DISALLOW and VOIDBASE_LLMS_NOTE shape the answers; VOIDBASE_SEO_PNG=1 renders the share cards as PNG. |
ai | A tool-calling chat over the instance on Workers AI, POST /api/ai/chat, with the MCP server's tool list for the caller's token. A signed-in user's conversations are records, ai_conversations and ai_messages, with a streaming route. | VOIDBASE_AI=1, or a Workers AI model name. |
translations | Declared fields answered in the locale the request asks for (?locale= or Accept-Language), falling back in the declared order, with Content-Language and a per-record note of what was swapped. Owns the translations collection; superusers get the missing and status reports. | VOIDBASE_TRANSLATABLE and VOIDBASE_LOCALES, both. |
stripe | Provides payments@1 and owns customers, subscriptions and payments: checkout, portal, cancel and a signature-checked webhook under /api/payments/stripe/, with no SDK. | STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, as secrets. |
polar | The same four routes under /api/payments/polar/, writing the same rows. Joins stripe's family at load; whichever key is set is the provider that answers, and /api/plugins says which. | POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET; POLAR_SANDBOX=1 for the sandbox. |
lemonsqueezy | The same four routes under /api/payments/lemonsqueezy/, same rows, same rule about which provider answers. | LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_STORE_ID and LEMONSQUEEZY_WEBHOOK_SECRET. |
commerce | A shop over ten collections: products, variants, inventory, carts, orders, shipments, refunds and an append-only audit trail, with a cart, an address that quotes tax and shipping, a checkout that reserves stock and charges the order's whole total at whichever payment provider runs, tax and shipping included, and fulfilment and refunds for a superuser. A payment moves an order only when it names that order, pays it in full before the provider's own tax, and bought what the order is. It requires payments@1, tax@1 and shipping@1, so the provider of each is yours to choose; tax-flat and shipping-flat are the flat-rate pair to add beside it. | Added by name, then VOIDBASE_COMMERCE=1. VOIDBASE_TAX_RATE, VOIDBASE_SHIPPING_FLAT and VOIDBASE_SHIPPING_FREE_OVER shape the two flat-rate defaults. |
domains | Deploy time: validates the hostnames, turns workers.dev off, attaches each through the Custom Domains API, waits for the certificate, redirects every other hostname to the canonical one, and detaches all of it on voidbase deploy --remove. | VOIDBASE_DOMAINS=example.com,www.example.com, or voidbase deploy --domain. |
previews | Deploy time: a second Worker for the branch with its own database, bucket and queue, seeded from production through the backups API, its address posted once on the pull request and updated on every push, gone with everything it owns when the pull request closes. A preview comes in two shapes: --shape instance, a whole instance for the branch, and --shape flagged, the same instance with the branch's new rows marked and filtered out of every production read. The flagged one isolates new rows only, and refuses a change to a row production already has rather than making it quietly. | voidbase deploy --preview <branch>, or VOIDBASE_PREVIEW, which voidbase sync --previews sets from the branch in CI. |
The client side of several of them is in @voidbase-cloud/sdk: ai(), payments() and seo() are plugins on the SDK's own surface over the routes above.
Installing one
voidbase plugins add <name> installs a plugin from a marketplace: the plugin's files at the commit the marketplace approved are fetched into pb_plugins/<name>, its manifest.json is checked against the listing, and voidbase.lock pins the marketplace, the version, the source commit and a hash of the files. It is committed like any dependency: what the repository says is running is what is running.
my-instance/
├─ pb_hooks/ your own code
├─ pb_migrations/ your own schema
├─ pb_plugins/ installed plugins, one directory each
│ └─ echo/ the plugin's files at the commit the marketplace approved
│ ├─ manifest.json its declaration
│ ├─ main.js what it does, when it does more than hooks
│ ├─ pb_hooks/ its hooks, run after the project's own
│ └─ pb_migrations/ its schema, when it has one
└─ voidbase.lock every installed plugin: marketplace, version, source commit, a hash of its filesvoidbase plugins ls # what this project runs, and where each plugin came from
voidbase plugins add echo # download, verify against the marketplace's hash, pin in voidbase.lock
voidbase plugins add echo@0.1.0 # a version other than the latest
voidbase plugins update # bring installed plugins to the latest their own marketplace serves
voidbase plugins remove echo # remove it; for a plugin voidbase ships, turn it off
voidbase plugins enable echo # turn a shipped plugin back on
voidbase plugins add echo --marketplace https://marketplace.example.com # any marketplace serving the protocolAn instance reads the same files when it starts. On Bun (the executable, a local instance, a project) every plugin's files are hashed again and checked against the lockfile, and a changed byte is refused by name. A directory in pb_plugins the lockfile does not name is not loaded. On Workers the same check runs at build time, so a mismatch fails the build rather than the instance; a stack app keeps pb_plugins and voidbase.lock at its root and its build carries both into the generated app. The marketplaces a project uses are the lockfile's list (ours is the default and can be removed) or VOIDBASE_PLUGIN_MARKETPLACES; a name served by two of them is refused until --marketplace says which. voidbase update names the installed plugins whose range excludes the target before it changes anything.
The installer plugin is how a running instance changes its own plugins, and it knows where they live. Three modes:
| Mode | Where | What a change is |
|---|---|---|
filesystem | Bun: voidbase serve, the executable | pb_plugins/ and voidbase.lock changed in place, as the CLI does; the instance loads them when it restarts. |
repository | A project deployed from a repository: VOIDBASE_PROJECT_REPO and VOIDBASE_GH_TOKEN on the Worker, and VOIDBASE_AUTO_MERGE=on | One commit to the repository, made by the instance through GitHub's API after fetching and verifying the plugin's files itself, which the repository's own build deploys. |
fixed | A Worker built without either | Nothing: the answer says what to connect. |
A request to the installer names only a marketplace the project already trusts: one voidbase.lock lists under marketplaces, or, for a plugin already installed, the one its own lock entry records. Anything else is refused before that marketplace is asked for anything, because an installed plugin runs with the Worker's secrets and a superuser session is not the project's owner. The owner trusts a new marketplace where the project lives: add it to the lockfile and commit, or run voidbase plugins add <name> --marketplace <url> on a checkout, then commit and push. Reading what a marketplace serves stays open, so an instance's page can show one before you trust it.
That is the whole cloud story. A cloud instance is your Worker in your account, deployed from a repository in your GitHub by your own Workers build; the instance's page on voidbase.cloud is a client of the instance's installer, signed in as its superuser, and voidbase cloud plugins <instance> does the same from a shell. Nothing is built for an instance by anyone but its own pipeline.
Where a plugin comes from
The marketplace does the work a publisher would otherwise do. A plugin is a public repository with a manifest.json; on approval the marketplace audits what an instance would load at a commit (nothing to compile, imports only of what an instance provides and nothing outside the plugin, nothing alarming) and records that commit, as the registry protocol says: three GETs, defined in voidbase because voidbase is the consumer. A static directory is enough to serve them, any marketplace can, and an instance fetches and verifies a plugin from any of them the same way. A published version never changes; a change is a new version. The demo runs one plugin from ours and one from a throwaway marketplace made of static files on GitHub.
At deploy time
Some of what a plugin is about happens around a deploy rather than inside a request: attaching a hostname, creating an instance for a preview. That is a second object a plugin may export beside its Plugin, with before, after and remove hooks around voidbase deploy. Every hook gets the Worker's name, the account, the Cloudflare API client the deploy uses, the resolved environment, the Worker config and vars (mutable in before), the URL, a log, and dryRun, with which a hook says what it would do and touches nothing.
import type { DeployPlugin } from "@voidbase-cloud/voidbase/deploy-plugin";
export const deploy: DeployPlugin = {
name: "echo",
manifest: plugin.manifest,
deploy: {
async before(ctx) { /* the config and the vars are yours to change; claim ctx.url; throw to refuse the deploy */ },
async after(ctx) { /* the Worker is up: act on the account with ctx.api */ },
async remove(ctx) { /* voidbase deploy --remove: undo what after did, before the Worker is deleted */ },
},
};domains and previews are the two official plugins built on it. A plugin ships its half as deploy.js: the marketplace record names the file and its integrity, voidbase plugins add downloads it with the plugin, and the lockfile pins its bytes too, so a changed deploy.js refuses the deploy the way a changed plugin refuses the build. Shipped deploy plugins run first, then installed ones in the lockfile's order; a hook that throws fails the deploy with the plugin and the phase named.
What is not built
- Interactive transactions for hooks.
VOIDBASE_DATABASE=durablemakes a batch one real transaction; a hook still cannot open a transaction of its own. - A plugin's own panel screens. The admin panel is PocketBase's own build, with voidbase's Plugins page added through the panel's extension file; that page edits each plugin's configuration plane. A plugin cannot add a screen of its own.
- The unpackaged way. Source in
pb_pluginsthat you write and commit without a marketplace in between. Today a plugin loads only whenvoidbase.lockpins it; for your own code,pb_hooksis that path. - A second auth provider. The seam exists and is proven by removal; no other plugin provides
auth@1yet. - A sandbox. A marketplace's audit is a first pass. A plugin runs inside the instance with everything the instance has, the way
pb_hooksdoes, and a plugin's failure is your instance's failure.
Write one
A repository with a manifest.json, the pb_ files it needs, a main.js if it does more than hooks, a licence and a README. Check it against voidbase's own type entry points: @voidbase-cloud/voidbase/plugins for the manifest and plugin types, /kernel for serve, using and onBootstrap, /interfaces for the contracts, /deploy-plugin for the deploy-time half. A plugin may import from @voidbase-cloud/voidbase/* and hono, because the instance has both, from its own files, and never from Node built-ins, because an instance may be a Worker. Nothing is compiled, so what you commit is JavaScript. Test it by loading it through the kernel:
// test/plugin.test.ts: the package is a loadable plugin
import { test, expect } from "bun:test";
import { Hono } from "hono";
import { createKernel, load } from "@voidbase-cloud/voidbase/kernel";
import main from "../main.js";
import manifest from "../manifest.json";
const plugin = { ...main, manifest }; // what the instance does when it loads the directory
test("loads through the kernel and its route answers", async () => {
const app = new Hono();
await load(createKernel(app as never), [plugin], "0.9.0");
expect(await (await app.request("/api/echo")).text()).toBe("echo");
});Then submit it: a form issue with the repository, a title, a line, a category and tags. The marketplace audits it and records the commit, and anyone can install it by name.
Why it is shaped this way
Interfaces rather than names, so that swapping a payment provider is removing one plugin and installing another and nothing that depended on it changes. Three tiers, so that a bare instance is usable and everything past that is something you asked for. Trust in the hash and the recorded audit rather than in a host name, so that a marketplace is three GETs anyone can serve and an instance is locked to none of them, ours included. And every install a commit, so that what an instance runs can be read, reviewed and reverted. If you have built a plugin system before and something here looks wrong, that is more useful than a feature request: Discord, or an issue.
Fix it yourself. The link below opens this file in GitHub's editor and forks the repository for you if you need one, and your change becomes a pull request without leaving the browser.