memos sidecar
wraps the self-hosted memos instance, same shape as transmission and slskd. no schema change was needed: service_connections already says `service` is text because "adding a service should not be a schema change", and memos is the one-instance-per-owner case that table was built for. the sidecar holds the url and the personal access token; the platform side is 16 lines of createSidecarProxy and holds neither. /_api/* is a pass-through onto the instance's own /api/v1 rather than a hand-written wrapper per endpoint — memos generates its rest api from protobufs and it moves between minor versions, so re-describing it here would be a second thing to keep in sync. the allow-list is the one piece of policy, and it keeps this from being a general ssrf hop. auth routes are excluded: signin/signout would mint sessions on the instance, and this authenticates with a stored token. probing is two calls on purpose. /healthz answers unauthenticated, so a bad url is distinguishable from a bad token — memos returns 200 and an empty list for unauthenticated reads rather than 401, so "the list came back" proves nothing. verified against the live container: unconfigured reports not-connected, a bad token is rejected WITH the reason and nothing is stored, and the platform mount 401s without a session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,14 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
|
||||||
|
// owner from the UI and stored in `service_connections` — read here, never from the environment.
|
||||||
|
{
|
||||||
|
name: 'officer-memos',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/memos/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
|
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
|
||||||
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
|
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
|
||||||
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
|
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { encryptSecret, decryptSecret } from '../crypto';
|
|||||||
// moment someone forgot to strip it.
|
// moment someone forgot to strip it.
|
||||||
|
|
||||||
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
|
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
|
||||||
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer';
|
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist';
|
||||||
|
|
||||||
export type ServiceConnection = {
|
export type ServiceConnection = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
|
|
||||||
|
// /api/memos/* — auth, then forward to officer-memos. No routes of its own and no Memos knowledge:
|
||||||
|
// this file must never grow app logic.
|
||||||
|
//
|
||||||
|
// The sidecar owns the Memos contract and holds the personal access token. The platform knows neither.
|
||||||
|
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'memos',
|
||||||
|
prefix: '/api/memos',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const memosRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getMemosServerUrl = proxy.getHttpUrl;
|
||||||
@@ -31,6 +31,7 @@ import { walletRouter } from './api/wallet/router';
|
|||||||
import { vpnRouter } from './api/vpn/router';
|
import { vpnRouter } from './api/vpn/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
import { caldavRouter } from './api/dav/sidecar-server';
|
import { caldavRouter } from './api/dav/sidecar-server';
|
||||||
|
import { memosRouter } from './api/memos/router';
|
||||||
import { davSyncRouter } from './api/dav/sync-router';
|
import { davSyncRouter } from './api/dav/sync-router';
|
||||||
import { davRouter } from './api/dav/router';
|
import { davRouter } from './api/dav/router';
|
||||||
import { notifyRouter } from './api/notify/router';
|
import { notifyRouter } from './api/notify/router';
|
||||||
@@ -123,6 +124,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
|
|||||||
protectedRouter.route('/music', musicRouter);
|
protectedRouter.route('/music', musicRouter);
|
||||||
protectedRouter.route('/slskd', slskdRouter);
|
protectedRouter.route('/slskd', slskdRouter);
|
||||||
protectedRouter.route('/terminal', terminalRouter);
|
protectedRouter.route('/terminal', terminalRouter);
|
||||||
|
protectedRouter.route('/memos', memosRouter);
|
||||||
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
|
protectedRouter.route('/caldav', caldavRouter); // the JSON door for Officer's own calendar/contacts UI
|
||||||
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
|
protectedRouter.route('/dav', davRouter); // app-password management (the sync door is /dav, top-level)
|
||||||
protectedRouter.route('/notify', notifyRouter);
|
protectedRouter.route('/notify', notifyRouter);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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<Response> {
|
||||||
|
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<string, unknown>).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'));
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { getServiceCredentials } from 'officerdb';
|
||||||
|
|
||||||
|
// Where the Memos instance is and what it takes to talk to it. The credential lives here and only here —
|
||||||
|
// the platform forwards to this sidecar and holds nothing.
|
||||||
|
//
|
||||||
|
// Memos authenticates with a personal access token (`Authorization: Bearer …`), minted from Memos' own
|
||||||
|
// Settings → Access Tokens. That is the OWNER'S to create and paste in; there is no way to derive one
|
||||||
|
// from a platform session, and no reason to want one.
|
||||||
|
|
||||||
|
export type MemosConfig = { id: number; base: string; token: string | null };
|
||||||
|
|
||||||
|
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
|
||||||
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const cache = new Map<number, MemosConfig | null>();
|
||||||
|
|
||||||
|
export function invalidateMemosConfig(userId: number): void {
|
||||||
|
cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMemosConfig(userId: number): Promise<MemosConfig | null> {
|
||||||
|
if (cache.has(userId)) return cache.get(userId) ?? null;
|
||||||
|
const row = await getServiceCredentials(userId, 'memos');
|
||||||
|
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
|
||||||
|
cache.set(userId, config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authHeaders = (config: MemosConfig): Record<string, string> =>
|
||||||
|
config.token ? { Authorization: `Bearer ${config.token}` } : {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
|
||||||
|
* nothing here parses a memo, because a memo is the owner's note and this layer has no business
|
||||||
|
* reading it.
|
||||||
|
*/
|
||||||
|
export async function callMemos(config: MemosConfig, path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
|
||||||
|
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the instance up, and does the stored token actually work?
|
||||||
|
*
|
||||||
|
* Two checks, because they fail differently and the UI needs to tell them apart: `/healthz` answers
|
||||||
|
* unauthenticated so a bad URL is distinguishable from a bad token, and `/api/v1/users/me` is the
|
||||||
|
* cheapest endpoint that genuinely requires auth. Memos returns 200 with an empty list for
|
||||||
|
* unauthenticated reads rather than 401, so "the list came back" proves nothing on its own.
|
||||||
|
*/
|
||||||
|
export async function probe(config: MemosConfig): Promise<ProbeResult> {
|
||||||
|
try {
|
||||||
|
const health = await fetch(`${config.base}/healthz`, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!health.ok) return { ok: false, version: null, user: null, error: `instance returned ${health.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const me = await callMemos(config, '/api/v1/users/me');
|
||||||
|
if (!me.ok) return { ok: false, version: null, user: null, error: `token rejected (${me.status})` };
|
||||||
|
const body = (await me.json().catch(() => ({}))) as { name?: string; username?: string; nickname?: string };
|
||||||
|
return { ok: true, version: null, user: body.nickname || body.username || body.name || null };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version: null, user: null, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'invoiceshelf:server'; port: number }
|
| { type: 'invoiceshelf:server'; port: number }
|
||||||
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'photos:server'; port: number }
|
| { type: 'photos:server'; port: number }
|
||||||
|
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
| { type: 'memos:server'; port: number }
|
||||||
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
|
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
|
||||||
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
|
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
|
||||||
| { type: 'caldav:server'; port: number }
|
| { type: 'caldav:server'; port: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user