Building plugins
How plugins are structured, run, and distributed (advanced).
Updated
Plugins are how functionality is packaged and sold on top of Railbase. This page is an overview of the plugin model for developers; it's an advanced topic — most apps are built directly on the core (see the rest of this section).
A plugin is a data-resident bundle the core runs
The unit a customer receives and the platform runs is a data-resident
bundle: an encrypted JavaScript + schema bundle stored in the core's own Vault
(the _plugins row), decrypted only at the license gate, and executed
in-process in the core's goja runtime. A plugin has no main(), no port, no
subprocess, no database of its own, and no run path outside the core — it is
data the platform runs, not a program.
The bundle ships:
- Verbs registered with
$app.routerAdd("POST", "/api/<slug>/…", fn)— the core serves them in-process at/api/<slug>/*. There is no reverse proxy and no child process; on install the core reloads its hooks runtime and the plugin's verbs mount immediately — no rebuild, no restart, no extra port. - Collections declared as
schema_jsoninside the bundle. The core reconciles them into the shared Vault on install. - UI as a declarative
bundle/widgets.jsondescriptor (pages + widgets bound to the plugin's verbs). The core's site shell mounts it at/<slug>on install — the whole plugin (backend + schema + UI) lands in one marketplace action.
The Go
Register(app)form — importing the module and compiling it into a self-hosted build — is a dev/embedder build detail only. It is never the unit a customer receives and never a distribution channel. Everything below describes the shipped, data-resident model.
How a data-resident plugin uses the platform
The platform provides everything; the plugin only consumes it through mediated
host bindings — never *App internals:
- Data —
$app.dao(), the tenant-scoped binding over the platform's shared Vault (single-file CBOR document store, not SQL). There is no per-plugin store and no embedded vault; the DB is the platform's, shared but mediated. Tenant scoping is explicit — stamptenant_idon write, filter on read. - Identity —
e.auth, the trusted authenticated user/tenant on each request. Never read identity from a client header. - Events — publish with
$app.realtime().publish(topic, payload)and consume with the$app.onEvent(topic, fn)binding. - Jobs — background work via
$jobs. - Licensing — plan/quota via
$app.license.
The core enforces min_core from the catalog (it refuses a plugin that needs a
newer core).
Inter-plugin communication
Plugins never import one another — every cross-plugin interaction goes through the platform's event bus. A plugin publishes and subscribes through the bus; there are no Go imports and no direct plugin→plugin calls.
Re-shape an incoming payload with eventbus.Coerce(e.Payload, &out) — the JSON
round-trip helper every listener uses (swallow semantics: a malformed payload
leaves out zero-valued; validate the fields you need). Two contracts CI
enforces across the suite: every subscription must have a live publisher (or a
documented allowlist entry), and every topic your plugin.yaml consumes must
match a real subscription in your code — aspirational declarations fail the
build.
The canonical example is the ledger contract: ap/ar/fa (and
htr/inventory) emit gl.journal.post.request, and the gl ledger plugin
posts the journal idempotently and replies gl.journal.posted.
For ecosystem value, plugins also expose named capabilities a peer can consume, and the core mediates and runs detach hooks before a provider is removed — always over declared, discoverable contracts, never ad-hoc Go calls.
Distribution
Plugins reach customers only through the marketplace, as signed bundles. railbase.app is the sole orchestrator of purchase, licensing, install, update, and removal — there are no side channels and no manual binary handoff:
- The bundle is published to railbase.app (the vendor's distribution server) with its version, content hash, and Ed25519 signature.
- A customer buys a subscription; railbase.app issues a
lic1license. - The customer's core pulls the bundle, verifies sha256 + signature against the pinned vendor key, lands the encrypted, license-gated row in its Vault, and runs the decrypted JS in goja. A paid plugin without a valid license installs dormant — its code is never decrypted — and recovers to active automatically once a valid license is bound.
There is no sideloading — see How plugins work and Licensing & seats for the full trust and billing model.
Pricing is a value-metric union — per-seat (seat_monthly) is the default, but a
plugin may instead be priced flat_monthly (per company/mo), host_seat_monthly,
flat_usage_monthly, or custom.
Note
Authoring and publishing plugins to the marketplace is a vendor/partner process. If you want to distribute a plugin, get in touch via Support.