Files
platform/src/servers/sidecar/notify/index.ts
T
pastilhasandClaude Opus 5 3c7f52ab77 PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.

It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.

It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.

It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.

server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:43:39 +00:00

120 lines
5.3 KiB
TypeScript

import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices';
import { resolveNotifyUser } from './resolve-user';
import { closeApnsSessions } from './apns';
import type { Notification, NotifyType } from './types';
import { API_URL } from '../../officer-url.mjs';
// 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 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<Notification>;
try {
body = (await req.json()) as Partial<Notification>;
} 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 });
}
// The header wins over the body wherever it is present — see ./resolve-user.ts for why that
// ordering is the whole access control on this route.
const userId = resolveNotifyUser({ header: req.headers.get('X-Officer-User'), bodyUserId: body.userId });
if (userId === null) {
return Response.json({ error: 'userId is required (body or X-Officer-User)' }, { status: 400 });
}
const results = await dispatch({ ...body, userId } 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...`);
closeApnsSessions();
connection.destroy();
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));