@aletheia-dev/plugin-sdk
v0.5.0
Published
SDK for writing Aletheia plugins: manifest, actions, webhooks, document access and the per-tenant context.
Readme
@aletheia-dev/plugin-sdk
The contract for writing Aletheia plugins. Aletheia is
open-source risk management infrastructure (KYC/KYB onboarding, underwriting, transaction
monitoring) with a workflow engine, a rule engine and a plugin system. A plugin wraps one vendor
or capability — sanctions screening, document verification, fraud scoring — behind a typed,
tenant-configured interface. Workflows call plugins through call_plugin steps and rules through
the plugin rule type; the platform validates configuration, resolves secrets per tenant,
invokes actions with timeouts and retries, audits every call and receives vendor webhooks.
This package is the only thing a plugin imports. It carries no runtime dependency on the platform.
Install
npm i @aletheia-dev/plugin-sdk zodzod (v4) is a peer dependency: the schemas in your manifest are validated with your own zod
instance. Node 22 or later; the package ships ESM and CommonJS builds with type declarations.
A plugin
A plugin is a module exporting a Plugin built with definePlugin, whose manifest (built with
defineManifest) declares what the plugin needs and what it offers:
import { z } from 'zod';
import { defineManifest, definePlugin } from '@aletheia-dev/plugin-sdk';
export const manifest = defineManifest({
name: '@acme/plugin-vendor', // the npm package name; also the key in the catalogue and the API
version: '0.1.0',
description: 'Screens names against Vendor.',
capabilities: ['sanctions.screen'], // tags rules and workflows look plugins up by
configSchema: z.object({ threshold: z.number().min(0).max(1).default(0.8) }),
secrets: ['apiKey'], // resolved per tenant by the platform, never stored in config
actions: {
screen: {
description: 'Screens one name.',
input: z.object({ name: z.string().min(1) }),
output: z.object({ hit: z.boolean(), score: z.number() }),
timeoutMs: 5_000,
idempotent: true,
retry: { maxAttempts: 3, backoffMs: 200 },
},
},
});
export default definePlugin({
manifest,
async onInit(ctx) {
// Optional: runs once per tenant context; throwing surfaces at worker start.
},
async invoke(action, input, ctx) {
// `input` has already been validated against the action's input schema.
const res = await ctx.fetch('https://vendor.example/screen', {
method: 'POST',
headers: { authorization: `Bearer ${ctx.secrets.apiKey}` },
body: JSON.stringify(input),
signal: ctx.signal, // aborted at the action deadline
});
return res.json(); // validated against the output schema by the platform
},
});The manifest
| Field | Purpose |
| ---------------------------------------------- | ----------------------------------------------------------------------------------- |
| name | npm package name; the catalogue, the API and tenant configuration key off it. |
| category, vendor, docsUrl, pricingNote | Optional details the console's catalogue shows (docsUrl must be https). |
| capabilities | Free-form tags such as sanctions.screen; the platform finds plugins by them. |
| configSchema | zod schema for the tenant's non-secret configuration; defaults apply on every call. |
| secrets | Names of secrets; each tenant maps them to secret references. |
| actions | Named, typed operations. Input and output are validated on every call. |
Per action: timeoutMs bounds one attempt (default 30 s; ctx.signal aborts on expiry),
retry is applied only when it is safe (the action is idempotent or the caller supplied an
idempotency key) and async marks an action that completes through a webhook.
PluginManifestSchema validates the data parts of a manifest at runtime.
Use ActionInput<M, K>, ActionOutput<M, K> and PluginConfig<M> to type the body of invoke
from the manifest rather than repeating the shapes.
The context
Every call receives a PluginContext:
tenantId,config(parsed throughconfigSchema) andsecrets(resolved values keyed by secret name);logger, a structured logger with the plugin and tenant already bound, andfetch— use it, never the global;signal, aborted when the action's deadline passes; pass it tofetch;idempotencyKey, present when the caller identifies the logical call (workflow run + step);callbackUrl, where the vendor must send webhooks for this plugin (see below);documents, read access to the tenant's documents when the deployment has object storage.
Synchronous and asynchronous actions
A synchronous action returns its result from invoke. An asynchronous action starts work at the
vendor and completes later through a webhook. Declare it with async and return
pending(externalId):
import { pending } from '@aletheia-dev/plugin-sdk';
actions: {
verify: {
input: z.object({ documentId: z.string() }),
output: z.object({ verdict: z.enum(['pass', 'fail']) }),
async: { callbackTimeoutSeconds: 3_600 }, // at most 7 days
},
},
async invoke(action, input, ctx) {
const session = await startVendorSession(input, ctx);
return pending(session.id); // the vendor's id for the session; webhooks must carry it back
}The platform records the pending call, parks the workflow run and waits up to
callbackTimeoutSeconds for a webhook reporting that externalId; on timeout the step fails
with a rule-visible CallbackTimeout. isPending(value) recognises the marker.
Webhooks
A plugin with asynchronous actions implements handleWebhook(request, ctx). It receives the raw
request — method, lower-cased headers, rawBody as bytes and query — plus the tenant's
context, verifies the signature and returns a WebhookEvent, or null to ignore the request:
import { WebhookRejectedError, verifyHmacSha256 } from '@aletheia-dev/plugin-sdk';
async handleWebhook(request, ctx) {
const signature = request.headers['x-vendor-signature'] ?? '';
if (!verifyHmacSha256(ctx.secrets.webhookSecret, request.rawBody, signature)) {
throw new WebhookRejectedError('bad signature'); // HTTP 401
}
const body = JSON.parse(Buffer.from(request.rawBody).toString('utf8'));
if (body.type !== 'session.completed') return null; // ignored, HTTP 202
return {
externalId: body.sessionId,
eventId: body.id, // de-duplicated per tenant
status: body.ok ? 'completed' : 'failed',
output: body.ok ? { verdict: body.verdict } : undefined, // validated against the output schema
error: body.ok ? undefined : body.reason,
};
}The ?externalId= convention
The platform has to know the tenant before it can build a context, and the tenant's secrets are
what the signature check needs. The external id breaks that cycle, so webhook URLs carry it:
when you register the callback with the vendor, append it to ctx.callbackUrl:
const url = `${ctx.callbackUrl}?externalId=${encodeURIComponent(externalId)}`;The platform resolves the tenant from the id, builds the context and calls handleWebhook.
webhookExternalId for dashboard-level webhook URLs
Some vendors take one webhook URL per account and never echo a per-session query string. Such a
plugin implements webhookExternalId(request), a pure, secret-free extractor that decodes the id
from the body or headers (or returns null). It only selects the tenant; handleWebhook still
verifies the signature with that tenant's secret, so a forged id buys nothing but a 401.
HMAC helpers
verifyHmacSha256(secret, rawBody, signature, encoding?) is a constant-time check of a hex (or
base64) HMAC-SHA256 over the raw body; signHmacSha256(secret, rawBody, encoding?) produces one.
Vendors that sign differently (timestamped payloads, asymmetric keys) implement their own check
and should still compare in constant time.
Reading documents
Document checks receive a documentId in their input and read the bytes through the context:
async invoke(action, input, ctx) {
if (!ctx.documents) throw new Error('document storage is not configured');
const doc = await ctx.documents.read(input.documentId);
// doc: { id, fileName, contentType, sizeBytes, bytes: Uint8Array }
}ctx.documents is present only when the deployment has object storage, so check before relying
on it. Only the tenant's own documents with status clean can be read; the bytes are the
platform's post-processing copy (type sniffed, size checked, images re-encoded, PDFs checked),
never the raw upload.
Logging
ctx.logger has debug, info, warn and error (pino-style: an optional object first, then
the message) and child(bindings). The plugin name and tenant are already bound. Log vendor
request ids and outcomes, never secrets or document bytes. noopLogger is a silent
implementation for tests, and the Logger type lets you accept any compatible logger.
Testing locally
@aletheia-dev/plugin-sdk/testing reproduces what the platform does around a plugin, so unit
tests need no hand-built context or fakes. It has no dependency on the platform and works with
any test runner.
import { WebhookRejectedError } from '@aletheia-dev/plugin-sdk';
import {
assertConformance,
createTestContext,
documentHandle,
webhookRequest,
} from '@aletheia-dev/plugin-sdk/testing';
import plugin, { SIGNATURE_HEADER, manifest } from './index.js';
const secrets = { apiKey: 'test', webhookSecret: 'wh' };
// A context built like the runtime builds one: config parsed through configSchema (defaults
// apply, bad config throws), declared secrets required, logger and fetch recording, documents
// in memory. The default fetch answers 404; pass a handler to play the vendor.
const ctx = createTestContext(manifest, {
config: { threshold: 0.9 },
secrets,
fetch: () => new Response(JSON.stringify({ matches: [] })),
documents: [documentHandle({ id: 'doc-1', text: '%PDF-1.4', contentType: 'application/pdf' })],
});
await plugin.invoke('screen', { name: 'Someone' }, ctx);
ctx.fetch.calls[0]?.headers.authorization; // 'Bearer test'
ctx.logger.entries; // [{ level, message, data, bindings }]
// The request the API would hand handleWebhook, signed with signHmacSha256 into the header.
const request = webhookRequest(plugin, {
body: { id: 'evt-1', externalId: 'ext-1', ok: true },
secret: 'wh',
headerName: SIGNATURE_HEADER,
});
await plugin.handleWebhook!(request, ctx);
await expect(
plugin.handleWebhook!(
webhookRequest(plugin, { body: {}, secret: 'wrong', headerName: SIGNATURE_HEADER }),
ctx,
),
).rejects.toThrow(WebhookRejectedError);
// The contract checks the runtime applies: manifest, schemas, unknown action, outputs for your
// sample inputs, pending() and the webhook round trip for asynchronous actions.
await assertConformance(plugin, {
context: { secrets },
samples: [
{ action: 'screen', input: { name: 'Someone' } },
{
action: 'screenAsync',
input: { name: 'Someone' },
webhook: (externalId) =>
webhookRequest(plugin, {
body: { id: 'evt-2', externalId, ok: true },
secret: 'wh',
headerName: SIGNATURE_HEADER,
}),
},
],
});conformance returns the same result ({ ok, checks, failures }) without throwing. The
authoring guide
builds a plugin and its tests step by step, and the
reference
lists every option of the helpers.
The repository's example plugins are the reference implementations:
examples/plugins/acme-screeningis the authoring guide's plugin, built outside the monorepo against the published SDK;plugins/mock-sanctionshas a synchronous action, an asynchronous twin and a signed webhook, with tests;plugins/doc-verify-mockreads a document throughctx.documentsand fires its own signed webhook;plugins/opensanctionsandplugins/sumsubare real vendors, includingwebhookExternalIdfor a dashboard-level webhook URL.
To run a plugin against the platform, add it to the catalogue and configure it for a tenant: Vendor plugins covers the catalogue and tenant configuration, and the reference input mapping from workflows and rules and what the platform does with each webhook outcome.
Versioning
The SDK follows semver on its own cadence, independent of the platform.
While the major version is 0, a minor release may change the contract (additive changes are
the norm; anything that requires plugin changes is called out in the
changelog).
From 1.0 onwards, breaking changes to the contract are major releases.
License
Apache-2.0
