Overview
How Voltro plugins compose into the runtime, what they can intercept, the catalogue, and writing your own.
A Voltro plugin is a server-side extension that hooks into the runtime. Plugins can intercept mutations / queries / actions, contribute schema mixins + tables + migrations, mount raw-HTTP and inspect routes, contribute an Effect service layer, and run install / activate / deactivate lifecycles.
The framework ships some plugins; you write your own; the contract is small enough to learn in a single read.
What's in this section
- The plugin contract —
definePlugin, lifecycle hooks, rpc interceptors, framework-version compatibility - plugin-audit — mutation audit log +
audit()mixin - plugin-auth — full auth suite: password (rehash-on-verify) + sessions (multi-key rotation + sliding-window) + magic-link/reset + email verification + tenant invitations + user impersonation + passkeys (atomic clone detection, BYO multi-replica challenge store) + CSRF + session revocation + memberships/switch-tenant + TOTP/MFA (sign-in enforcement + recovery codes), mounted by
authRoutesPlugin()(see also the Authentication section) - plugin-multitenancy —
tenant()schema mixin +assertOwnTenantwrite-guard +TenantMismatch - plugin-soft-delete —
softDelete()schema mixin (hide on delete,hardDelete()bypass) - plugin-rbac — roles + permissions + the
permission()guard - plugin-ratelimit — per-endpoint / per-subject / per-tenant request limits
- plugin-billing — subscriptions, plans, entitlements + usage metering (Stripe + mock provider); seat-based billing on Stripe's own proration, retries, tax and checkout; money as integer minor units
- plugin-licensing — offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version
- plugin-mail — transactional email (Resend / Postmark / SendGrid / SES / Mailgun / SMTP, templates, suppression, scheduling, batch, idempotency)
- plugin-storage — file storage: public (CDN-direct) + private (access policy + per-object grants), S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem
- plugin-ai-flows — durable multi-step AI pipelines (deterministic + agentic) with human-in-the-loop, chaining, and cadence; code-first
defineFlowor data-driven rows - plugin-postgis — postgres-native
geography/geometrycolumns + spatial operators - plugin-broadcast — cross-replica reactivity over a pub/sub bus (Redis / NATS) for non-postgres dialects
- plugin-webhooks — durable incoming + outgoing webhooks (HMAC signing, retries, idempotency)
- plugin-atlassian —
JiraService+ConfluenceServiceover the Atlassian APIs - plugin-deactivation —
deactivation()schema mixin (visible, can't log in) - plugin-prometheus — Prometheus exporter at
GET /metrics; scrapes the unified Metrics-API (the same source the dashboard Metrics panel reads) - plugin-datadog — deep Datadog integration; agentless metrics + opt-in logs + traces (OTLP→Agent) + profiler, trace-correlated
- plugin-sentry — deep Sentry integration; trace-correlated errors + breadcrumbs from the log sink + opt-in performance traces
- plugin-flags — feature flags: per-subject / per-tenant targeting, deterministic % rollouts, kill-switch, declarative rpc gating + client UI gating
- plugin-notifications — unified notifications: one send API across email / Slack / SMS / push (first-class APNs/FCM factory) / in-app, channel preferences + in-app inbox, digest/batching, quiet hours, broadcast/topics, durable DataStore-backed store by default
- plugin-logship — ship structured logs to Better Stack / Axiom / Loki / any HTTP sink; batched, redacted, fail-soft
- plugin-moderation — moderate user content before commit: keyword or AI provider, block / flag via interceptor + in-handler redact
- plugin-search — keep an external index (Typesense / Meilisearch / Algolia) in sync via the ChangeEvent tap; tenant-scoped
search.query+ hook - plugin-cdc-out — declarative reverse-ETL: mirror table changes outward to a webhook or Kafka sink (or any custom
CdcSink) through a durable outbox; ordered per pipe, at-least-once from enqueue, dead-lettered - plugin-queue — Kafka interop: Schema-decoded consumers (
*.consumer.ts; at-least-once, serial per partition, retry + dead-letter), batched producing via a service or transactionally through the outbox, and akafkaSinkfor cdc-out - plugin-comments — comment threads on any app entity: replies, resolve/reopen, tenant-safe @-mentions with notifications, reactions, unread — live over the reactive engine, with the ejectable
<CommentsThread>UI - plugin-governance — data governance: retention TTL sweep, GDPR export + erasure, consent ledger, field encryption
- plugin-openapi — OpenAPI 3.1 spec + Swagger-UI docs generated from your
defineRestRoutedescriptors and (opt-in) rpc procedures - plugin-row-history — full row history + time-travel (
rowHistory/rowAsOf/restoreAsOf/diffVersions); what-changed-to-what on every write - plugin-presence — ephemeral realtime presence: heartbeat roster per channel +
usePresence/useTypinghooks, held in memory; cross-instance with plugin-broadcast - plugin-auth-social — first-party Sign in with Google / GitHub / Apple: mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, sessions issued through plugin-auth
- plugin-scim — SCIM 2.0 provisioning (Users + Groups at
/scim/v2) so an enterprise IdP can create/deactivate users - plugin-sso-saml — enterprise SAML 2.0 SSO: SP-initiated login + Single Logout, ACS, metadata (+ IdP-metadata-URL auto cert rotation, encrypted assertions, SP request signing); mints a framework session
- API keys — first-class (not a plugin):
apiKeys: trueenables Bearer-key auth + admin-gated issue/list/revoke - Analytics & warehouse sinks —
AnalyticsSinkcontract + five first-party sink plugins (postgres-lite, DuckDB, ClickHouse, Tinybird, PostHog) - External identity providers — the six auth-adapter packages (WorkOS, Kinde, Clerk, Auth0, Supabase, generic OIDC)
The catalogue at a glance
Status legend: ✓ shipped · ◐ partial · — planned.
| Plugin | Status | What it does |
|---|---|---|
@voltro/plugin-audit |
✓ | Mutation audit log + audit() mixin |
@voltro/plugin-auth |
✓ | Full auth suite via authRoutesPlugin(): password (rehash-on-verify), sessions (multi-key rotation + sliding-window), magic-link + password-reset, email verification (off/soft/strict policy), tenant invitations (addressed, single-use, role chosen by the inviter), user impersonation (marked, time-bounded, escalation-proof), passkeys/WebAuthn (atomic clone detection, BYO multi-replica challenge store), CSRF, session enumeration + revocation, memberships + switch-tenant, TOTP/MFA (sign-in enforcement + recovery codes); authTables schemas |
@voltro/plugin-multitenancy |
✓ | tenant() schema mixin (read-scope + write-fill) + assertOwnTenant guard + typed TenantMismatch |
@voltro/plugin-soft-delete |
✓ | softDelete() schema mixin — deletedAt / deletedBy; delete → UPDATE, hardDelete() bypass |
@voltro/plugin-rbac |
✓ | Roles compile to scopes + the permission() handler guard + typed ScopeError |
@voltro/plugin-ratelimit |
✓ | Per-endpoint / per-subject / per-tenant limits; sliding-window / fixed-window / token-bucket; memory / postgres / redis stores |
@voltro/plugin-billing |
✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing; proration, failed-payment retries, tax and the checkout seat stepper are Stripe's, via the official SDK; requireEntitlement() guard + enforce interceptor; /billing/webhook via plugin-webhooks; money as integer minor units |
@voltro/plugin-licensing |
✓ | Offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version. → details |
@voltro/plugin-ai-flows |
✓ | Durable multi-step AI pipelines — deterministic or agentic, with human-in-the-loop, chaining and cadence; author flows in code (defineFlow) or as data (visual-editor rows), one engine runs both. → details |
@voltro/plugin-mail |
✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
@voltro/plugin-storage |
✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / MinIO (R2 and GCS via their S3 interop) / Azure / database / filesystem providers, presigned URLs, listRefs browse/search, HTTP Range (206) serving, dashboard browser |
@voltro/plugin-postgis |
✓ | Postgres-native geography / geometry columns + spatial predicates (ST_DWithin, ST_Contains, ST_Intersects); GiST indexes via .expressionIndex(..., { kind: 'gist' }). No ST_Distance projection yet. Postgres-only by design (fails loud elsewhere). → details |
@voltro/plugin-broadcast |
✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. → details |
@voltro/plugin-webhooks |
✓ | Incoming + outgoing webhooks — defineIncomingWebhook (signature verify + idempotency, Stripe/GitHub/Slack presets) and defineEvent (durable delivery workflow, HMAC signing, retries, filters). → details |
@voltro/plugin-comments |
✓ | Comment threads anchored to any entity — fail-closed access delegation (viaEntity/scope), replies, resolve/reopen, tenant-safe mentions (validated twice, delivered via plugin-notifications incl. digests), reactions, per-subject unread, live comments.list, <CommentsThread> in @voltro/ui. → details |
@voltro/plugin-queue |
✓ | Kafka interop — defineQueueConsumer (*.consumer.ts, Schema-decoded, at-least-once, serial per partition, retry + DLQ with reason headers), batched producing via QueueService or transactionally through the outbox (queueOutboxHandler), kafkaSink for cdc-out, per-topic counters in the dashboards. → details |
@voltro/plugin-auth-social |
✓ | First-party social login — Sign in with Google / GitHub / Apple with no identity vendor: authorize URL + code exchange + JWKS-verified ID tokens, mandatory PKCE (S256) and state, an explicit account-linking policy (never by default), Apple's signed-JWT client secret / one-time name / private-relay email all handled; sessions via issueUserSession. → details |
@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc} |
✓ | Six IdP adapters over the shared jwtBearerStrategy — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (workosAuthorizationUrl / workosAuthenticateWithCode) for a redirect-based SSO login flow. → details |
@voltro/plugin-analytics-postgres |
✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). → details |
@voltro/plugin-duckdb |
✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. → details |
@voltro/plugin-clickhouse |
✓ | Production OLAP via the official ClickHouse client. → details |
@voltro/plugin-tinybird |
✓ | Hosted ClickHouse via Events API + Pipes. → details |
@voltro/plugin-posthog |
✓ | Product analytics — track-only; compose with another sink for reads. → details |
@voltro/plugin-atlassian |
✓ | JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile APIs — PAT or OAuth 2.0 (3LO) auth, transient retry, SSRF guard, comment-write, signature-verified inbound webhooks, avatar proxy, per-tenant cache. → details |
@voltro/plugin-deactivation |
✓ | deactivation() schema mixin — deactivatedAt + deactivatedBy (→ Actor); subject can't log in but data stays visible. → details |
@voltro/plugin-prometheus |
✓ | Prometheus exporter — GET /metrics in text exposition format over the unified Metrics-API (Effect MetricRegistry); counters / histograms / gauges + custom metrics, optional bearer gate + node process metrics. → details |
@voltro/plugin-datadog |
✓ | Deep Datadog integration — agentless metrics push to /api/v2/series + opt-in logs (/api/v2/logs, dd.trace_id-correlated) + traces (OTLP→Agent) + dd-trace profiler; DD_API_KEY/DD_SITE + unified service tagging, fail-soft. → details |
@voltro/plugin-sentry |
✓ | Deep Sentry integration — mutation/query/action errors reported correlated to the active trace (trace_id + span_id) + breadcrumbs from the framework log sink; opt-in performance traces (SentrySpanProcessor, OTel-consumer mode) + profiler. @sentry/* optional + lazy. → details |
@voltro/plugin-flags |
✓ | Feature flags — per-subject / per-tenant targeting + deterministic % rollout (FNV-1a bucket) + kill-switch; multivariate variant flags + scheduled / ramping rollouts + a durable kill-switch audit trail; declarative gatedBy (typed FlagDisabled) + requireFlag guard + flags.evaluate / flags.variants routes + useFlags/useFlag/useVariant hooks; memory / postgres store. → details |
@voltro/plugin-notifications |
✓ | Unified notifications — one send across email / Slack / SMS / push (first-class pushChannel APNs/FCM factory) / in-app channels, per-user channel preferences, in-app inbox + unread count + delivery records; digest/batching rollup, per-subject quiet hours (DND), broadcast/topic fan-out; NotificationService + useInbox/useUnreadCount/useMarkRead/useTopicSubscription/useQuietHours hooks; durable DataStore-backed store by default (auto-migrated notification_* tables). → details |
@voltro/plugin-logship |
✓ | Ship structured logs to Better Stack / Axiom / Loki / any HTTP sink — rides the log-sink hook, batched + redacted + fail-soft, trace-correlated. → details |
@voltro/plugin-moderation |
✓ | Content moderation — keyword denylist or AI provider (fails open), block (typed ContentRejected) / flag via rpc interceptor + in-handler moderate() redact helper. → details |
@voltro/plugin-search |
✓ | External search index sync — rides the ChangeEvent tap to mirror tables into Typesense / Meilisearch / Algolia (memory default), tenant-scoped search.query action (facets · highlighting · fuzziness · range/negation filters · engine-param passthrough) + useSearch hook + backfillIndex + durable cross-replica sync stats. → details |
@voltro/plugin-cdc-out |
◐ | Declarative reverse-ETL — mirror table changes outward to external sinks (webhook, Kafka via kafkaSink, plus a CdcSink interface for custom sinks) through a durable outbox; ordered per pipe, at-least-once from enqueue, retried with backoff, dead-lettered. Engine + memory/webhook/Kafka sinks shipped; a warehouse connector implements the CdcSink interface. |
@voltro/plugin-governance |
✓ | Data governance — retention TTL sweep (delete / anonymise), GDPR subject export + erasure (admin-gated routes + GovernanceService), consent ledger, field encryption. → details |
@voltro/plugin-openapi |
✓ | OpenAPI 3.1 spec (GET /openapi.json) + Swagger-UI (GET /docs) generated from defineRestRoute descriptors AND (opt-in) rpc procedures (queries/mutations/actions/streams → POST /rpc/<name>) — input/output/error Schemas via JSONSchema.make. → details |
@voltro/plugin-row-history |
✓ | Full row history + time-travel — value snapshot of every insert/update/delete (every table by default; narrow with include/exclude) into _voltro_row_history (rides the ChangeEvent tap); rowHistory / rowAsOf queries + restoreAsOf / diffVersions; TTL + per-row cap retention. → details |
@voltro/plugin-presence |
✓ | Ephemeral realtime presence — heartbeat roster per channel (presence.heartbeat/list/leave + usePresence), a useTyping typing indicator. Held in memory, owner-partitioned — no table is written; cross-instance requires @voltro/plugin-broadcast, and without a broker each replica sees only its own clients. → details |
@voltro/plugin-scim |
✓ | SCIM 2.0 provisioning — Users + Groups REST at /scim/v2 (bearer-gated) incl. group-membership PATCH/PUT + the RFC 7644 discovery trio (ServiceProviderConfig/Schemas/ResourceTypes), userName/externalId/displayName eq filters, pagination, unique userName, active:false deactivation; _voltro_scim_users/_voltro_scim_groups. → details |
@voltro/plugin-sso-saml |
✓ | Enterprise SAML 2.0 SSO — SP-initiated login + Single Logout (both directions) + ACS + SP metadata under /saml; IdP-metadata-URL auto cert rotation, encrypted assertions, clock-skew, SP request signing. Signature verify via @node-saml/node-saml (optional+lazy), mints a framework session. → details |
API keys are first-class (not a plugin): apiKeys: true in app.config.ts → Bearer-key auth + admin-gated /v1/api-keys management, hash-only storage. → details
Every plugin carries a design doc in the framework's plans/ directory before it ships.
Configuration shape
// app.config.ts
import { auditPlugin } from '@voltro/plugin-audit'
import { rateLimitPlugin } from '@voltro/plugin-ratelimit'
export default {
type: 'api' as const,
name: 'api',
plugins: [
rateLimitPlugin({ default: { limit: 60, window: '1m' } }),
auditPlugin({ sink: 'console' }),
],
}Order matters: the framework composes outer→inner, so the rate-limit interceptor runs before the audit interceptor sees the request. Rejected requests never enter the audit log.
What plugins can do
| Surface | What it lets you do |
|---|---|
interceptMutation / interceptQuery / interceptAction |
Wrap every mutation / query-setup / action — gate, audit, transform input/output. |
extendSchema |
Contribute tables + custom SQL migrations (tracked in _voltro_plugin_migrations). |
services |
Provide an Effect Layer whose Tags every handler can yield* (e.g. MailService, StorageService). |
routes |
Register plugin-owned rpc queries / mutations / actions (alias-prefixed tags). |
httpRoutes |
Serve public raw-HTTP endpoints on the framework listener (e.g. GET /_voltro/storage/:id). The request carries store — the app's DataStore — for a route that must read or write (a login endpoint minting a session row cannot be an rpc mutation), and remoteAddr, the client address already resolved through security.trustedProxies (use it instead of x-forwarded-for). Not tenant-scoped: raw HTTP has no resolved Subject, so scope it yourself. A state-changing route is origin-checked unless it declares originGuard: 'exempt'. |
inspectEndpoints |
Mount tooling under /_voltro/inspect/plugins/<alias>/…. |
onScheduleFire / onWorkflowStep / onHttpRequest |
Wrap every cron firing, every workflow step(), every pre-auth HTTP request. |
onInstall / onActivate / onDeactivate / onUninstall |
Lifecycle hooks at first-install, boot, shutdown, and removal. |
Schema mixins (defineMixin) |
The OTHER plugin shape — audit(), tenant(), softDelete() — declared in @voltro/database, not via the runtime contract. |
Pointing YOUR table at a plugin's row
Every table-carrying plugin exports its table handles, so a column in your schema can reference one exactly like it references your own:
import { aiFlowsTable } from '@voltro/plugin-ai-flows'
import { id, reference, table, text } from '@voltro/database'
export const flowFavourites = table('flow_favourites', {
id: id({ prefix: 'fav' }),
employeeId: reference(() => employees, { onDelete: 'cascade' }),
// A real foreign key across the plugin boundary. Deleting the flow removes
// the favourite; the DATABASE enforces it, so no cleanup subscriber exists to
// forget.
flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
note: text().nullable(),
})This is not a special primitive — it is reference(), with the same
onDelete semantics and the same index defaults. plugin-storage's assetRef()
has always been exactly this under the hood: a reference(() => _voltroStorageRefsTable, { onDelete: 'setNull' }).
Referencing the table as a VALUE rather than its name as a string is what makes
this safe across a plugin's own migrations. When ten plugin tables moved into
the _voltro_ namespace in 0.22.0, a reference(() => table) followed the rename
(catalog-only, the constraint travels with the table); a hand-written
text() column holding ids would not have told you anything had changed.
fk: false-style decoupling is still available — declare a plain text()
column instead. Choose it when you deliberately want the app schema independent
of the plugin's, and accept that nothing then enforces the link. What you should
NOT do is reach for it by default.
When you DO want the decoupling, pluginRef gives you the rule without the
key. It is a plain typed id column — no constraint, no cross-schema
dependency — plus a declared orphan policy the framework runs on the post-commit
change channel:
import { pluginRef } from '@voltro/database'
flowId: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()That closes the gap named above: an unenforced id column plus a hand-written
defineSubscriber that cleans up on delete is referential integrity
re-implemented per app, and it is silently wrong the first time somebody forgets
it. The declaration is the same one line either way — the difference is that the
framework performs it.
Prefer reference() when you want a real key. pluginRef is for the case
where you have deliberately chosen not to have one; it does not make the
database enforce anything. The tenant boundary fails closed, soft deletes are
opt-in (onSoftDelete), and a pluginRef at a table no installed plugin
registers refuses at boot rather than sitting there looking enforced.
orphanPolicy is not part of this. It is migration metadata — how existing
orphan rows are cleaned up before the FK constraint is added — and has no
runtime semantics. Runtime behaviour comes from onDelete.
Composing with a plugin's namespace
Sharing a namespace with a plugin already works: the collision check compares
FULL tags, so notifications.list of yours beside the plugin's
notifications.inbox is not a clash. Only an identical name is — two handlers
behind one tag is not something a caller can reason about.
To REPLACE one deliberately, declare it:
// Adopt the plugin's namespace, add your own leaves beside it…
defineQuery({ name: 'notifications.archive', guards: [{ scope: 'notifications:read' }], … })
// …and REPLACE just the one you need to behave differently. Your replacement is
// YOUR procedure, so it needs its own access decision — the plugin's does not
// carry over with the tag.
defineMutation({
name: 'notifications.markRead', overridesPlugin: true,
guards: [{ scope: 'notifications:write' }], …
})The plugin's route is dropped, not merely permitted alongside yours — permitting both would leave two handlers bound, which is the state the check exists to prevent. The boot logs which routes were replaced.
Explicit, never inferred. Letting your route win silently would mean a
plugin upgrade that adds a route could shadow one of yours with no diff to read.
It is also why the two obvious alternatives are worse: renaming your procedure,
or aliasing the whole plugin away, both move the split from a domain boundary
to "who built it" — for whoever calls the api, the worst possible partition.
When NOT to write a plugin
- One-off side effect — just call it from the mutation directly.
- App-specific behaviour — keep it in app code, not a reusable plugin.
- Anything cross-cutting that only affects ONE mutation — a single
await ctx.audit.log(...)call beats a plugin's hook.
Plugins are for cross-cutting concerns. Audit-log every write, rate-limit every mutation, send a user.created event from every sign-up: that's plugin territory.
Where to read next
- The plugin contract — write your own
- plugin-audit — most complete reference implementation