diff --git a/.hermes/plans/phase11-crud-auth-ui.md b/.hermes/plans/phase11-crud-auth-ui.md new file mode 100644 index 0000000..c6eee64 --- /dev/null +++ b/.hermes/plans/phase11-crud-auth-ui.md @@ -0,0 +1,64 @@ +# MCPedia Phase 11 — CRUD + Auth + UI/UX Improvement + +## Plan (spec BEFORE implementation, per user rule) + +### Scope: 4 areas +1. **CRUD**: Create/Read/Update/Delete documents from web UI + MCP +2. **Auth**: Session-based auth for web edits; API key auth for MCP writes +3. **UI/UX**: Polish doc page, edit forms, search UX, dark mode +4. **Agent integration**: MCP tools for CRUD with auth + +### Files to touch: +- `packages/db/` — migrations (users table, if needed) +- `apps/api/src/` — auth routes, CRUD tRPC routers +- `apps/mcp/src/` — add create/update/delete tools +- `apps/web/app/` — edit/create pages, auth UI + +### Schema changes: +- Optional: `users` table if auth is user-based +- OR: simple `WEB_AUTH_TOKEN` env for admin access (simpler, fits single-author KB) + +### Backend surface: +- **Web auth**: Cookie-based session OR single admin token in localStorage + - Simpler approach: `POST /api/auth/login` (checks `ADMIN_PASSWORD` env) → sets cookie + - Edit UI gated behind cookie check in server components +- **MCP auth**: `x-api-key` header for write tools (create/update/delete) + - Read tools (list, get, search) — public + - Write tools (create, update, delete, index_document, reindex_all, restore_revision) — require key + - `createMcpServer(authKey?)` threads the header, same pattern as `x-webhook-secret` + +### CRUD operations: +- **Create**: `POST /trpc/createDocument` (title, slug, section, body, tags) + - Writes markdown file to `content/{section}/{slug}.md` + - Triggers indexer (enqueues job or calls directly) +- **Update**: `POST /trpc/updateDocument` (slug, body, title, tags, status) + - Updates file + creates revision + reindexes +- **Delete**: `POST /trpc/deleteDocument` (slug) + - Removes file, DB rows, chunks; creates revision tombstone +- All mutations require auth (web cookie OR webhook secret OR MCP api-key) + +### UI improvements: +- Edit button on doc pages (auth-gated) → link to `/docs/{slug}/edit` +- Create page: `/docs/create` with form (section dropdown, slug, title, tags, markdown editor) +- Edit page: `/docs/{slug}/edit` pre-fills from getDocument +- Search page: live search results with keyboard nav, better empty states +- Doc page: table of contents (auto-generated from h2), dark mode toggle + +### Verification steps: +1. TDD: write failing tests for each new API endpoint/method first +2. `bun run typecheck` — green +3. `bun run test` — 32 existing + new tests green +4. `bun run build` — green +5. CI + Deploy passes +6. Browser: create doc → search sees it → edit doc → changes reflect → delete doc → gone +7. MCP: create_document tool with key → doc appears; without key → unauthorized + +### Acceptance criteria: +- [ ] createDocument tRPC + MCP +- [ ] updateDocument tRPC + MCP +- [ ] deleteDocument tRPC + MCP +- [ ] Web auth (cookie-based login) +- [ ] MCP write-tool auth (api-key) +- [ ] UI: edit/create pages +- [ ] UI: TOC + dark mode on doc pages +- [ ] All tests green, CI+Deploy passes diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 91fc773..a56beed 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -2,6 +2,7 @@ import { test, expect, beforeEach, mock } from "bun:test"; import { createApp } from "../src/app"; import type { ApiDeps } from "../src/app"; import { DASHBOARD_HTML } from "../src/dashboard"; +import { Hono } from "hono"; // ------------------------------------------------------------------- // A fake queue that records calls and returns canned counts. Injected into @@ -39,6 +40,37 @@ mock.module("@mcpedia/queue", () => ({ INDEX_QUEUE: "mcpedia-index", })); +// Mock @mcpedia/core so tRPC CRUD procedures don't hit Postgres. +// Only the functions used by the router are faked; assertions record calls. +const coreCalls = { + createDocument: [] as Array, + updateDocument: [] as Array, + deleteDocument: [] as Array, +}; +mock.module("@mcpedia/core", () => ({ + keywordSearch: () => Promise.resolve([]), + getDocument: () => Promise.resolve(null), + getRelated: () => Promise.resolve([]), + hybridSearch: () => Promise.resolve([]), + semanticSearch: () => Promise.resolve([]), + listDocuments: () => Promise.resolve([]), + listRevisions: () => Promise.resolve([]), + getRevision: () => Promise.resolve(null), + restoreRevision: () => Promise.resolve(null), + createDocument: (...args: any[]) => { + coreCalls.createDocument.push(args); + return Promise.resolve({ slug: "docs/test", title: "Test" }); + }, + updateDocument: (...args: any[]) => { + coreCalls.updateDocument.push(args); + return Promise.resolve({ slug: args[0], title: "Updated" }); + }, + deleteDocument: (...args: any[]) => { + coreCalls.deleteDocument.push(args); + return Promise.resolve({ deleted: true }); + }, +})); + let SECRET: string; beforeEach(() => { SECRET = "test-secret-" + Math.random().toString(36).slice(2); @@ -131,3 +163,66 @@ test("GET /dashboard returns the self-contained HTML", async () => { expect(html).toContain("Index Queue (BullMQ)"); expect(html).toContain(DASHBOARD_HTML.slice(0, 100)); }); + +// --- Phase 11: CRUD tRPC procedures (auth-gated) --- + +function trpcRequest(app: Hono, procedure: string, input: unknown, secret?: string) { + const headers: Record = { + "content-type": "application/json", + }; + if (secret) headers["x-webhook-secret"] = secret; + // tRPC fetch adapter expects the procedure input directly as the JSON body + // (not wrapped in a JSON-RPC envelope). The procedure name comes from the URL. + return app.request("/trpc/" + procedure, { + method: "POST", + headers, + body: JSON.stringify(input), + }); +} + +test("tRPC createDocument without secret -> rejects (tRPC error)", async () => { + const app = await createApp(makeDeps(SECRET, fakeQueue({}))); + const res = await trpcRequest(app, "createDocument", { + slug: "docs/test", title: "Test", section: "docs", body: "# Hi" + }); + // tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401) + expect(res.status).toBe(500); + expect(coreCalls.createDocument).toHaveLength(0); +}); + +test("tRPC createDocument with secret -> 200 + calls core", async () => { + const app = await createApp(makeDeps(SECRET, fakeQueue({}))); + const res = await trpcRequest(app, "createDocument", { + slug: "docs/test", title: "Test", section: "docs", body: "# Hi" + }, SECRET); + expect(res.status).toBe(200); + expect(coreCalls.createDocument).toHaveLength(1); +}); + +test("tRPC updateDocument with secret -> 200 + calls core", async () => { + const app = await createApp(makeDeps(SECRET, fakeQueue({}))); + const res = await trpcRequest(app, "updateDocument", { + slug: "docs/test", body: "# Updated" + }, SECRET); + expect(res.status).toBe(200); + expect(coreCalls.updateDocument).toHaveLength(1); +}); + +test("tRPC deleteDocument without secret -> rejects (tRPC error)", async () => { + const app = await createApp(makeDeps(SECRET, fakeQueue({}))); + const res = await trpcRequest(app, "deleteDocument", { + slug: "docs/test" + }); + // tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401) + expect(res.status).toBe(500); + expect(coreCalls.deleteDocument).toHaveLength(0); +}); + +test("tRPC deleteDocument with secret -> 200 + calls core", async () => { + const app = await createApp(makeDeps(SECRET, fakeQueue({}))); + const res = await trpcRequest(app, "deleteDocument", { + slug: "docs/test" + }, SECRET); + expect(res.status).toBe(200); + expect(coreCalls.deleteDocument).toHaveLength(1); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6123e48..582428b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -140,6 +140,7 @@ export async function createApp(deps?: ApiDeps): Promise { createContext: (): Context => ({ db, // real Postgres connection (read procedures use it via @mcpedia/db). webhookSecret: c.req.raw.headers.get("x-webhook-secret") ?? undefined, + expectedSecret: d.webhookSecret, }), }), ); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index c385a96..72ea3f9 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -10,20 +10,22 @@ import { listRevisions, getRevision, restoreRevision, + createDocument, + updateDocument, + deleteDocument, } from "@mcpedia/core"; import { getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client"; -import { WEBHOOK_SECRET } from "@mcpedia/config"; // restoreRevision is a state-changing action (it rewrites the live document row // + rebuilds its chunks). It must NOT be callable anonymously over the network — // only the Web UI (which calls @mcpedia/core directly) and an operator with the // webhook secret may use it. Anything else is rejected. const requireWriteAuth = t.middleware(({ ctx, next }) => { - if (!WEBHOOK_SECRET) { + if (!ctx.expectedSecret) { throw new Error("WEBHOOK_SECRET is not configured; writes are disabled"); } - if (ctx.webhookSecret !== WEBHOOK_SECRET) { + if (ctx.webhookSecret !== ctx.expectedSecret) { throw new Error("unauthorized: missing or invalid x-webhook-secret"); } return next(); @@ -68,6 +70,46 @@ export const appRouter = router({ .input(z.object({ id: z.string() })) .mutation(async ({ input }) => restoreRevision(input.id)), + // --- Phase 11: CRUD (gated by x-webhook-secret / admin auth) --- + createDocument: publicProcedure + .use(requireWriteAuth) + .input( + z.object({ + slug: z.string().min(1), + title: z.string().min(1), + section: z.enum(["docs", "writeups", "research", "notes"]), + body: z.string(), + type: z.enum(["documentation", "writeup", "research", "note"]).optional(), + status: z.enum(["published", "draft"]).optional(), + author: z.string().optional(), + tags: z.array(z.string()).optional(), + }), + ) + .mutation(async ({ input }) => createDocument(input)), + + updateDocument: publicProcedure + .use(requireWriteAuth) + .input( + z.object({ + slug: z.string().min(1), + title: z.string().min(1).optional(), + body: z.string().optional(), + type: z.enum(["documentation", "writeup", "research", "note"]).optional(), + status: z.enum(["published", "draft"]).optional(), + tags: z.array(z.string()).optional(), + author: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const { slug, ...rest } = input; + return updateDocument(slug, rest); + }), + + deleteDocument: publicProcedure + .use(requireWriteAuth) + .input(z.object({ slug: z.string().min(1) })) + .mutation(async ({ input }) => deleteDocument(input.slug)), + // --- Phase 3: async job status --- jobStatus: publicProcedure .input(z.object({ id: z.string() })) diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index 0a15ecd..9a7cb87 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -4,9 +4,12 @@ import { db } from "@mcpedia/db"; export interface Context { db: typeof db; // Raw `x-webhook-secret` header from the incoming request, if present. - // State-changing tRPC mutations (restoreRevision) require it to match - // WEBHOOK_SECRET; read-only procedures ignore it. + // State-changing tRPC mutations (restoreRevision, CRUD) require it to match + // the configured secret; read-only procedures ignore it. webhookSecret?: string; + // The configured expected secret (from deps). Used by requireWriteAuth + // to validate the header — request-scoped so tests can inject a fake. + expectedSecret: string; } export const t = initTRPC.context().create(); diff --git a/apps/mcp/src/auth.test.ts b/apps/mcp/src/auth.test.ts index c6215a7..3f6f7ca 100644 --- a/apps/mcp/src/auth.test.ts +++ b/apps/mcp/src/auth.test.ts @@ -46,6 +46,9 @@ mock.module("@mcpedia/core", () => ({ return Promise.resolve({ slug: "docs/test", documentId: "d1" }); }, readContentFile: () => "", + createDocument: (_input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }), + updateDocument: (_slug: string, _input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }), + deleteDocument: (_slug: string) => Promise.resolve({ deleted: true }), })); // Import AFTER mocking so the modules resolve to our fakes. @@ -150,6 +153,55 @@ test("queue_status is public (no auth needed)", async () => { await server.close(); }); +// --- Phase 11: CRUD write tools auth gates --- + +test("create_document without auth secret -> tool returns isError", async () => { + const { client, server } = await connect(); // no secret + const res = await client.callTool({ + name: "create_document", + arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" }, + }); + expect(res.isError).toBe(true); + await client.close(); + await server.close(); +}); + +test("create_document with auth secret -> success", async () => { + const { client, server } = await connect("secret"); + const res = await client.callTool({ + name: "create_document", + arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" }, + }); + expect(res.isError).toBeFalsy(); + const text = (res.content as any[])[0].text; + const parsed = JSON.parse(text); + expect(parsed.ok).toBe(true); + await client.close(); + await server.close(); +}); + +test("update_document with auth secret -> success", async () => { + const { client, server } = await connect("secret"); + const res = await client.callTool({ + name: "update_document", + arguments: { slug: "docs/test", body: "# Updated" }, + }); + expect(res.isError).toBeFalsy(); + await client.close(); + await server.close(); +}); + +test("delete_document without auth secret -> tool returns isError", async () => { + const { client, server } = await connect(); // no secret + const res = await client.callTool({ + name: "delete_document", + arguments: { slug: "docs/test" }, + }); + expect(res.isError).toBe(true); + await client.close(); + await server.close(); +}); + test("tool discovery works without auth (read tools present)", async () => { const { client, server } = await connect(); const tools = await client.listTools(); @@ -166,6 +218,10 @@ test("tool discovery works without auth (read tools present)", async () => { expect(names).toContain("reindex_all"); expect(names).toContain("restore_revision"); expect(names).toContain("queue_status"); + // Phase 11: CRUD write tools also present in discovery. + expect(names).toContain("create_document"); + expect(names).toContain("update_document"); + expect(names).toContain("delete_document"); await client.close(); await server.close(); }); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 9aaed04..024b089 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -13,6 +13,9 @@ import { getRevision, restoreRevision, readContentFile, + createDocument, + updateDocument, + deleteDocument, } from "@mcpedia/core"; import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { CONTENT_ROOT } from "@mcpedia/config"; @@ -195,6 +198,90 @@ export function createMcpServer(authSecret?: string): McpServer { }, ); + // --- Phase 11: CRUD write tools (require x-webhook-secret) --- + server.registerTool( + "create_document", + { + description: + "Create a new document (writes markdown file + DB row + revision + chunks). Requires the x-webhook-secret header.", + inputSchema: z.object({ + slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc')"), + title: z.string().describe("Document title"), + section: z.enum(["docs", "writeups", "research", "notes"]).describe("Content section"), + body: z.string().describe("Markdown body"), + type: z.enum(["documentation", "writeup", "research", "note"]).optional(), + status: z.enum(["published", "draft"]).optional(), + author: z.string().optional(), + tags: z.array(z.string()).optional(), + }), + }, + async ({ slug, title, section, body, type, status, author, tags }) => { + requireMcpAuth(authSecret); + const doc = await createDocument({ + slug, + title, + section, + body, + type, + status, + author, + tags, + }); + return { + content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }], + }; + }, + ); + + server.registerTool( + "update_document", + { + description: + "Update an existing document (title, body, tags, status, etc.). Requires the x-webhook-secret header.", + inputSchema: z.object({ + slug: z.string().describe("Document slug to update"), + title: z.string().optional(), + body: z.string().optional(), + type: z.enum(["documentation", "writeup", "research", "note"]).optional(), + status: z.enum(["published", "draft"]).optional(), + tags: z.array(z.string()).optional(), + author: z.string().optional(), + }), + }, + async ({ slug, title, body, type, status, tags, author }) => { + requireMcpAuth(authSecret); + const doc = await updateDocument(slug, { + title, + body, + type, + status, + tags, + author, + }); + return { + content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }], + }; + }, + ); + + server.registerTool( + "delete_document", + { + description: + "Delete a document (removes file + DB rows + chunks + revisions). Requires the x-webhook-secret header.", + inputSchema: z.object({ + slug: z.string().describe("Document slug to delete"), + }), + }, + async ({ slug }) => { + requireMcpAuth(authSecret); + const result = await deleteDocument(slug); + return { + content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: result.deleted }) }], + }; + }, + ); + server.registerTool( "queue_status", { diff --git a/apps/web/app/[section]/[...slug]/page.tsx b/apps/web/app/[section]/[...slug]/page.tsx index abeda1c..2edea1d 100644 --- a/apps/web/app/[section]/[...slug]/page.tsx +++ b/apps/web/app/[section]/[...slug]/page.tsx @@ -1,7 +1,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { getDocument, listDocuments, getRelated, listRevisions } from "@mcpedia/core"; +import { cookies } from "next/headers"; +import { getDocument, getRelated, listRevisions } from "@mcpedia/core"; +import { WEBHOOK_SECRET } from "@mcpedia/config"; import Markdown from "@/components/Markdown"; +import DocForm from "@/components/DocForm"; // Render at request time. The content lives in Postgres (populated by the // indexer/worker), which is not available at build time (CI has no DB), so we @@ -9,16 +12,45 @@ import Markdown from "@/components/Markdown"; // instant. export const dynamic = "force-dynamic"; -export default async function DocPage({ - params, -}: { +interface DocPageProps { params: Promise<{ section: string; slug: string[] }>; -}) { + searchParams: Promise<{ edit?: string }>; +} + +export default async function DocPage({ params, searchParams }: DocPageProps) { const { section, slug } = await params; + const { edit } = await searchParams; const fullSlug = `${section}/${slug.join("/")}`; const doc = await getDocument(fullSlug); if (!doc) notFound(); + // Check auth for edit mode. + const cookieStore = await cookies(); + const canEdit = cookieStore.get("mcpedia_admin")?.value != null; + + // If ?edit=1 and authenticated → show the edit form. + if (edit === "1" && canEdit) { + return ( +
+

Edit: {doc.title}

+ +
+ ); + } + const related = await getRelated(fullSlug, 5); const revisions = await listRevisions(fullSlug, 10); @@ -34,6 +66,14 @@ export default async function DocPage({
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
+ {canEdit && ( + + Edit + + )} @@ -69,7 +109,7 @@ export default async function DocPage({ #{rev.revisionNo} · {rev.reason} ·{" "} {new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars -
+