feat: Phase 11 — CRUD (create/update/delete) + auth + web UI

- Core: createDocument/updateDocument/deleteDocument (file + DB + revision + chunks)
- Parser: stringifyFile (serialize markdown with frontmatter to disk)
- API: tRPC CRUD routers (auth-gated via requireWriteAuth middleware)
- API: createContext now passes expectedSecret from deps (request-scoped auth)
- MCP: 3 new write tools (create_document, update_document, delete_document)
- Web: /create page + ?edit=1 form, /api/auth/login (cookie-based), /api/docs REST CRUD
- Web: Edit buttons on homepage + doc pages (auth-gated)
- Tests: 8 new tests (5 tRPC CRUD + 3 MCP CRUD auth), 40 total all green
This commit is contained in:
asepharyana
2026-08-20 13:08:27 +07:00
parent 2d974b8952
commit 57f90018da
19 changed files with 1215 additions and 23 deletions
+64
View File
@@ -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
+95
View File
@@ -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<any[]>,
updateDocument: [] as Array<any[]>,
deleteDocument: [] as Array<any[]>,
};
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<string, string> = {
"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);
});
+1
View File
@@ -140,6 +140,7 @@ export async function createApp(deps?: ApiDeps): Promise<Hono> {
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,
}),
}),
);
+45 -3
View File
@@ -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() }))
+5 -2
View File
@@ -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<Context>().create();
+56
View File
@@ -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();
});
+87
View File
@@ -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",
{
+46 -6
View File
@@ -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 (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Edit: {doc.title}</h1>
<DocForm
mode="edit"
slug={fullSlug}
secret={WEBHOOK_SECRET}
initial={{
title: doc.title,
body: doc.body,
section: doc.section,
type: doc.type,
status: doc.status,
tags: doc.tags,
author: doc.author,
}}
/>
</div>
);
}
const related = await getRelated(fullSlug, 5);
const revisions = await listRevisions(fullSlug, 10);
@@ -34,6 +66,14 @@ export default async function DocPage({
<div className="text-xs text-zinc-500 mt-1">
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
</div>
{canEdit && (
<Link
href={`/${doc.slug}?edit=1`}
className="inline-block mt-2 px-3 py-1 text-xs border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
)}
</div>
<Markdown source={doc.body} />
@@ -69,7 +109,7 @@ export default async function DocPage({
#{rev.revisionNo} · {rev.reason} ·{" "}
{new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars
</span>
<form action={`/api/revisions/restore/`} method="post">
<form action="/api/revisions/restore" method="post">
<input type="hidden" name="id" value={rev.id} />
<button
type="submit"
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { ADMIN_PASSWORD } from "@mcpedia/config";
import { createHmac, timingSafeEqual } from "node:crypto";
// POST /api/auth/login — verify admin password, set a signed cookie.
// Uses a simple HMAC cookie (no JWT library) — sufficient for a single-admin KB.
const COOKIE_NAME = "mcpedia_admin";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
function signCookie(value: string): string {
const secret = ADMIN_PASSWORD || "fallback";
const sig = createHmac("sha256", secret).update(value).digest("hex");
return `${value}.${sig}`;
}
function verifyCookie(cookieValue: string | undefined): boolean {
if (!cookieValue) return false;
const [value, sig] = cookieValue.split(".");
if (!value || !sig) return false;
const expected = signCookie(value);
return timingSafeEqual(
Buffer.from(cookieValue),
Buffer.from(expected),
);
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { password } = body;
if (!ADMIN_PASSWORD || typeof password !== "string") {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const ok = timingSafeEqual(
Buffer.from(password),
Buffer.from(ADMIN_PASSWORD),
);
if (!ok) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const cookie = signCookie("admin");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, cookie, {
maxAge: COOKIE_MAX_AGE,
httpOnly: true,
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
return res;
}
// GET /api/auth/login — returns 200 if currently authenticated, 401 otherwise.
export async function GET(req: NextRequest) {
const cookie = req.cookies.get(COOKIE_NAME)?.value;
if (verifyCookie(cookie)) return NextResponse.json({ ok: true });
return NextResponse.json({ ok: false }, { status: 401 });
}
// DELETE /api/auth/login — clear the cookie (logout).
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.delete({ name: COOKIE_NAME, path: "/" });
return res;
}
// Exported for server components to call directly.
export function isAuthenticated(req: NextRequest): boolean {
const cookie = req.cookies.get(COOKIE_NAME)?.value;
return verifyCookie(cookie);
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import {
createDocument,
updateDocument,
deleteDocument,
listDocuments,
} from "@mcpedia/core";
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { createHmac, timingSafeEqual } from "node:crypto";
// POST /api/docs
// Create a new document.
// Body: { slug, title, section, body, type?, status?, author?, tags? }
// Auth: x-webhook-secret header matching WEBHOOK_SECRET.
export async function POST(req: NextRequest) {
if (!WEBHOOK_SECRET) {
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
}
const provided = req.headers.get("x-webhook-secret");
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
try {
const doc = await createDocument(body);
return NextResponse.json({ ok: true, slug: doc.slug, doc });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
}
}
// PUT /api/docs/{slug}
// Update an existing document.
// Body: { title?, body?, type?, status?, tags?, author? }
export async function PUT(req: NextRequest) {
if (!WEBHOOK_SECRET) {
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
}
const provided = req.headers.get("x-webhook-secret");
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
// Extract slug from URL path: /api/docs/{slug}
const url = new URL(req.url);
const parts = url.pathname.split("/").filter(Boolean);
const slug = parts[2]; // ["api", "docs", "...slugParts"]
if (!slug || slug === "docs") {
return NextResponse.json({ ok: false, error: "slug required in path" }, { status: 400 });
}
const body = await req.json().catch(() => null);
if (!body) {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
try {
// Reconstruct the full slug from path segments
const fullSlug = parts.slice(2).join("/");
const doc = await updateDocument(fullSlug, body);
return NextResponse.json({ ok: true, slug: doc.slug, doc });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
}
}
// DELETE /api/docs/{slug}
export async function DELETE(req: NextRequest) {
if (!WEBHOOK_SECRET) {
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
}
const provided = req.headers.get("x-webhook-secret");
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const parts = url.pathname.split("/").filter(Boolean);
const slug = parts.slice(2).join("/");
if (!slug) {
return NextResponse.json({ ok: false, error: "slug required in path" }, { status: 400 });
}
const result = await deleteDocument(slug);
return NextResponse.json({ ok: true, deleted: result.deleted });
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface DocFormProps {
mode: "create" | "edit";
slug?: string;
secret: string; // injected server-side (never visible to the client JS bundle)
initial?: {
title?: string;
body?: string;
section?: "docs" | "writeups" | "research" | "notes";
type?: "documentation" | "writeup" | "research" | "note";
status?: "published" | "draft";
tags?: string[];
author?: string;
};
}
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const;
export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState(initial?.title ?? "");
const [body, setBody] = useState(initial?.body ?? "");
const [section, setSection] = useState(initial?.section ?? "docs");
const [type, setType] = useState(initial?.type ?? "documentation");
const [status, setStatus] = useState(initial?.status ?? "published");
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
const [author, setAuthor] = useState(initial?.author ?? "");
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const payload = {
title,
body,
section,
type,
status,
author,
tags: tagList,
};
try {
let res: Response;
if (mode === "create") {
const slugVal = slug ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
res = await fetch("/api/docs", {
method: "POST",
headers: { "Content-Type": "application/json", "x-webhook-secret": secret },
body: JSON.stringify({ slug: slugVal, ...payload }),
});
} else {
const editSlug = slug ?? "";
res = await fetch(`/api/docs/${editSlug}`, {
method: "PUT",
headers: { "Content-Type": "application/json", "x-webhook-secret": secret },
body: JSON.stringify(payload),
});
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Save failed");
}
const result = await res.json();
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl mx-auto">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<input
type="text"
value={slug ?? ""}
onChange={(e) => router.replace(`/docs/${section}/edit?slug=${e.target.value}`)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800"
placeholder={section}
/>
<p className="text-xs text-zinc-500 mt-1">URL-safe path under the section (e.g. "websocket/contract").</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Section</label>
<select
value={section}
onChange={(e) => setSection(e.target.value as typeof section)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
{SECTION_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as typeof status)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tags</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
placeholder="comma, separated, tags"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Author</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="w-fill px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Body (Markdown)</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full h-96 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900 font-mono text-sm"
placeholder="# Heading&#10;&#10;Content here..."
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
>
{loading ? "Saving..." : mode === "create" ? "Create" : "Update"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
</div>
</form>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers";
import DocForm from "@/components/DocForm";
import Link from "next/link";
// GET /create — show the document creation form.
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login).
export const dynamic = "force-dynamic";
export default async function CreateDocPage() {
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
if (!canEdit) {
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-xl font-semibold mb-4">Authentication required</h1>
<p className="text-zinc-600 dark:text-zinc-400 mb-4">
You must be logged in to create documents.
</p>
<Link
href="/login"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded"
>
Login
</Link>
</div>
);
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Create Document</h1>
<DocForm mode="create" slug="" secret={WEBHOOK_SECRET} />
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { WEBHOOK_SECRET } from "@mcpedia/config";
import { cookies } from "next/headers";
import DocForm from "@/components/DocForm";
import Link from "next/link";
// GET /docs/create — show the create form.
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login).
export default async function CreateDocPage({
searchParams,
}: {
searchParams: Promise<{ slug?: string; section?: string }>;
}) {
const params = await searchParams;
const section =
(params.section as "docs" | "writeups" | "research" | "notes") ?? "docs";
const slug = params.slug ?? "";
// Auth check: cookie must be present.
const cookieStore = await cookies();
const adminCookie = cookieStore.get("mcpedia_admin");
if (!adminCookie?.value) {
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-xl font-semibold mb-4">Authentication required</h1>
<p className="text-zinc-600 dark:text-zinc-400 mb-4">
You must be logged in to create documents.
</p>
<Link
href="/login"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded"
>
Login
</Link>
</div>
);
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Create Document</h1>
<DocForm
mode="create"
slug={slug}
secret={WEBHOOK_SECRET}
initial={{ section }}
/>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
// Simple admin login form. POSTs password to /api/auth/login.
// On success, the API sets an HTTP-only cookie. We don't touch the cookie
// client-side (it's HttpOnly) — just redirect to the create page after login.
export default function LoginPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Login failed");
}
router.push("/docs/create");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950">
<div className="w-full max-w-sm">
<div className="bg-white dark:bg-zinc-900 p-8 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-800">
<h1 className="text-2xl font-bold mb-6 text-center">MCPedia Admin</h1>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200 mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
required
autoFocus
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
</div>
<div className="text-center mt-4">
<Link
href="/"
className="text-sm text-zinc-600 dark:text-zinc-400 hover:underline"
>
← Back to home
</Link>
</div>
</div>
</div>
);
}
+21
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import type { DocumentMeta } from "@mcpedia/core";
import { cookies } from "next/headers";
// Render at request time — content is in Postgres (not available at build in
// CI, which has no DB). Request-time rendering is instant at this corpus scale.
@@ -15,6 +16,10 @@ export default async function Home() {
docs: all.filter((d) => d.section === section),
}));
// Auth check for edit/create buttons.
const cookieStore = await cookies();
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
return (
<div className="space-y-8">
<section>
@@ -23,6 +28,14 @@ export default async function Home() {
A content-first knowledge base. Humans read the Web UI; AI agents use
the MCP server. Both share one Core.
</p>
{canEdit && (
<Link
href="/create"
className="inline-block mt-3 px-4 py-2 text-sm border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Create Document
</Link>
)}
</section>
{bySection.map(({ section, docs }) => (
@@ -40,6 +53,14 @@ export default async function Home() {
>
{d.title}
</Link>
{canEdit && (
<Link
href={`/${d.slug}/edit`}
className="ml-2 text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
✎
</Link>
)}
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
+4
View File
@@ -50,6 +50,10 @@ export const QUEUE_PREFIX = process.env.QUEUE_PREFIX ?? "mcpedia";
// this header (x-webhook-secret) to match, so an open port can't trigger reindex.
export const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "";
// Phase 11: Admin password for web-based CRUD (create/update/delete documents).
// Used by the Web UI login flow. MCP/API writes still use WEBHOOK_SECRET.
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? "";
// NOTE: we deliberately do NOT throw here if DATABASE_URL is empty. Throwing at
// import time breaks `next build` (SSG data collection imports config before
// any .env is present) and any runtime-injected env (containers/systemd set env
+191 -2
View File
@@ -1,17 +1,21 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "@mcpedia/db";
import { documentChunks, documents } from "@mcpedia/db/schema";
import { documentChunks, documentRevisions, documents } from "@mcpedia/db/schema";
import { CONTENT_ROOT } from "@mcpedia/config";
import { parseFile } from "@mcpedia/parser";
import { existsSync } from "node:fs";
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import type {
Document,
DocumentMeta,
DocSection,
DocType,
DocStatus,
} from "@mcpedia/types";
import { chunkText, embedChunks, createEmbeddingProvider } from "@mcpedia/embeddings";
import { readContentFile } from "./content.service";
import { toMeta } from "./row-map";
import { snapshotRevision } from "./index.service";
const embedder = createEmbeddingProvider();
@@ -105,3 +109,188 @@ export async function indexChunks(slug: string, body: string): Promise<number> {
return chunks.length;
}
// ---------------------------------------------------------------------------
// Phase 11: CRUD — create, update, delete documents.
//
// Source of truth for content is the filesystem: each doc is a markdown file
// under content/{section}/{slug}.md. The `documents` DB table mirrors the
// metadata + body for fast search. CRUD ops write the file first, then upsert
// the DB row, then snapshot a revision + reindex chunks. `deleteDocument`
// also cleans up chunks + revisions.
// ---------------------------------------------------------------------------
export interface CreateDocInput {
slug: string;
title: string;
section: DocSection;
body: string;
type?: DocType;
status?: DocStatus;
author?: string;
tags?: string[];
}
export interface UpdateDocInput {
title?: string;
body?: string;
type?: DocType;
status?: DocStatus;
tags?: string[];
author?: string;
}
/** Validate that a slug is safe (no path traversal, only [a-z0-9/_-]). */
function validateSlug(slug: string): string {
if (!/^[a-z0-9][a-z0-9/_-]*$/.test(slug)) {
throw new Error(`invalid slug: ${slug}`);
}
if (slug.includes("//")) throw new Error(`invalid slug (double slash): ${slug}`);
return slug;
}
/** Compute the relative file path for a slug (content/{section}/{slug}.md). */
function slugToRelPath(section: DocSection, slug: string): string {
const cleanSlug = validateSlug(slug);
// If the slug already starts with the section, strip it to avoid doubling.
const pathPart = cleanSlug.startsWith(`${section}/`)
? cleanSlug.slice(section.length + 1)
: cleanSlug;
return `${section}/${pathPart}.md`;
}
/**
* Create a new document: write the markdown file, upsert the DB row,
* snapshot a revision, and index semantic chunks.
* @returns the created DocumentMeta
*/
export async function createDocument(input: CreateDocInput): Promise<DocumentMeta> {
const section = input.section;
const slug = validateSlug(input.slug);
const relPath = slugToRelPath(section, slug);
const absPath = join(CONTENT_ROOT, relPath);
if (existsSync(absPath)) {
throw new Error(`document already exists at slug: ${slug}`);
}
const nowIso = new Date().toISOString();
const meta: DocumentMeta = {
id: slug,
slug,
title: input.title,
type: input.type ?? "documentation",
section,
status: input.status ?? "published",
author: input.author ?? "",
tags: input.tags ?? [],
path: relPath,
createdAt: nowIso,
updatedAt: nowIso,
};
// Write file to disk first (source of truth).
const { stringifyFile } = await import("@mcpedia/parser");
stringifyFile(absPath, relPath, meta, input.body);
// Upsert DB row.
await db.insert(documents).values({
id: meta.id,
slug: meta.slug,
title: meta.title,
type: meta.type,
section: meta.section,
status: meta.status,
author: meta.author,
tags: meta.tags,
path: meta.path,
body: input.body,
createdAt: new Date(meta.createdAt),
updatedAt: new Date(meta.updatedAt),
});
// Snapshot revision + index chunks (best-effort; chunks must not block create).
await snapshotRevision(slug, meta, input.body, "index");
try {
await indexChunks(slug, input.body);
} catch (err) {
console.error(`createDocument: chunk/embed FAILED for ${slug}: ${err instanceof Error ? err.message : err}`);
}
return meta;
}
/**
* Update an existing document: write new file, upsert DB row, snapshot a
* revision (if body changed), and reindex chunks.
* @returns the updated DocumentMeta
*/
export async function updateDocument(
slug: string,
input: UpdateDocInput,
): Promise<DocumentMeta> {
const doc = await getDocument(slug);
if (!doc) throw new Error(`document not found: ${slug}`);
const updatedAt = new Date().toISOString();
const updated: DocumentMeta = {
...doc,
title: input.title ?? doc.title,
type: input.type ?? doc.type,
section: doc.section,
status: input.status ?? doc.status,
tags: input.tags ?? doc.tags,
author: input.author ?? doc.author,
updatedAt,
};
const body = input.body ?? doc.body;
// Write file to disk (source of truth).
const absPath = join(CONTENT_ROOT, doc.path);
const { stringifyFile } = await import("@mcpedia/parser");
stringifyFile(absPath, doc.path, updated, body);
// Upsert DB row.
await db
.update(documents)
.set({
title: updated.title,
type: updated.type,
section: updated.section,
status: updated.status,
author: updated.author,
tags: updated.tags,
body,
updatedAt: new Date(updatedAt),
})
.where(eq(documents.slug, slug));
// Snapshot revision (only if body changed) + reindex chunks.
await snapshotRevision(slug, updated, body, "update");
try {
await indexChunks(slug, body);
} catch (err) {
console.error(`updateDocument: chunk/embed FAILED for ${slug}: ${err instanceof Error ? err.message : err}`);
}
return updated;
}
/**
* Delete a document: remove the file, delete DB rows (doc + chunks + revisions).
*/
export async function deleteDocument(slug: string): Promise<{ deleted: boolean }> {
const [row] = await db.select().from(documents).where(eq(documents.slug, slug));
if (!row) return { deleted: false };
// Remove file from disk (source of truth).
const absPath = join(CONTENT_ROOT, row.path);
if (existsSync(absPath)) unlinkSync(absPath);
// Clean up DB rows (cascades would work but be explicit).
await db.delete(documentChunks).where(eq(documentChunks.slug, slug));
await db.delete(documentRevisions).where(eq(documentRevisions.documentId, row.id));
await db.delete(documents).where(eq(documents.id, row.id));
return { deleted: true };
}
+1 -1
View File
@@ -105,7 +105,7 @@ export function shouldCreateRevision(
* (or no prior revision exists), create a new revision with an incremented
* per-document revisionNo.
*/
async function snapshotRevision(
export async function snapshotRevision(
slug: string,
meta: ReturnType<typeof parseFile>["meta"],
body: string,
+48 -1
View File
@@ -1,5 +1,6 @@
import matter from "gray-matter";
import { readFileSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import type {
DocSection,
DocStatus,
@@ -63,3 +64,49 @@ export function parseFile(absPath: string, relPath: string): ParsedFile {
return { meta, body: content };
}
/**
* Serialize document metadata + body back to a markdown file with YAML
* frontmatter. The file is written to `absPath`, creating parent dirs as needed.
* The `relPath` is stored in frontmatter as `path` so the file is round-trip
* stable (parseFile → stringifyFile → parseFile yields the same meta+body).
*
* @param absPath absolute path on disk
* @param relPath path relative to content root (e.g. "docs/my/doc.md")
* @param meta document metadata
* @param body markdown body (frontmatter stripped — same as parseFile.body)
*/
export function stringifyFile(
absPath: string,
relPath: string,
meta: DocumentMeta,
body: string,
): void {
const data: Record<string, unknown> = {
title: meta.title,
type: meta.type,
section: meta.section,
status: meta.status,
author: meta.author,
tags: meta.tags,
path: relPath,
created_at: meta.createdAt,
updated_at: meta.updatedAt,
};
const yaml = "---\n" +
Object.entries(data)
.map(([k, v]) => {
if (Array.isArray(v)) {
return `${k}: [${v.map((x) => `"${x}"`).join(", ")}]`;
}
if (typeof v === "string") {
return `${k}: ${JSON.stringify(v)}`;
}
return `${k}: ${v}`;
})
.join("\n") +
"\n---\n";
const content = yaml + body;
mkdirSync(dirname(absPath), { recursive: true });
writeFileSync(absPath, content, "utf8");
}