Railbase
GPTClaude

How plugins work

Pull-only acquisition, signed artifacts, verification, and the data-resident goja runtime.

Updated

Video guide —watch on YouTube ↗

Plugins are how Railbase becomes a product. This page explains the hosted distribution model: how a signed plugin gets from the marketplace onto your server at runtime, and how it runs once it's there.

Note

This is the only customer distribution path — the built-in marketplace, on by default, pulling from railbase.app. The Go Register(app) form that compiles a plugin into a build you produce yourself is a dev/embedder build detail, not a way customers receive plugins; see Installing plugins.

Acquisition is pull-only

In the hosted model, a plugin enters your instance one way: the core pulls it from the marketplace. There is no "install from file", no upload field, and no way to point a marketplace-managed slot at a binary on disk. (Build-time registration is a separate, compile-time path — it doesn't go through the manager at all.)

When you install a plugin, the core runs a four-step exchange with railbase.app:

catalog  →  grant  →  download  →  verify
  1. catalog — fetch the plugin's entry: its current version, minimum core version, content hash (sha256), signature, and the vendor's public key.
  2. grant — present your license; the vendor returns a short-lived download token for the version you're entitled to.
  3. download — fetch the artifact bytes using that token.
  4. verify — refuse to run it unless all three hold:
    • the sha256 matches the catalog,
    • the catalog's public key equals the key your instance has pinned for the vendor,
    • the Ed25519 signature validates against that pinned key.

Important

The pinned public key is the trust anchor. An artifact that isn't signed by the key you trust never executes — even if a network path is compromised. This is the whole reason sideloading is disabled: every running plugin is one the core cryptographically verified.

How a plugin runs

A verified artifact is not a program the core launches — it's an encrypted JS + schema bundle that runs inside the core's existing goja runtime. There is no subprocess, no private port, no separate address space. The bundle is a row in the core's _plugins Vault collection holding its AES-256-GCM-encrypted code and collection schema; the license gate decrypts it only at the point of execution, so an unpaid, expired, or revoked plugin's code is never decrypted and has zero behavior.

Once decrypted and loaded into the runtime, a plugin reaches the platform only through mediated host bindings — never the core's internals:

  • verbs via $app.routerAdd, mounted in-process and served at /api/<slug>/*;
  • data via $app.dao — the platform's own Vault DB, tenant-scoped; a plugin has no separate store of its own;
  • events via $app.realtime (publish) and $app.onEvent (consume);
  • background work via $jobs;
  • identity via e.auth — the trusted caller from the request context, never a client header;
  • plan / quota via $app.license.

The verb surface is deployment-invariant: a marketplace-installed plugin serves at /api/<slug>/* — exactly where the Go Register(app) dev form would — so installing through the marketplace never changes the URLs a frontend calls. All integrations target /api/<slug>/*.

Running in the core's runtime is safe because the bindings are the only surface a plugin can touch: it can't reach *App internals, another plugin's data, or the filesystem outside what the host mediates.

One install delivers the whole plugin

A single marketplace install delivers the plugin including its UI. The end-user frontend ships inside the bundle as a declarative widgets.json descriptor (pages + widgets bound to the plugin's verbs and collections), and the core's site shell mounts it at /<slug> on install — no rebuild, no separate step. (The old railbase plugin setup codegen wizard, which wrote raw TSX into a site's web/ and needed a rebuild, was removed; it never fit the data-resident model.)

What the bundle declares

The bundle's manifest — its catalog row, derived from plugin.yaml — declares, at install time:

Field Meaning
slug The plugin's identifier (and its /api/<slug>/* + /<slug> mount)
colls The collections it owns in the platform's Vault
provides / consumes Capabilities it offers to / needs from other plugins (over the event bus)
min_core The minimum core version it requires
roles The RBAC roles + permissions it declares (and which are billable)

Two consequences worth knowing:

  • Compatibility is enforced. If a plugin's min_core is newer than your core, the core refuses to install it and tells you to update. See Updating.
  • The core doesn't hardcode plugin roles. A plugin carries its roles and teaches them to the core at install time, which is what makes per-seat billing work without a core rebuild. See Licensing & seats.

Why data-resident, not compiled-in or .so files?

A plugin is data the platform executes, not a program that re-embeds the platform. Compiling each plugin into its own binary inverts that relationship, and native .so/plugin loading is platform-locked and ABI-fragile across Go versions. Shipping plugins as encrypted JS run in the core's goja runtime gives you cross-platform artifacts, at-rest encryption (the bundle is never a decompilable executable on disk), and runtime install/update/remove — without trusting unverified code.

Note

The Go Register(app) form does exist, but only as a dev/embedder build detail for someone compiling their own host binary. It is not a separate runtime, not a distribution channel, and not how customers obtain plugins — every customer plugin arrives as a signed, encrypted bundle through the marketplace.

Was this page helpful?Thanks for your feedback!