@agentoria/notifykit
v0.3.1
Published
Notification delivery for TypeScript apps — bot webhooks, email, web push, SMS and an inbox behind one contract, with a per-recipient category × channel matrix.
Maintainers
Readme
@agentoria/notifykit
Notification delivery for TypeScript apps. Bot webhooks, email, browser push, SMS and an in-app inbox behind one contract — plus the per-recipient category × channel matrix that decides which of them a message may use.
Runs on Node, Cloudflare Workers, Deno and the browser: fetch and WebCrypto only, no Buffer, no
node:crypto, no dependencies.
npm i @agentoria/notifykitWhy the obvious contract isn't the contract
The tempting model is "a channel is a pure function from a message to one HTTP request". It's a good model — a provider becomes ~20 lines and is testable without a network — and it fits exactly one of the five channel families:
| Family | Destination | Payload | Writes back? | |---|---|---|---| | Bot webhook (Feishu, Telegram, Slack, …) | one URL the user pasted | free-form JSON | no | | Email | N addresses, chunked (50/call) | subject + text + html | no | | Browser push | N device subscriptions your app stores | encrypted, VAPID-signed | yes — 404/410 means delete it | | SMS | one phone number | a pre-approved template code — free text is not sendable in CN | no | | Inbox | your own database | a row | it is a write |
Two capabilities break the single-request model: fan-out over destinations the host owns, and
write-back. So plan takes the destinations and returns requests, plural, and dead endpoints
come back as stale for you to delete — a transport never learns what a database is.
Three layers
import { render, deliver, feishu } from "@agentoria/notifykit";
const rendered = render(
{ category: "failed", title: "Build failed", body: "3 tests red", url: "https://app/run/42" },
feishu.capabilities,
);
const result = await deliver(feishu, { webhookUrl: process.env.FEISHU_HOOK! }, rendered);
if (result.stale?.length) await dropSubscriptions(result.stale);
if (!result.ok && result.retryable) await myQueue.retryLater();render— pure and capability-aware. Truncation and escaping are decided once per transport instead of re-derived by every provider: Server酱's 32-character title is a declared capability, not a surprise from the API. A transport with a URL field gets the link raw and a body that doesn't repeat it; an inline one gets it appended, with the body trimmed to make room — a verbose body must not be what costs the reader the actionable part.plan— pure, returns requests. Feishu and DingTalk HMAC here; that's still not I/O, so every transport is unit-testable without a fetch mock. The clock, the nonce source and the phone normaliser arrive as aPlanContextparameter rather than being read from globals, so a fixed input has a fixed output and asserting on a signature needs no fake timers:await dingtalk.plan(cfg, rendered, [], planContext({ now: () => 1_767_322_000_000 }));deliver— the only layer that touches the network. It never throws, and it classifies without scheduling:retryablesays whether another attempt could work; when to retry is yours, because you know whether you have a durable queue or a cron loop.
When a channel fans out, any destination succeeding is success. Reaching four of five devices delivered the notification; reporting failure would make you retry and duplicate on the four that worked.
Severity
severity says how loudly, where category says what happened — they vary independently, since
an app can have four categories and only two worth waking someone for.
render({ category: "failed", title: "Build failed", body: "…", severity: "urgent" }, ntfy.capabilities);Transports with a native notion of it map it — Feishu card colour, Discord embed colour, ntfy
priority, Gotify priority, Bark interruption level — and the rest ignore it. urgent is the only
level above the default on purpose: push services grade priority coarsely, and a scale with five
names invites callers to pick one at random. The question is "should this interrupt someone", which
is a yes or a no. The mappings are the conservative reading of each API: ntfy urgent is 4 rather
than 5, because 5 bypasses Do Not Disturb and that is the user's setting to make, not yours.
Channels
feishu · telegram · slack · discord · dingtalk · wecom · ntfy · bark · serverchan ·
gotify · email (Resend) · webpush (RFC 8291 aes128gcm + VAPID) · sms (Aliyun templates) ·
sms_twilio
Each declares its config fields, so your settings UI renders itself:
import { TRANSPORTS, getTransport, validateConfig, redactConfig } from "@agentoria/notifykit";
const fields = getTransport("telegram")!.fields; // → drives the form
validateConfig(telegram, config); // → a message, or null
redactConfig(telegram.fields, stored); // → botToken: "••••CRET"Redaction is an allowlist over the field declarations, not a denylist of key names — a field added later is masked by default, and an undeclared key is dropped rather than echoed.
Adding one
const myBot: Transport = {
id: "mybot",
title: "My bot",
capabilities: { richText: "markdown", link: "inline" },
fields: [{ key: "url", label: "Webhook URL", secret: true, required: true, url: true }],
async plan(cfg, r, dests) {
return [{ destinationId: dests[0]?.id ?? "self", url: cfg.url, method: "POST",
headers: { "content-type": "application/json" }, body: JSON.stringify({ text: r.body }) }];
},
};Two optional hooks exist for what a pure plan can't express:
validate(cfg)— cross-field checks, run before anything is stored.interpret(status, body)— for a provider whose success envelope looks like an error. Bark answers{"code":200}; rather than bend the shared rule around one vendor, that transport judges its own responses.
The matrix
Which categories a recipient accepts on which channels — "marketing may go to SMS and email, nothing else".
import { route, applyPreference, type Category } from "@agentoria/notifykit";
const failures: Category = { id: "failed", title: "Failures", policy: "transactional" };
const offers: Category = { id: "promo", title: "Offers", policy: "marketing" };
route(matrix, failures, { available: ["inbox", "email", "feishu"] }); // → ["inbox"] by default
route(matrix, offers, { available: ["inbox", "email", "sms"] }); // → [] until consentedThree things about it are not obvious, and each is a bug the naive version ships with:
- Defaults are a function of
(category, channel), not a constant. Account events are opt-in — don't email someone who never asked. Operator broadcasts are opt-out — a maintenance notice reaches everyone unless they left. One global default cannot express both. - Storage is sparse. Only cells the recipient explicitly toggled are stored (
toRows/fromRows); the rest resolve through the defaults, so defaults can change without a migration. - Marketing is not just another category. Consent for marketing over SMS and email is a legal
requirement in several jurisdictions, so a
marketingcategory is refused on any channel without an explicit stored opt-in — whatever the defaults say — while an explicitfalseis kept rather than pruned, because silence and refusal differ when the question is legal rather than a preference. This is the one rule here deliberately not configurable.
UI: headless first
@agentoria/notifykit/headless has the logic with no framework and no markup — which fields to show,
when a secret may be left blank (editing keeps the stored value, creating cannot), whether a cell is
explicitly set or merely defaulted, which cells are consent rather than preference. Those are the
rules that have bugs in them.
import { initChannelForm, channelFormStatus, buildMatrixGrid, toggleCell } from "@agentoria/notifykit/headless";@agentoria/notifykit/react is ~200 lines of unstyled markup over it, taking every class name
from props, so it drops into your design system without a fork:
import { ChannelForm, PreferenceMatrix } from "@agentoria/notifykit/react";
<ChannelForm transports={TRANSPORTS} onSubmit={save} classes={{ input: "my-input", submit: "my-btn" }} />
<PreferenceMatrix matrix={matrix} categories={categories} channels={channels} onChange={setMatrix} />What it doesn't own
Storage · tenancy (a recipient is an opaque id — an org in one app, a user in another) · scheduling
and retry timing · translation catalogs · the inbox implementation (an InboxSink interface, since
the table is yours).
Its whole surface is: given a message, a category, some destinations and a matrix — which channels, and what HTTP requests.
Browser push
The subscriptions live in your tables; hand them in as destinations.
import { generateVapidKeys, subscriptionDestination, webpush, deliver, render } from "@agentoria/notifykit";
const { publicKey, privateKey } = await generateVapidKeys(); // once, then store
const result = await deliver(
webpush,
{ vapidPublicKey: publicKey, vapidPrivateKey: privateKey, vapidSubject: "mailto:[email protected]" },
render(message, webpush.capabilities),
rows.map(subscriptionDestination),
);
await Promise.all((result.stale ?? []).map(deleteSubscription)); // 404/410 → goneEndpoints are checked against the known push services before anything is sent: a stored subscription endpoint is attacker-influenced data, and refusing anything else keeps a row from becoming an SSRF primitive years later.
SMS
Chinese carriers only deliver pre-approved templates, so the transport maps a category to a
template code and fills it from message.data:
{ signName: "Acme", templates: "promo=SMS_123456\nsecurity=SMS_654321", … }A category with no mapped template sends nothing rather than something the carrier would reject. Twilio takes free text and is a separate transport rather than a flag on this one.
Numbers, and which carrier gets them
Numbers are canonicalised to E.164 before anything is sent — otherwise 13800138000 and
+8613800138000 are one person counted as two destinations, and every stale/sent tally keyed on
the id is quietly wrong. Aliyun still receives the bare domestic number it requires; the
destinationId stays canonical, because you track people rather than one carrier's URL quirk.
The built-in normaliser is a shape check, not libphonenumber: it cannot tell you a well-formed number is unassigned or a landline. If that matters, inject the real thing rather than have this package grow an 82 KB metadata table for two of its fourteen transports:
import parsePhoneNumber from "libphonenumber-js";
deliver(sms, cfg, rendered, dests, {
context: { normalizePhone: (s) => parsePhoneNumber(s, "CN")?.number ?? null },
});Routing between carriers is a separate function on purpose — no library can decide it, because it depends on the contracts you signed:
import { partitionPhones } from "@agentoria/notifykit";
const { sms, sms_twilio, unroutable } = partitionPhones(phones, { aliyun: true, twilio: true });+86 prefers Aliyun, everything else prefers Twilio, and each falls back to the other — except
into mainland China, which is refused unless you pass allowTwilioToChina. Delivery there over an
international carrier needs registered templates and sender ids, so the usual result is a silent
non-delivery you still pay for. Those numbers come back in unroutable instead, where you can see
them.
License
MIT
