import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { getServiceConnection, saveServiceConnection, deleteServiceConnection, recordServiceProbe } from 'officerdb'; import { callMemos, getMemosConfig, invalidateMemosConfig, normalizeBase, probe } from './upstream'; // The officer-memos sidecar. Owns the whole Memos contract: the instance URL and the personal access // token. The platform side is a thin auth-gated forwarder holding no Memos credentials. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/memos mount prefix before forwarding. // // GET /_health is the instance up, and does the stored token work // GET /_config the connection MINUS the token // POST /_config { url, token } — validated live before it is stored // DELETE /_config forget the connection // ANY /_api/* forwarded to the instance's own /api/*, token attached // // `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint. Memos' REST // API is generated from its protobufs and moves between minor versions; re-describing it here would // mean a second thing to keep in sync, and the UI is going to speak the upstream's shapes anyway. // The allow-list below is the one piece of policy: it keeps the sidecar from being a general-purpose // SSRF hop into whatever else is on that host. // ───────────────────────────────────────────────────────────────────────────────────────────────── const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; // Everything under /api/v1 the UI legitimately needs. Auth routes are excluded on purpose: signin and // signout would mint or destroy sessions on the instance, and this sidecar authenticates with a stored // token rather than borrowing the owner's Memos session. const ALLOWED = [/^\/api\/v1\/memos(\/|$|\?)/, /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/resources(\/|$|\?)/]; function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const bad = (error: string, status = 400) => Response.json({ error }, { status }); async function handleConfig(req: Request, userId: number): Promise { if (req.method === 'GET') { const connection = await getServiceConnection(userId, 'memos'); return Response.json({ configured: !!connection, connection }); } if (req.method === 'DELETE') { const removed = await deleteServiceConnection(userId, 'memos'); invalidateMemosConfig(userId); return Response.json({ ok: removed }); } if (req.method === 'POST') { const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {}; const url = typeof body.url === 'string' ? normalizeBase(body.url) : ''; // A blank token on a save means "keep the stored one", so the URL can be corrected without the // token being re-pasted. There is no way to read it back out to re-send it. const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null; if (!url) return bad('url is required'); if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://'); const existing = await getServiceConnection(userId, 'memos'); if (!token && !existing?.hasSecret) return bad('an access token is required the first time'); // Probe with the credentials as they WILL be, so a save either stores something that works or // fails with the reason. Storing first and discovering later makes every downstream screen fail // mysteriously instead. const current = token ? null : await getMemosConfig(userId); const result = await probe({ id: existing?.id ?? 0, base: url, token: token ?? current?.token ?? null }); if (!result.ok) return bad(result.error ?? 'could not reach the instance'); await saveServiceConnection({ userId, service: 'memos', url, secret: token ?? undefined, version: result.version, }); invalidateMemosConfig(userId); return Response.json({ ok: true, user: result.user }); } return bad('method not allowed', 405); } const server = Bun.serve({ port, hostname: '127.0.0.1', // Memos resources are image/file attachments on a note. maxRequestBodySize: 64 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } try { if (url.pathname === '/_config') return await handleConfig(req, userId); const config = await getMemosConfig(userId); if (url.pathname === '/_health') { if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); const started = Date.now(); const result = await probe(config); const ms = Date.now() - started; if (!result.ok) { return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 }); } await recordServiceProbe(userId, 'memos', result.version); return Response.json({ ok: true, configured: true, user: result.user, ms }); } if (url.pathname.startsWith('/_api/')) { if (!config) return Response.json({ error: 'memos not connected', configured: false }, { status: 503 }); const subpath = url.pathname.slice('/_api'.length) + url.search; if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403); const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; const upstream = await callMemos(config, subpath, { method: req.method, headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {}, body: hasBody ? await req.arrayBuffer() : undefined, }); return new Response(upstream.body, { status: upstream.status, headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }, }); } } catch (err) { // Path only. A memo body is the owner's private note. console.error(`[memos] ${req.method} ${url.pathname} failed`, String(err)); return Response.json({ error: 'internal error' }, { status: 500 }); } return Response.json({ error: 'not found' }, { status: 404 }); }, }); console.log(`[memos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'memos', capabilities: ['memos'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'memos:server', port }); console.log(`[memos] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[memos] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));