Docs / notification-service.md · mirrored from the repo
Notification Service — Architecture & Design
Status: design / planned. Phase 1 (in-app SSE) is the first implementation target. Phases 2–3 are the extension roadmap — adapters slot in behind the same port without touching the schema or the core delivery logic.
1. Goals
- Phase 1 (now): In-app push notifications for back-office (BO) users via SSE.
The bell icon stub already exists in
apps/console/src/components/Header.tsx. - Phase 2: Email (Resend) + Slack webhook for BO ops alerts.
- Phase 3: SMS + Web Push for consumer-app users (same port, new adapters, no schema migration).
- Reusable across both trust domains: BO users (Cloudflare Access) and consumer users
(Better Auth on Workers). Differentiated only by
recipient_type.
2. Architecture: Two Layers
Layer 1 — NotificationPort (packages/notification)
A provider-agnostic interface, mirroring the ModelPort/MemoryPort pattern from
AGENT-KERNEL.md. Every delivery channel is an adapter behind
this port.
// packages/notification/src/types.ts
export type NotificationChannel = 'in_app' | 'email' | 'sms' | 'slack' | 'web_push';
export type NotificationEventType =
| 'agent.run.failed'
| 'agent.run.completed'
| 'agent.mode.changed'
| 'approval.required'
| 'approval.submitted';
// extend as new system events emerge
export interface Notification {
id: string;
recipient_id: string; // email (BO) or consumer user id
recipient_type: 'bo_user' | 'consumer_user';
channel: NotificationChannel;
event_type: NotificationEventType | string;
title: string;
body: string;
data?: Record<string, unknown>; // structured payload for UI deep-linking
created_at: string;
read_at?: string | null; // in_app only
delivered_at?: string | null;
}
export interface NotificationPort {
send(n: Omit<Notification, 'id' | 'created_at'>): Promise<void>;
}
The InAppAdapter writes to D1 notifications and is the only adapter in Phase 1.
Future adapters (ResendAdapter, SlackAdapter, TwilioAdapter) implement the same
interface and are selected by the service layer based on notification_preferences.
Layer 2 — Delivery Channels (phased)
| Phase | Channel | Adapter / Provider | Scope |
|---|---|---|---|
| 1 | In-app | D1 store + SSE stream | BO users |
| 2 | Resend (CF Workers SDK, no TCP restriction) | BO + consumer | |
| 2 | Slack | Incoming webhook | BO ops alerts |
| 3 | SMS | Twilio or AWS SNS | Consumer users |
| 3 | Web Push | Standard Web Push API / CF Push | Consumer app |
Why Resend over SendGrid/SES: Resend has a first-class CF Workers SDK that doesn’t require TCP sockets (which Workers can’t open). Consistent with “Cloudflare by default.”
3. D1 Schema (services/console-api/migrations/0007_notifications.sql)
-- Notification inbox — source of truth for all channels; read-state for in_app.
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
recipient_id TEXT NOT NULL,
recipient_type TEXT NOT NULL DEFAULT 'bo_user'
CHECK (recipient_type IN ('bo_user', 'consumer_user')),
channel TEXT NOT NULL DEFAULT 'in_app',
event_type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
data TEXT, -- JSON
read_at TEXT, -- NULL = unread (in_app only)
delivered_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notif_recipient ON notifications(recipient_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(recipient_id, read_at)
WHERE read_at IS NULL;
-- Per-user channel preferences (opt-in/opt-out per event_type × channel).
-- Default = in_app enabled for all event types (no row needed to enable).
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
);
4. In-App Delivery: SSE Stream (Phase 1)
No Durable Objects needed at BO scale. The approach is a polling SSE stream:
- Client opens
GET /api/notifications/stream(requiresnotification:readpermission, resolved viawithIdentity()). - Worker opens a
ReadableStreamand polls D1 for new unread rows every ~3 s, sending each asdata: <json>\n\n. c.executionCtx.waitUntil()keeps the Worker alive while the stream is open.
API routes (services/console-api/src/index.ts)
GET /api/notifications/stream → SSE, pushes new notifications in real time
GET /api/notifications → paginated list, unread-first (query: limit, before)
POST /api/notifications/:id/read → mark one read
POST /api/notifications/read-all → mark all read for the authenticated user
Frontend (apps/console/src/)
| File | Purpose |
|---|---|
hooks/useNotifications.ts | EventSource wrapper; maintains notifications[] + unreadCount in state |
components/NotificationBell.tsx | Bell icon + unread badge + dropdown list |
components/NotificationBell.css | Styles (follows the glass-panel / CSS variable conventions) |
components/Header.tsx | Replace stub <button className="icon-btn"><Bell /></button> with <NotificationBell> |
5. Event Emission
A notifyBOUsers(db, event_type, title, body, data?) helper (in console-api/src/index.ts):
- Queries active BO users from
users(status = ‘active’). - Respects
notification_preferences(default: in_app on for all). - Batch-inserts into
notifications. - Runs inside
c.executionCtx.waitUntil()— never blocks the HTTP response.
Natural trigger points
| Trigger | Where in index.ts | event_type |
|---|---|---|
run.finished + status: 'failed' | applyRunEvent() | agent.run.failed |
run.finished + status: 'completed' | applyRunEvent() | agent.run.completed |
PUT /api/agents/:id/control/mode | mode-change handler | agent.mode.changed |
POST /api/agents/:id/events/:id/approve | approve handler | approval.submitted |
| Control DO escalation (future) | Control DO | approval.required |
6. RBAC
Add notification:read to packages/rbac/src/permissions.ts. All roles receive it
(mirrors the audit:read pattern — every authenticated user can read their own
notifications).
Mirror the addition in the seed section of migrations/0001_rbac.sql (comment only;
D1 is the runtime authority).
7. Consumer-User Extensibility
The recipient_type: 'bo_user' | 'consumer_user' column and the NotificationPort
interface are the extension seams. When consumer auth (Better Auth on Workers) ships:
- Pass the consumer
user_idasrecipient_idwithrecipient_type: 'consumer_user'. - Wire
web_push/smsadapters behind the same port. - No schema migration needed.
8. Secrets (Phase 2+)
Provider credentials go into the CF Secrets Store (already used for Langfuse, resolved
via the readSecret() helper in console-api). No new pattern — just new entries:
RESEND_API_KEY → Secrets Store
SLACK_WEBHOOK_URL → Secrets Store (or KV for per-channel config at runtime)
TWILIO_AUTH_TOKEN → Secrets Store (Phase 3)
9. Files Summary
| File | Status | Change |
|---|---|---|
packages/notification/src/types.ts | New | NotificationPort interface + types |
packages/notification/src/index.ts | New | Barrel export |
packages/notification/package.json | New | @namaste-ji/notification workspace package |
packages/notification/tsconfig.json | New | Standard TS config |
services/console-api/migrations/0007_notifications.sql | New | D1 schema |
services/console-api/src/index.ts | Modify | Notification routes + helper + emit calls |
packages/rbac/src/permissions.ts | Modify | Add notification:read |
apps/console/src/hooks/useNotifications.ts | New | SSE hook |
apps/console/src/components/NotificationBell.tsx | New | Bell + dropdown |
apps/console/src/components/NotificationBell.css | New | Styles |
apps/console/src/components/Header.tsx | Modify | Wire <NotificationBell> |
10. Verification Checklist
-
docker compose up— stack boots clean - Trigger an agent run →
run.finished(failed) → bell shows badge + notification in dropdown - Mark as read → badge clears
- Flip agent mode →
agent.mode.changednotification appears without page refresh - Confirm D1
notificationsrows viawrangler d1 execute - SSE stream stays open across multiple events (no reconnects)
-
notification:readgating:401without identity,403with wrong role