Issue #1647. Extend the QuorumProof API server without forking it. Runtime:
api-server/src/plugins/. Example:examples/plugins/event-stats/.
A plugin is an ES module loaded into the API-server process at startup. It
gets a narrow, versioned context — not the Express app or internal services —
so plugins can't accidentally depend on internals that change between releases.
QUORUMPROOF_PLUGINS=a,b
│
startup ── migrations ── loadPluginsFromEnv() ── listen()
│
PluginRegistry.load(spec) (src/plugins/registry.ts)
import(spec) → default export
factory(config)? → validatePlugin() → setup(ctx)
│
┌─────────────────────┼──────────────────────────┐
▼ ▼ ▼
ctx.router onEvent(event) registerHealthCheck()
/api/plugins/<name> fan-out from /health →
(behind /api auth, broadcastEvent() plugin:<name>:<check>
rate limits, etc.) async, time-boxed
│
SIGTERM → teardown() (reverse order)
| Hook | When | Notes |
|---|---|---|
setup(ctx) |
Once, before the server listens. | Register routes and health checks here. Throwing skips this plugin (or aborts startup with PLUGINS_STRICT=true). |
ctx.router |
Mounted at /api/plugins/<name>. |
All /api middleware applies: CORS, rate limiting, API-key limits, request signing, IP allow-list, concurrency limiter. |
onEvent(event) |
Every event broadcast to WebSocket subscribers (credential_issued, credential_attested, credential_revoked, …). |
Fire-and-forget off the request path; timed out after PLUGIN_EVENT_TIMEOUT_MS (5 s). Errors are logged and counted, never propagated. |
ctx.registerHealthCheck(name, fn) |
Polled by /health. |
Returning unhealthy makes /health return 503 — use degraded for non-critical problems. |
teardown() |
Graceful shutdown. | 10 s limit. Close sockets, flush buffers. |
ctx.logger |
Anytime. | Logs under module plugin:<name>; tune with MODULE_LOGS=plugin:<name>:debug. |
ctx.config |
Anytime. | Frozen copy of this plugin's entry in QUORUMPROOF_PLUGIN_CONFIG. |
// api-server/src/plugins/types.ts
export interface QuorumProofPlugin {
readonly name: string; // /^[a-z0-9][a-z0-9-]{0,62}$/, unique
readonly version: string; // your semver
readonly apiVersion: number; // must equal PLUGIN_API_VERSION (1)
setup(ctx: PluginContext): void | Promise<void>;
onEvent?(event: PluginEvent): void | Promise<void>;
teardown?(): void | Promise<void>;
}The default export may be the plugin object or a factory
(config) => QuorumProofPlugin | Promise<QuorumProofPlugin>. Prefer the
factory — it keeps state per instance and makes tests trivial.
- A plugin whose
setup()throws is skipped; others still load. onEventruns after the host has already broadcast the event; a slow or failing plugin cannot delay WebSocket or webhook delivery.- Events are frozen copies — plugins cannot mutate what other plugins see.
- Plugins run in-process with full Node privileges. Isolation protects availability, not security: only install plugins you would merge into the codebase (see §6).
PLUGIN_API_VERSION is bumped only for breaking changes to types.ts.
Additive changes (new optional hooks, new context fields) keep the version.
A plugin targeting a different version is rejected at load time with a clear
error rather than failing at runtime.
The walkthrough builds the example in
examples/plugins/event-stats/:
per-type event counters, a stats endpoint, an optional forwarder, and a
health check.
my-plugin/
package.json "type": "module", keyword "quorumproof-plugin",
"quorumproof": { "apiVersion": 1 }
index.js default export: factory
test/
README.md config keys, routes, health checks
Plain JavaScript with // @ts-check and JSDoc typedefs pointing at
api-server/src/plugins/types.ts gives type checking with no build step.
TypeScript is fine too — publish the compiled .js.
// @ts-check
/** @returns {import('quorumproof-api-server/src/plugins/types.js').QuorumProofPlugin} */
export default function createPlugin(config = {}) {
return {
name: 'hello',
version: '0.1.0',
apiVersion: 1,
setup(ctx) {
ctx.router.get('/', (_req, res) => res.json({ hello: config.who ?? 'world' }));
ctx.logger.info('hello plugin ready');
},
};
}setup(ctx) {
ctx.router.get('/stats', (_req, res) => res.json(Object.fromEntries(counts)));
ctx.registerHealthCheck('forwarder', async () =>
failures >= threshold ? { status: 'degraded', message: `${failures} failures` }
: { status: 'healthy' });
},
async onEvent(event) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
if (forwardUrl) await forward(event); // throw on failure → counted by host
},
teardown() { counts.clear(); },From api-server/:
QUORUMPROOF_PLUGINS=../examples/plugins/event-stats/index.js \
QUORUMPROOF_PLUGIN_CONFIG='{"../examples/plugins/event-stats/index.js":{"forwardUrl":"http://localhost:9999"}}' \
MODULE_LOGS=plugin:event-stats:debug \
npm run dev
curl localhost:3000/api/plugins/event-stats/stats
curl localhost:3000/health # includes plugin:event-stats:forwarder| Variable | Default | |
|---|---|---|
QUORUMPROOF_PLUGINS |
— | Comma-separated specifiers, loaded in order. Package names resolve from node_modules; ./, ../ and absolute paths resolve from the working directory. |
QUORUMPROOF_PLUGIN_CONFIG |
{} |
JSON object keyed by the exact specifier used in QUORUMPROOF_PLUGINS. Invalid JSON aborts startup. |
PLUGINS_STRICT |
false |
true refuses to start if any plugin fails to load. Recommended in production. |
PLUGIN_EVENT_TIMEOUT_MS |
5000 |
Per-call onEvent timeout. |
Secrets belong in environment variables or a secret store the plugin reads
itself, not in QUORUMPROOF_PLUGIN_CONFIG (which is often logged by
deployment tooling).
| Do | Don't |
|---|---|
Keep onEvent fast; queue heavy work. |
Block the event loop (sync crypto, large JSON on the hot path). |
| Validate all request input on your routes. | Assume /api auth means callers are trusted for your operation. |
Use degraded for optional dependencies. |
Return unhealthy for something that shouldn't take the pod out of rotation. |
Clean up timers and sockets in teardown. |
Leave intervals running — they keep the process alive past shutdown. |
Import only from plugins/types.ts. |
Import other api-server/src/** modules; they are not a stable API. |
Test in three layers. The example's
test/event-stats.test.js
shows all three.
Check what the host's validatePlugin() checks, so a bad manifest fails in
your CI rather than at startup:
const p = createPlugin();
expect(p.name).toMatch(/^[a-z0-9][a-z0-9-]{0,62}$/);
expect(p.apiVersion).toBe(1);When developing inside this repo you can call the real validator:
import { validatePlugin } from '../api-server/src/plugins/index.js';
validatePlugin(createPlugin());Build a PluginContext with a real express.Router(), spy loggers and an
in-memory health-check map. Inject network dependencies (e.g. fetch) through
the factory so they can be stubbed:
const healthChecks = new Map();
const ctx = {
router: Router(), config: {}, apiVersion: 1,
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
registerHealthCheck: (n, fn) => healthChecks.set(n, fn),
};
const p = createEventStatsPlugin({ forwardUrl: 'https://x.test' }, { fetch: vi.fn() });
await p.setup(ctx);
await p.onEvent({ type: 'credential_issued', timestamp: new Date().toISOString() });Call onEvent directly and await it — in the host it is fire-and-forget,
but in tests you want deterministic ordering.
Mount ctx.router on a bare Express app at the same path the host uses:
const app = express().use('/api/plugins/event-stats', ctx.router);
const res = await request(app).get('/api/plugins/event-stats/stats');
expect(res.status).toBe(200);import { PluginRegistry } from '../src/plugins/registry.js';
const registry = new PluginRegistry({ strict: true, eventTimeoutMs: 100 });
await registry.register(createPlugin({}), {});
registry.emit({ type: 'credential_issued', timestamp: new Date().toISOString() });
await new Promise((r) => setTimeout(r, 10));
expect(registry.list()[0].eventsDelivered).toBe(1);
await registry.teardownAll();Also worth covering: setup() throwing (plugin skipped), onEvent exceeding
the timeout (counted in eventErrors), and teardown() releasing resources.
- Contract test passes against the current
PLUGIN_API_VERSION. - Every route validates input and has a supertest case for bad input.
-
onEventtested for success, thrown error and slow dependency. -
teardownleaves no open handles (vitest --runexits cleanly). - Loaded once in a local API server with
PLUGINS_STRICT=true.
- Name it
quorumproof-plugin-<name>or@scope/quorumproof-plugin-<name>. - Add the
quorumproof-pluginkeyword and"quorumproof": { "apiVersion": 1 }topackage.jsonso compatibility is visible before install. - List
expressas a peerDependency, not a dependency, so routes share the host's Express instance. - Ship only runtime files (
"files": [...]). - Follow semver: bump major when your routes/config change incompatibly
or when you move to a new
apiVersion.
Operators install and enable it:
cd api-server
npm install @acme/quorumproof-plugin-audit@1.2.0
export QUORUMPROOF_PLUGINS=@acme/quorumproof-plugin-auditThe API-server image doesn't include third-party plugins. Build a derived image so the plugin set is pinned and reviewed like any other change:
FROM quorumproof/api-server:1.4.0
RUN npm install --omit=dev @acme/quorumproof-plugin-audit@1.2.0
ENV QUORUMPROOF_PLUGINS=@acme/quorumproof-plugin-audit \
PLUGINS_STRICT=trueRoll it out like any api-server release (see blue-green-deployment.md).
For organisation-internal plugins, a path specifier is fine:
QUORUMPROOF_PLUGINS=/opt/quorumproof/plugins/internal-audit/index.js. Mount
the directory read-only.
Open a PR adding your plugin to the table below with its name, npm
package, supported apiVersion, and a one-line description.
| Plugin | Package | apiVersion | Description |
|---|---|---|---|
| event-stats | examples/plugins/event-stats (in-repo) |
1 | Per-type event counters with optional forwarder. Reference example. |
| Symptom | Cause |
|---|---|
Plugin failed to load: … targets apiVersion 2, host provides 1 |
Plugin built for a newer host; upgrade the API server or pin an older plugin. |
invalid plugin name |
Name must be lowercase alphanumerics and dashes, ≤ 63 chars. |
plugin X is already registered |
Same plugin listed twice, or two plugins share a name. |
| Routes 404 | Plugin failed to load (check plugins logs) or path missing the /api/plugins/<name> prefix. |
| Routes 401/429 | /api middleware (auth, rate limiting) applies to plugin routes too. |
onEvent timed out in logs |
Handler exceeds PLUGIN_EVENT_TIMEOUT_MS; queue work instead of awaiting it. |
| Process doesn't exit on SIGTERM | A timer/socket not closed in teardown. |
Plugins run with the server's privileges and environment (contract ids, database URL, RPC keys). Before enabling one in production:
- Source reviewed; version pinned exactly (no
^/~). - Dependencies scanned (
npm audit, anddeny.tomlpolicy for any native code). - Outbound hosts known and allowed by network policy.
- Routes don't expose PII without the same authorisation checks as core routes (see gdpr-compliance.md).
-
PLUGINS_STRICT=trueso a missing plugin fails fast instead of silently running without it.