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