Add the content service and MCP server (phase 2)

services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:10:38 +00:00
parent a2f723ca2d
commit 3f646048a2
23 changed files with 3482 additions and 38 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Settings come from the environment, so the same image runs on Cosmos and in
* tests. Nothing here is read from the vault: an agent that can edit notes
* cannot change who may write or what the rules allow.
*/
import { resolve } from "node:path";
export type Role = "reader" | "agent" | "editor" | "admin";
export interface Config {
/** The vault's Site folder. */
siteDir: string;
/** The SQLite file holding accepted notes, history and the review queue. */
dbFile: string;
port: number;
host: string;
/** Bearer token to role. */
tokens: Map<string, Role>;
/** Pinged after every full scan, so a stopped watcher is noticed. */
healthcheckUrl?: string;
/** Receives a JSON message whenever a change is held. */
notifyWebhookUrl?: string;
allowedEmails: string[];
scanMinutes: number;
}
const roles = new Set<Role>(["reader", "agent", "editor", "admin"]);
/** CONTENT_TOKENS is a comma separated list of role:token pairs. */
export function parseTokens(raw: string | undefined): Map<string, Role> {
const tokens = new Map<string, Role>();
for (const pair of (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
const at = pair.indexOf(":");
const role = pair.slice(0, at) as Role;
const token = pair.slice(at + 1);
if (at < 1 || !roles.has(role)) throw new Error(`CONTENT_TOKENS: "${pair.slice(0, Math.max(at, 0))}" is not a role`);
if (token.length < 24) throw new Error(`CONTENT_TOKENS: the ${role} token is shorter than 24 characters`);
tokens.set(token, role);
}
return tokens;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
if (!env.SITE_DIR) throw new Error("SITE_DIR is required: the vault's Site folder");
const dataDir = resolve(env.DATA_DIR ?? "data");
return {
siteDir: resolve(env.SITE_DIR),
dbFile: resolve(dataDir, "content.db"),
port: Number(env.PORT ?? 4310),
host: env.HOST ?? "0.0.0.0",
tokens: parseTokens(env.CONTENT_TOKENS),
healthcheckUrl: env.HEALTHCHECK_URL || undefined,
notifyWebhookUrl: env.NOTIFY_WEBHOOK_URL || undefined,
allowedEmails: (env.ALLOWED_EMAILS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
scanMinutes: Number(env.SCAN_MINUTES ?? 5),
};
}
/** What each role may do. Checked by the HTTP API and the MCP server alike. */
export const can = {
readDrafts: (r: Role) => r !== "reader",
write: (r: Role) => r === "agent" || r === "editor" || r === "admin",
review: (r: Role) => r === "editor" || r === "admin",
rollback: (r: Role) => r === "admin",
};