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:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user