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
+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>
);
}
+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>
)}