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 contractdefinePlugin, 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-multitenancytenant() schema mixin + assertOwnTenant write-guard + TenantMismatch
  • plugin-soft-deletesoftDelete() 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 defineFlow or data-driven rows
  • plugin-postgis — postgres-native geography / geometry columns + 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-atlassianJiraService + ConfluenceService over the Atlassian APIs
  • plugin-deactivationdeactivation() 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 a kafkaSink for 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 defineRestRoute descriptors 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 / useTyping hooks, 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 keysfirst-class (not a plugin): apiKeys: true enables Bearer-key auth + admin-gated issue/list/revoke
  • Analytics & warehouse sinksAnalyticsSink contract + 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.