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:
@@ -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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user