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 };