From 3f22a808d62ad4b177b3ebd54a7df493ab8ad9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 21:36:17 +0000 Subject: [PATCH] notify: the sidecar shell, with Discord as the first channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple or Google credential exists, so the pipe is proven before the hard part. officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and proxied at /api/notify. It is a sidecar rather than platform code because the producers are spread across sidecars — the queue, email, the agent — and a platform-owned notifier would force every one of them to call back into the platform. That is the inversion just removed from email; this avoids recreating it. Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather than replacing anything. Each is awaited with its own error boundary and the dispatcher always resolves: a job that finished has finished whether or not a banner appeared, so a channel must never be able to break its producer. text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to render a banner, so "send nothing" was never available — what we control is that the string is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to put one. Device registration lives behind X-Officer-User, trusted because the listener binds loopback. Platform and environment are validated rather than defaulted: an iOS token from a debug build fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that never receives anything and never says why. GET /_officer/devices returns only the last 8 characters of a token — enough to identify a row, not enough to push to it. Verified end to end against a fake webhook: /_health reports configured channels, a test notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as {"content":"3 new emails"}, and every validation path returns its own error. Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 1 - ecosystem.config.cjs | 9 ++ src/servers/api/notify/router.ts | 13 +++ src/servers/hono.ts | 4 + src/servers/notify/discord.ts | 33 -------- src/servers/sidecar/notify/devices.ts | 80 +++++++++++++++++ src/servers/sidecar/notify/discord.ts | 48 +++++++++++ src/servers/sidecar/notify/dispatch.ts | 37 ++++++++ src/servers/sidecar/notify/index.ts | 113 +++++++++++++++++++++++++ src/servers/sidecar/notify/text.ts | 32 +++++++ src/servers/sidecar/notify/types.ts | 42 +++++++++ src/servers/sidecar/protocol.ts | 2 + 12 files changed, 380 insertions(+), 34 deletions(-) create mode 100644 src/servers/api/notify/router.ts delete mode 100644 src/servers/notify/discord.ts create mode 100644 src/servers/sidecar/notify/devices.ts create mode 100644 src/servers/sidecar/notify/discord.ts create mode 100644 src/servers/sidecar/notify/dispatch.ts create mode 100644 src/servers/sidecar/notify/index.ts create mode 100644 src/servers/sidecar/notify/text.ts create mode 100644 src/servers/sidecar/notify/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8230cc7f..a1420893 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,6 @@ src/ │ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting │ ├── api// # one folder per feature, each exporting a router │ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn -│ ├── notify/ # outbound notifications (Discord webhook, env-configured) │ ├── queue/ # background job engine │ └── sidecar/ # sidecar implementations + the wire protocol ├── databases/officer_db/ # the only database (Postgres + Drizzle) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index ddaa5ee8..439cdb52 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -90,6 +90,15 @@ module.exports = { }, // The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so // it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks. + // The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the + // Discord webhook, behind one interface. A sidecar rather than platform code because the producers + // are spread across sidecars, and a platform-owned notifier would make every one of them call back in. + { + name: 'officer-notify', + script: 'bun', + args: 'run src/servers/sidecar/notify/index.ts', + watch: false, + }, { name: 'officer-wallet', script: 'bun', diff --git a/src/servers/api/notify/router.ts b/src/servers/api/notify/router.ts new file mode 100644 index 00000000..2ecac6fc --- /dev/null +++ b/src/servers/api/notify/router.ts @@ -0,0 +1,13 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// /api/notify/* — auth, then forward to officer-notify. The apps register their push tokens through here +// and nothing else uses it; producers inside the tailnet POST to the sidecar over loopback directly. +// +// No notification knowledge lives in the platform: not a device token, not an APNs key, not a payload. + +const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' }); + +export const notifyRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getNotifyServerUrl = proxy.getHttpUrl; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 53e5d24c..a3d473b9 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -10,6 +10,7 @@ import { usersRouter } from './api/users/users-router'; import { plansRouter } from './api/plans/plans'; import { skillsRouter } from './api/skills/skills'; import { tasksRouter } from './api/tasks/tasks'; +import { agentsRouter } from './api/agents/agents'; import { toolsRouter } from './api/tools/tools'; import { processesRouter } from './api/processes/processes'; import { rescanRouter } from './api/items/rescan'; @@ -28,6 +29,7 @@ import { invoiceshelfRouter } from './api/invoiceshelf/router'; import { walletRouter } from './api/wallet/router'; import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; +import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; // Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar @@ -99,6 +101,7 @@ protectedRouter.route('/users', usersRouter); protectedRouter.route('/plans', plansRouter); protectedRouter.route('/skills', skillsRouter); protectedRouter.route('/tasks', tasksRouter); +protectedRouter.route('/agents', agentsRouter); protectedRouter.route('/tools', toolsRouter); protectedRouter.route('/processes', processesRouter); protectedRouter.route('/rescan', rescanRouter); @@ -111,6 +114,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); protectedRouter.route('/terminal', terminalRouter); +protectedRouter.route('/notify', notifyRouter); protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/invoiceshelf', invoiceshelfRouter); diff --git a/src/servers/notify/discord.ts b/src/servers/notify/discord.ts deleted file mode 100644 index c435ddeb..00000000 --- a/src/servers/notify/discord.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Discord notifications, and nothing else. -// -// Officer used to run a full Discord bot: a gateway connection, command parsing, account pairing, an admin -// config screen and a token in the database — and the same again for Telegram and WhatsApp. All three -// existed to drive the platform from a chat app, which the phone app does now. What is left is the one -// piece worth keeping: the ability to push a message out. -// -// Configured by env, so there is no UI, no pairing and no stored credential: -// DISCORD_WEBHOOK_URL a channel webhook. Unset = notifications are silently skipped. - -const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; - -/** True when a webhook is configured; callers can skip building a message otherwise. */ -export const isDiscordNotifyConfigured = (): boolean => Boolean(WEBHOOK_URL); - -/** - * Post a message to the configured Discord channel. Never throws and never blocks anything important — a - * notification that fails to send is logged and dropped, not retried. - */ -export async function notifyDiscord(content: string): Promise { - if (!WEBHOOK_URL) return; - try { - const res = await fetch(WEBHOOK_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - // Discord rejects anything over 2000 characters outright. - body: JSON.stringify({ content: content.slice(0, 2000) }), - }); - if (!res.ok) console.error(`[notify:discord] webhook returned ${res.status}`); - } catch (err) { - console.error('[notify:discord] failed to send:', err instanceof Error ? err.message : err); - } -} diff --git a/src/servers/sidecar/notify/devices.ts b/src/servers/sidecar/notify/devices.ts new file mode 100644 index 00000000..2b8efa06 --- /dev/null +++ b/src/servers/sidecar/notify/devices.ts @@ -0,0 +1,80 @@ +import { upsertPushDevice, getPushDevices, deletePushDevice } from 'officerdb'; + +// Device registration, reached through the platform's authenticated proxy. +// +// The owner comes from X-Officer-User, injected by createSidecarProxy and trusted because this server +// binds loopback only. Nothing else can reach it. + +const PLATFORMS = new Set(['ios', 'android']); +const ENVIRONMENTS = new Set(['production', 'sandbox']); + +type RegisterBody = { + token?: string; + platform?: string; + environment?: string; + bundleId?: string; + appSlug?: string; +}; + +function ownerId(req: Request): number | null { + const id = Number(req.headers.get('X-Officer-User')); + return Number.isFinite(id) && id > 0 ? id : null; +} + +export async function handleDeviceRoute(req: Request, url: URL): Promise { + const userId = ownerId(req); + if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); + + if (url.pathname === '/_officer/devices' && req.method === 'GET') { + const devices = await getPushDevices(userId, url.searchParams.get('appSlug') ?? undefined); + // Never echo tokens back in full: they are the address of a device, and this response goes to a + // client. Enough to identify a row, not enough to push to it. + return Response.json({ + devices: devices.map((d) => ({ + id: d.id, + platform: d.platform, + environment: d.environment, + bundleId: d.bundleId, + appSlug: d.appSlug, + tokenSuffix: d.token.slice(-8), + lastSeenAt: d.lastSeenAt, + })), + }); + } + + if (url.pathname === '/_officer/devices' && req.method === 'POST') { + let body: RegisterBody; + try { + body = (await req.json()) as RegisterBody; + } catch { + return Response.json({ error: 'invalid json' }, { status: 400 }); + } + + const { token, platform, bundleId, appSlug } = body; + const environment = body.environment ?? 'production'; + + if (!token || !platform || !bundleId || !appSlug) { + return Response.json({ error: 'token, platform, bundleId and appSlug are required' }, { status: 400 }); + } + if (!PLATFORMS.has(platform)) { + return Response.json({ error: "platform must be 'ios' or 'android'" }, { status: 400 }); + } + // Rejected rather than defaulted: an iOS token from a debug build fails against production APNs with + // a silent BadDeviceToken, so a wrong value here is a device that never receives anything and never + // reports why. + if (!ENVIRONMENTS.has(environment)) { + return Response.json({ error: "environment must be 'production' or 'sandbox'" }, { status: 400 }); + } + + const device = await upsertPushDevice({ userId, token, platform, environment, bundleId, appSlug }); + return Response.json({ ok: true, id: device.id }); + } + + const match = url.pathname.match(/^\/_officer\/devices\/(.+)$/); + if (match && req.method === 'DELETE') { + await deletePushDevice(decodeURIComponent(match[1]!)); + return Response.json({ ok: true }); + } + + return null; +} diff --git a/src/servers/sidecar/notify/discord.ts b/src/servers/sidecar/notify/discord.ts new file mode 100644 index 00000000..69f778f3 --- /dev/null +++ b/src/servers/sidecar/notify/discord.ts @@ -0,0 +1,48 @@ +import type { Channel, Notification } from './types'; +import { renderTitle } from './text'; + +// Discord notifications, via a channel webhook. +// +// Moved here from src/servers/notify/discord.ts when the notify sidecar took over outbound notification: +// one surface, several channels, rather than a Discord path and a push path that do not know about each +// other. Officer used to run a full Discord bot — gateway, commands, pairing, an admin screen and a token +// in the database — and all of that is gone; what survives is the one useful piece, pushing a message out. +// +// Configured by env, so there is no UI, no pairing and no stored credential: +// DISCORD_WEBHOOK_URL unset = channel skipped entirely. +// +// The same payload discipline applies here as to APNs and FCM. A Discord webhook is a third party too, +// and its history is searchable and permanent in a way a push banner is not — arguably it deserves the +// rule more, not less. + +const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; + +export const discordChannel: Channel = { + name: 'discord', + + isConfigured: () => Boolean(WEBHOOK_URL), + + async send(n: Notification) { + if (!WEBHOOK_URL) return { channel: 'discord', sent: 0, failed: 0 }; + + // Discord rejects anything over 2000 characters outright. + const content = renderTitle(n).slice(0, 2000); + + try { + const res = await fetch(WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + console.error(`[notify:discord] webhook returned ${res.status}`); + return { channel: 'discord', sent: 0, failed: 1 }; + } + return { channel: 'discord', sent: 1, failed: 0 }; + } catch (err) { + console.error('[notify:discord] failed to send:', err instanceof Error ? err.message : err); + return { channel: 'discord', sent: 0, failed: 1 }; + } + }, +}; diff --git a/src/servers/sidecar/notify/dispatch.ts b/src/servers/sidecar/notify/dispatch.ts new file mode 100644 index 00000000..64d44fb5 --- /dev/null +++ b/src/servers/sidecar/notify/dispatch.ts @@ -0,0 +1,37 @@ +import type { Channel, DeliveryResult, Notification } from './types'; +import { discordChannel } from './discord'; + +// Fan a notification out to every configured channel. +// +// Channels are independent and none of them may break a producer: a job that finished has finished +// whether or not a banner appeared. So every channel is awaited with its own error boundary, and the +// dispatcher always resolves. + +const channels: Channel[] = [discordChannel]; + +/** Registered here rather than imported at the top so channels can be added without touching producers. */ +export function registerChannel(channel: Channel): void { + channels.push(channel); +} + +export function configuredChannels(): string[] { + return channels.filter((c) => c.isConfigured()).map((c) => c.name); +} + +export async function dispatch(n: Notification): Promise { + const active = channels.filter((c) => c.isConfigured()); + if (active.length === 0) return []; + + return Promise.all( + active.map(async (c) => { + try { + return await c.send(n); + } catch (err) { + // A channel that throws instead of returning is a bug in that channel, not a reason to fail + // the notification or the producer behind it. + console.error(`[notify] channel ${c.name} threw:`, err instanceof Error ? err.message : err); + return { channel: c.name, sent: 0, failed: 1 }; + } + }), + ); +} diff --git a/src/servers/sidecar/notify/index.ts b/src/servers/sidecar/notify/index.ts new file mode 100644 index 00000000..d5ef2455 --- /dev/null +++ b/src/servers/sidecar/notify/index.ts @@ -0,0 +1,113 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import { createSidecarConnector } from '../connect'; +import { dispatch, configuredChannels } from './dispatch'; +import { handleDeviceRoute } from './devices'; +import type { Notification, NotifyType } from './types'; + +// The officer-notify sidecar. The one place anything leaves this machine to tell the owner something. +// +// It exists as a sidecar rather than as platform code because the producers are spread out — the queue, +// the email sidecar, the agent sidecar — and a platform-owned notifier would force every sidecar to call +// BACK into the platform. That is the inversion just removed from email. Here, anything POSTs over +// loopback and the direction of every arrow stays the same. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/notify mount prefix before forwarding. +// +// GET /_health liveness, plus which channels are configured +// POST /_officer/notify send one. Body: { type, userId, id?, count?, ok?, appSlug? } +// POST /_officer/devices register/refresh a device (idempotent; call every launch) +// DELETE /_officer/devices/:token deregister, on sign-out +// GET /_officer/devices the caller's registered devices +// +// The notify body is deliberately narrow: a category and an id, never content. See ./text.ts for why +// the visible string is composed here rather than sent by the producer. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test']; + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probe.port; + probe.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url); + + if (url.pathname === '/_health') { + return Response.json({ ok: true, channels: configuredChannels() }); + } + + if (url.pathname === '/_officer/notify' && req.method === 'POST') { + let body: Partial; + try { + body = (await req.json()) as Partial; + } catch { + return Response.json({ error: 'invalid json' }, { status: 400 }); + } + + if (!body.type || !VALID_TYPES.includes(body.type)) { + return Response.json({ error: `type must be one of ${VALID_TYPES.join(', ')}` }, { status: 400 }); + } + // Producers inside the tailnet are trusted, but a userId typo would silently notify nobody, so it + // is required rather than defaulted. + if (typeof body.userId !== 'number') { + return Response.json({ error: 'userId is required' }, { status: 400 }); + } + + const results = await dispatch(body as Notification); + return Response.json({ ok: true, results }); + } + + if (url.pathname.startsWith('/_officer/devices')) { + try { + const res = await handleDeviceRoute(req, url); + return res ?? new Response('not found', { status: 404 }); + } catch (err) { + console.error(`[notify] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + + return new Response('not found', { status: 404 }); + }, +}); + +console.log(`[notify] listening on http://127.0.0.1:${server.port} — channels: ${configuredChannels().join(', ') || 'none configured'}`); + +// ── Connect to the API server ── + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'notify', + capabilities: ['notify'], + onCommand(cmd, reply) { + const c = cmd as SidecarCommand; + if (c.type === 'ping') return reply({ type: 'pong', id: c.id } as SidecarEvent); + reply({ type: 'error', id: (c as { id?: string }).id, error: `Unknown command type: ${c.type}` } as SidecarEvent); + }, + onConnected() { + // Re-announced on every reconnect: the platform forgets the port when the socket drops, and this + // listener outlives an officer restart. + connection.send({ type: 'notify:server', port: server.port } as SidecarEvent); + }, +}); + +function shutdown(signal: string) { + console.log(`[notify] ${signal} received, shutting down...`); + connection.destroy(); + process.exit(0); +} +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/src/servers/sidecar/notify/text.ts b/src/servers/sidecar/notify/text.ts new file mode 100644 index 00000000..631a198b --- /dev/null +++ b/src/servers/sidecar/notify/text.ts @@ -0,0 +1,32 @@ +import type { Notification } from './types'; + +// The visible text, composed HERE from the category — never sent by a producer and never carrying +// content. This is what stops "Re: your invoice from Acme" reaching Apple or Google. +// +// It has to exist somewhere: APNs and FCM both need a title/body to render a banner, so "send nothing" +// is not an option. What we control is that the string is generic and derived from `type` alone, so the +// most either service learns is the category and the timing. The app can render its own copy from the +// same data payload if it wants better wording; this is the fallback that goes over the wire. + +export function renderTitle(n: Notification): string { + switch (n.type) { + case 'job': + return n.ok === false ? 'Job failed' : 'Job finished'; + case 'mail': + return n.count && n.count > 1 ? `${n.count} new emails` : 'New email'; + case 'agent': + return 'Agent finished'; + case 'download': + return 'Download complete'; + case 'test': + return 'Officer'; + } +} + +/** + * Deliberately empty for most categories. A banner with a title and no body is the least we can send + * while still being a usable notification, and every word added is a word Apple and Google get to read. + */ +export function renderBody(n: Notification): string { + return n.type === 'test' ? 'Test notification' : ''; +} diff --git a/src/servers/sidecar/notify/types.ts b/src/servers/sidecar/notify/types.ts new file mode 100644 index 00000000..87c147e4 --- /dev/null +++ b/src/servers/sidecar/notify/types.ts @@ -0,0 +1,42 @@ +// The one shape every producer sends and every channel receives. +// +// A notification is a DOORBELL, not a message. Apple and Google can read anything we hand them, so the +// payload carries a category and an id — never a subject line, a sender, a filename or an error string. +// The device composes the visible text from `type` and fetches the real content over the tailnet when +// tapped. See docs/push-notifications.md. + +/** What happened. Deliberately coarse — the categories a device needs to route and render, nothing more. */ +export type NotifyType = 'job' | 'mail' | 'agent' | 'download' | 'test'; + +export type Notification = { + type: NotifyType; + /** Who to notify. Single-user today, but the registry is keyed by user, so this stays explicit. */ + userId: number; + /** + * The thing this is about, if there is one — a job id, a session id. The device fetches by it. + * Never a human-readable string. + */ + id?: string | number; + /** How many, where a count is the whole story ("3 new emails"). */ + count?: number; + /** Outcome, where there is one. */ + ok?: boolean; + /** Restrict to one app: `mobile` | `music` | `read-aloud`. Absent = every registered app. */ + appSlug?: string; +}; + +/** What a channel reports back. Never throws — a failed notification must not break its producer. */ +export type DeliveryResult = { + channel: string; + sent: number; + failed: number; + /** Tokens Apple/Google rejected outright; the caller deletes these rows. */ + dead?: string[]; +}; + +export type Channel = { + name: string; + /** False when unconfigured — the sidecar skips it rather than erroring. */ + isConfigured: () => boolean; + send: (n: Notification) => Promise; +}; diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 8ae6ff26..f63f9633 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -79,6 +79,8 @@ export type SidecarEvent = | { type: 'pty:server'; port: number } // Email — the sidecar reports where its mail HTTP server is listening (random port) on connect | { type: 'email:server'; port: number } + // Notify — the sidecar reports where its notification HTTP server is listening (random port) on connect + | { type: 'notify:server'; port: number } // Generic | { type: 'error'; id?: string; error: string };