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
+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>
);
}
+29 -8
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 }) => (
@@ -33,17 +46,25 @@ export default async function Home() {
) : (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{docs.map((d: DocumentMeta) => (
<li key={d.slug} className="py-2">
<li key={d.slug} className="py-2">
<Link
href={`/${d.slug}`}
className="hover:underline font-medium"
>
{d.title}
</Link>
{canEdit && (
<Link
href={`/${d.slug}`}
className="hover:underline font-medium"
href={`/${d.slug}/edit`}
className="ml-2 text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
{d.title}
✎
</Link>
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
</li>
)}
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
</li>
))}
</ul>
)}