easy-pingv0.7.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Plugins / Writing a plugin

Edit this page

Writing a plugin

The surface preferences, digests and push are all built on — hooks, scoped storage, and a channel of your own.

The plugin shape

definePlugin({
  id: "my-plugin",
  dependsOn: ["preferences"],   // optional — resolves init order
  schema: { ... },              // tables this plugin owns
  hooks: { ... },
  channels: ["sms"],            // channels this plugin can deliver
  routes: [ ... ],
  init: (ctx) => { /* runs once, before any hook or route */ },
});

definePlugin is an identity function — it exists purely so TypeScript infers the plugin's own id and schema types without you writing them out twice.

Hooks, and the fail-open/closed rule

hooks: {
  prepare,          // once per send(), for bulk loading — avoids an N+1 per recipient
  beforeSend,       // can veto or modify the send
  resolveChannels,  // decides which channels actually fire
  afterSend,        // once the rows are committed — the only hook that sees notification ids
  deliver,          // implements delivery for a channel this plugin declares
  afterDeliver,     // observes the outcome of a delivery attempt
}
This is the one to get right

beforeSend and resolveChannels fail closed — a thrown error blocks the send. afterSend and afterDeliver fail open — a thrown error is logged and delivery proceeds anyway. Get this backwards and either a bug in an unrelated plugin blocks every notification in the app, or a broken observability hook silently hides real delivery failures. The asymmetry is intentional: a decision hook's whole job is to be trusted; an observation hook's failure should never be allowed to look like a successful delivery.

Scoped storage

A plugin declares its own tables in schema() and reaches them through a store that validates every table and column against that declaration — a plugin can never accidentally (or otherwise) read the core notification table it didn't declare:

init: (ctx) => {
  store = ctx.store;
  // ctx also carries: sign, logger, notificationTypes, getRecipients, send
},

// later, inside a hook:
await store.insert("myTable", [{ id, userId, ... }]);
await store.find("myTable", { userId });

The store checks values as well as names: a where-clause value must be a scalar or a single recognised operator (in, lt, lte, gt, gte, not), so a request body forwarded straight into a query cannot smuggle a Mongo operator through. Validate bodies in the route anyway — readJsonBody(request) from easy-ping parses with a size cap — and check typeof before querying.

A plugin never receives secret. ctx.sign({ uid, purpose, data?, ttlSeconds? }) mints a token, and only for a purpose one of the plugin's own signed routes declares; anything else throws. ctx.notificationTypes lists the configured notification names, for routes that store rows per type and should refuse ones that do not exist.

This is also how the same plugin code runs unmodified on Postgres and MongoDB: the store translates field names and JSON serialization per adapter, so a plugin author never writes dialect-specific code. See Writing an adapter for the other side of that boundary.

Adding a channel

A plugin can carry a channel the core doesn't know about by declaring it in channels and implementing deliver — this is exactly how push works, and the pattern for sms or slack:

channels: ["sms"],
hooks: {
  deliver: async ({ channel, notification, recipient }) => {
    if (channel !== "sms") return { result: "skipped" };
    // call your SMS provider, return { result: "sent" } or
    // { result: "failed", error, retryable }
  },
},

Routes and scope

routes: [
  {
    path: "/my-plugin/thing",
    method: "POST",
    scope: { type: "user" },  // | { type: "machine" } | { type: "signed", purpose }
                              // | { type: "custom", justification }
    handler: async ({ request, userId }) => Response.json({ ok: true }),
  },
],

user-scoped POSTs get the handler's CSRF checks for free; machine routes are guarded by machineSecret (or cron.secret); signed routes receive verified claims. A custom route does its own auth and must say why — the justification is printed in a startup warning and in notify.listRoutes(), so the exception stays visible.

See Route handler for what each scope actually enforces before your handler runs.