9.9 KiB
wire-notification-channels
Package: @suaveplan/api-plugin-notifications
Status: PROPOSED
Type: feature
Spec delta: ./specs/api-plugin-notifications/spec.md
Why
The initial migration (add-api-plugin-notifications, archived
2026-05-19) explicitly scoped out four classes of delivery wiring
because their dependencies had not yet landed on genesis. Quote from
the archived proposal (openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md
L97-L138, ### Excluded from this migration):
- FCM / Web Push / Email / SMS / Slack channels — depend on
@suaveplan/notification-channel,@suaveplan/emailer, etc. - Audit-log emission, idempotency, rate-limit middleware — depend on
@suaveplan/api-plugin-audit,@suaveplan/idempotency,@suaveplan/api-plugin-rate-limit. - Delivery tracking — depends on
@suaveplan/delivery-tracking.
Three of those deps have since shipped on genesis:
@suaveplan/notification-channel (267 tests, full router + transports),
@suaveplan/emailer (136 tests, three transports), and
@suaveplan/delivery-tracking (71 tests, channel-agnostic event
tracker). @suaveplan/idempotency was already shipped at the time of
the initial migration. Now that the deps exist, this change recovers
the dispatcher slice of the excluded scope so the plugin can route
a notification across multiple channels instead of being APN-only at
the route layer. Audit and rate-limit middleware composition remain
host-app concerns (the original proposal documented them as such) and
are not in scope here.
This is a MODIFIED-Requirements follow-up — the package
(@suaveplan/api-plugin-notifications) already shipped at commit
eca398680 and is archived. No new package is created; the existing
package gains a thin dispatcher layer on top of its APN service.
What changes
- ADDED:
NotificationDispatcherclass — wrapsnotification-channel'sNotificationRouterand exposes a singledispatch(input)surface that fans out to the registered channels with delivery-tracking emission. - ADDED:
createNotificationDispatcher(options)factory — builds a dispatcher pre-wired with the APN and email channels (when the caller supplies anApnService/EmailService), an optional delivery-event store, an optional idempotency guard, and an injectable clock. - ADDED:
ApnDeliveryChannelclass — implementsnotification-channel'sDeliveryChannelinterface against the in-packageApnService. Registered under channel nameapn. Maps the APN payload shape from a genericNotification.body+metadata.deviceToken(ormetadata.deviceTokensfor fan-out). - ADDED:
EmailDeliveryChannelclass — implementsDeliveryChannelagainst@suaveplan/emailer'sEmailService. Registered under channel nameemail. MapsNotification.recipient→EmailMessage.to,Notification.subject→subject,Notification.body→text(HTML body whenmetadata.html === true). - ADDED:
DispatchInput/DispatchResulttypes — Zod-validated at the public boundary; channel selection mirrorsNotificationInput.channeland falls back to the dispatcher's default channel. - ADDED:
DispatcherIdempotencyAdapterinterface — narrow contract (execute<T>(key, fn)) that lets callers plug@suaveplan/idempotency'sIdempotencyGuard(or any equivalent) without the package taking a hard runtime dep on the guard. Default is the in-packageNoopIdempotencyAdapterthat just calls the closure. - ADDED:
NoopIdempotencyAdapter— the default implementation that executes the closure without any dedup. Exported so consumers can reuse it in tests. - ADDED:
NOTIFICATIONS_PLUGIN_CODESentriesDISPATCH_FAILED,DISPATCH_CHANNEL_UNAVAILABLE,DISPATCH_INPUT_INVALID,EMAIL_SERVICE_UNAVAILABLE— surfaced from the new dispatcher / email channel. - ADDED:
notificationsDispatchedTotalandnotificationsDispatchFailedTotalOTel counters ingetNotificationsInstruments(). - ADDED:
services/registry-augmentation.tsslotsnotificationDispatcher(NotificationDispatcher) andemailService(EmailService) — both optional, registered only when the caller supplies them through new plugin options. - ADDED:
NotificationsPluginOptionsSchemaextends withenableDispatcher: boolean (default false),defaultChannel(NotificationChannelfrom notification-channel, defaultapn), and a nesteddeliveryblock (store,tenantId,idempotency.adapter,idempotency.keyFrom(input)). - MODIFIED:
createNotificationsPluginonInit— when the host injects anEmailServicevia the newservices.emailServiceoption, register it underemailService. WhenenableDispatcheris on, instantiate aNotificationDispatcherfrom the configured channels (apnifapnServiceis present,emailifemailServiceis present) and register it undernotificationDispatcher.onCloseclears the dispatcher too. - ADDED: Subpath exports under
./dispatcher/notification-dispatcher,./channels/apn-channel,./channels/email-channel,./schemas/dispatch-schemas.
Out of scope
- Rewriting the existing route layer to call the dispatcher. The
routes still call
apnServicedirectly — adding a dispatcher surface is additive and consumers opt in. (Cross-cutting route refactor is a separate change.) - FCM / Web Push / SMS / Slack delivery channels — no upstream
transport exists on genesis yet
(
@suaveplan/sms-providers,@suaveplan/slack-clientremain pending). Each lands in a follow-up change once its transport package ships. - Audit-log middleware composition — host-app concern per the archived proposal (L135-L138). Not modified here.
- Rate-limit middleware composition — same. Hosts compose
@suaveplan/api-plugin-rate-limitat the app layer. - Schema migrations or queue/history tables — the slimmed schema from the initial migration is unchanged.
Source mapping
This change derives from
../suaveplan/enterprise/api-plugin-notifications.md "Required
enterprise-quality improvements" (the now-unblocked rows) and from
the archived genesis proposal at
openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md.
| Requirement | Source lines | Notes |
|---|---|---|
| Multi-channel dispatcher routing notifications to email/push/sms/webhook | archived proposal L113-L117 (### Excluded — FCM / Web Push / Email / SMS / Slack channels) |
recovered now that @suaveplan/notification-channel ships |
Email channel via @suaveplan/emailer |
archived proposal L113-L117 (same row) | concrete email channel binding |
| Delivery-tracking integration | archived proposal L124-L130 (Preference center, … delivery tracking, …) |
wired via @suaveplan/delivery-tracking's DeliveryEventStore |
| Optional idempotency on dispatch | archived proposal L135-L138 (Audit-log emission, idempotency, rate-limit) |
adapter contract so the package never hard-deps on a guard |
| APN delivery as one channel among several | enterprise/api-plugin-notifications.md "Cross-channel routing" section; archived proposal L34-L33 | wraps existing ApnService as a DeliveryChannel |
Requirements with no source line
| Requirement | Justification |
|---|---|
DispatcherIdempotencyAdapter interface (instead of a hard IdempotencyGuard dep) |
Genesis convention — keep api-plugin-notifications from gaining a runtime dep on @suaveplan/idempotency. Callers that want dedup wire their own guard through the adapter. |
notificationsDispatchedTotal + notificationsDispatchFailedTotal OTel counters |
Genesis-wide observability policy (CLAUDE.md "Quality", AGENTS.md §13) — every new code path emits a counter. |
enableDispatcher opt-in flag |
Backwards compatibility — the existing routes/services keep working unchanged when the flag is off. |
Verification approach
src/dispatcher/notification-dispatcher.test.ts— dispatcher surface end-to-end: success, channel-disabled, transport failure, delivery-tracking emission, idempotency dedup, default-channel fallback, multiple-channel fan-out, OTel emission with realMeterProvider.src/channels/apn-channel.test.ts— APNDeliveryChannelagainst the in-packageMemoryApnTransport-backedApnService: success path, per-device failure, missing-token rejection, bulk recipient fan-out viametadata.deviceTokens.src/channels/email-channel.test.ts— EmailDeliveryChannelagainst aDummyTransport-backedEmailService: success, missing subject, transport failure →DeliveryResult.error.src/schemas/dispatch-schemas.test.ts— Zod boundary tests forDispatchInputvalidation.src/schemas/options.test.ts— MODIFIED to cover the newenableDispatcher/defaultChannel/deliveryfields.src/plugin/plugin.test.ts— MODIFIED to cover the dispatcher registration path (onInitregistersnotificationDispatcherwhenenableDispatcheris on;onCloseclears it).src/api-plugin-notifications.spec-conformance.test.ts— MODIFIED to pin every new public symbol exported by the package barrel.src/error-codes/error-codes.test.ts— MODIFIED to pin reachability of the four new codes.src/otel/instruments.test.ts— MODIFIED to pin the two new counters.
Migration impact
None to existing consumers. The dispatcher and the new channels are opt-in:
createNotificationsPlugin()with no options behaves identically (no dispatcher registered, routes still callapnServicedirectly).- Existing imports (
NotificationRepository,ApnService,createPushRoutes, etc.) are unchanged.
Affected dependents
None on genesis today —
@suaveplan/api-plugin-notifications had no downstream consumers at
the time of writing. Future consumers that opt in to the dispatcher
gain multi-channel routing without changing call sites for the
existing routes.
Impact
Additive — no removed surface, no signature changes to existing public symbols.