npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 zod

zod (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 through configSchema) and secrets (resolved values keyed by secret name);
  • logger, a structured logger with the plugin and tenant already bound, and fetch — use it, never the global;
  • signal, aborted when the action's deadline passes; pass it to fetch;
  • 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:

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