NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

Build enterprise imports webhooks and AI admin workflows

5600eb6
Alex Nord <[email protected]> 3 months ago
.../src/db/migrations/002_enterprise_features.sql  |  55 ++
 apps/api/src/http/routes/ai.ts                     |  68 ++-
 apps/api/src/http/routes/attachments.ts            |   2 +-
 apps/api/src/http/routes/feedback.ts               |   8 +
 apps/api/src/http/routes/integrations.ts           | 114 +++-
 apps/api/src/http/routes/mcp.ts                    |  29 +-
 apps/api/src/http/routes/pages.ts                  |  47 +-
 apps/api/src/http/routes/search.ts                 |  11 +-
 apps/api/src/http/routes/webhooks.ts               |  86 ++-
 apps/api/src/services/ai.ts                        | 203 +++++++
 apps/api/src/services/integrations.ts              | 483 ++++++++++++++++
 apps/api/src/services/pages.ts                     |  53 +-
 apps/api/src/services/settings.ts                  |   4 +
 apps/api/src/services/webhooks.ts                  |  29 +-
 apps/api/src/utils/webhook.ts                      |   3 +
 apps/web/src/AppShell.tsx                          | 134 ++++-
 apps/web/src/components/DashboardView.tsx          | 609 +++++++++++++++++++++
 apps/web/src/components/DocsSidebar.tsx            |  16 +
 apps/web/src/docs-theme.css                        |  51 ++
 apps/web/src/lib/api.ts                            |  17 +-
 apps/worker/package.json                           |   5 +-
 apps/worker/src/index.ts                           |  90 ++-
 ledger-deploy-ui.zip                               | Bin 63455 -> 0 bytes
 ledger-deploy.zip                                  | Bin 47046 -> 0 bytes
 packages/shared/src/contracts.ts                   |  61 +++
 25 files changed, 2094 insertions(+), 84 deletions(-)
 create mode 100644 apps/api/src/db/migrations/002_enterprise_features.sql
 create mode 100644 apps/api/src/services/ai.ts
 create mode 100644 apps/api/src/services/integrations.ts
 create mode 100644 apps/web/src/components/DashboardView.tsx
 delete mode 100644 ledger-deploy-ui.zip
 delete mode 100644 ledger-deploy.zip

Diff

diff --git a/apps/api/src/db/migrations/002_enterprise_features.sql b/apps/api/src/db/migrations/002_enterprise_features.sql
new file mode 100644
index 0000000..948e386
--- /dev/null
+++ b/apps/api/src/db/migrations/002_enterprise_features.sql
@@ -0,0 +1,55 @@
+CREATE TABLE IF NOT EXISTS external_page_sources (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+  provider TEXT NOT NULL,
+  source_url TEXT,
+  source_title TEXT,
+  source_branch TEXT,
+  source_path TEXT,
+  source_document_id TEXT,
+  source_identifier TEXT NOT NULL,
+  imported_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  last_synced_at TIMESTAMPTZ,
+  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+  UNIQUE(page_id),
+  UNIQUE(provider, source_identifier)
+);
+
+CREATE TABLE IF NOT EXISTS import_jobs (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  provider TEXT NOT NULL,
+  source_label TEXT NOT NULL,
+  space_id UUID REFERENCES spaces(id) ON DELETE SET NULL,
+  status TEXT NOT NULL DEFAULT 'pending',
+  imported_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  page_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
+  imported_count INT NOT NULL DEFAULT 0,
+  error_message TEXT,
+  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS ai_answer_logs (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  question TEXT NOT NULL,
+  answer TEXT NOT NULL,
+  citations JSONB NOT NULL DEFAULT '[]'::jsonb,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+ALTER TABLE webhook_deliveries
+  ADD COLUMN IF NOT EXISTS success BOOLEAN,
+  ADD COLUMN IF NOT EXISTS error_message TEXT,
+  ADD COLUMN IF NOT EXISTS attempt_count INT NOT NULL DEFAULT 0;
+
+CREATE INDEX IF NOT EXISTS idx_external_page_sources_page_id
+  ON external_page_sources(page_id);
+
+CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at
+  ON import_jobs(created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_ai_answer_logs_created_at
+  ON ai_answer_logs(created_at DESC);
diff --git a/apps/api/src/http/routes/ai.ts b/apps/api/src/http/routes/ai.ts
index 9b6aa79..a04c9b0 100644
--- a/apps/api/src/http/routes/ai.ts
+++ b/apps/api/src/http/routes/ai.ts
@@ -1,37 +1,55 @@
 import { Router } from "express";
 import { z } from "zod";
-import { env } from "../../config/env.js";
-import { searchPages } from "../../services/search.js";
+import { requireAdmin } from "../middleware/auth.js";
+import { answerQuestion, getAiSettingsRecord, upsertAiSettings } from "../../services/ai.js";
 
 const aiAnswerSchema = z.object({
   question: z.string().min(3)
 });
 
-export const aiRouter = Router();
-
-aiRouter.post("/answers", async (req, res) => {
-  const input = aiAnswerSchema.parse(req.body);
-  const results = await searchPages(input.question, req.user ?? null);
-
-  if (results.length === 0) {
-    return res.json({
-      answer: "The knowledge base does not contain enough information to answer that confidently.",
-      citations: []
-    });
-  }
+const aiSettingsSchema = z.object({
+  provider: z.enum(["none", "openai_compatible", "anthropic_compatible"]),
+  model: z.string(),
+  apiKey: z.string().nullable(),
+  isEnabled: z.boolean()
+});
 
-  const citations = results.slice(0, 3).map((page) => ({
-    title: page.title,
-    slug: page.slug
-  }));
+export const aiRouter = Router();
 
-  const answer =
-    env.AI_PROVIDER === "none"
-      ? `I found relevant Ledger pages, but no external AI provider is configured. Start with ${citations
-          .map((citation) => citation.title)
-          .join(", ")}.`
-      : `Grounded answer generation is ready for provider ${env.AI_PROVIDER}, but the provider client is not wired in this MVP.`;
+aiRouter.get("/settings", requireAdmin, async (_req, res) => {
+  const settings = await getAiSettingsRecord();
+  return res.json({
+    settings: settings
+      ? {
+          provider: settings.provider,
+          model: settings.model,
+          isEnabled: settings.is_enabled,
+          hasApiKey: Boolean(settings.encrypted_api_key)
+        }
+      : {
+          provider: "none",
+          model: "",
+          isEnabled: false,
+          hasApiKey: false
+        }
+  });
+});
 
-  return res.json({ answer, citations });
+aiRouter.put("/settings", requireAdmin, async (req, res) => {
+  const input = aiSettingsSchema.parse(req.body);
+  const updated = await upsertAiSettings(input);
+  return res.json({
+    settings: {
+      provider: updated.provider,
+      model: updated.model,
+      isEnabled: updated.is_enabled,
+      hasApiKey: Boolean(updated.encrypted_api_key)
+    }
+  });
 });
 
+aiRouter.post("/answers", async (req, res) => {
+  const input = aiAnswerSchema.parse(req.body);
+  const result = await answerQuestion(input.question, req.user ?? null);
+  return res.json(result);
+});
diff --git a/apps/api/src/http/routes/attachments.ts b/apps/api/src/http/routes/attachments.ts
index 58afc8c..ab207cd 100644
--- a/apps/api/src/http/routes/attachments.ts
+++ b/apps/api/src/http/routes/attachments.ts
@@ -43,7 +43,7 @@ attachmentsRouter.post("/", requireEditor, async (req, res) => {
   }
 
   if (env.STORAGE_PROVIDER !== "local") {
-    return res.status(400).json({ error: "Only local storage is enabled in this MVP" });
+    return res.status(400).json({ error: "This Ledger deployment is configured for local storage only." });
   }
 
   const buffer = Buffer.from(input.base64Data, "base64");
diff --git a/apps/api/src/http/routes/feedback.ts b/apps/api/src/http/routes/feedback.ts
index 82378a5..f4854fe 100644
--- a/apps/api/src/http/routes/feedback.ts
+++ b/apps/api/src/http/routes/feedback.ts
@@ -35,6 +35,14 @@ feedbackRouter.post("/", async (req, res) => {
     feedbackId: result.rows[0].id,
     pageId: input.pageId,
     helpful: input.helpful
+  }, {
+    actor: req.user
+      ? {
+          id: req.user.id,
+          name: req.user.displayName,
+          email: req.user.email
+        }
+      : null
   });
 
   await logAudit(req.user?.id ?? null, "feedback.create", "feedback", result.rows[0].id, {
diff --git a/apps/api/src/http/routes/integrations.ts b/apps/api/src/http/routes/integrations.ts
index bb8c7a0..914348c 100644
--- a/apps/api/src/http/routes/integrations.ts
+++ b/apps/api/src/http/routes/integrations.ts
@@ -1,33 +1,111 @@
 import { Router } from "express";
 import { z } from "zod";
-import { requireAdmin } from "../middleware/auth.js";
-import { pool } from "../../db/pool.js";
+import { requireAdmin, requireEditor } from "../middleware/auth.js";
+import {
+  importGitHubDocument,
+  importGoogleDoc,
+  importMarkdownDocuments,
+  listImportJobs,
+  listIntegrations,
+  previewGitHubImport,
+  previewGoogleDocImport,
+  previewMarkdownImports,
+  upsertIntegration
+} from "../../services/integrations.js";
 
 const integrationSchema = z.object({
-  provider: z.enum(["github", "google_docs", "markdown_import"]),
   name: z.string().min(2),
   config: z.record(z.any()),
   isEnabled: z.boolean()
 });
 
+const importTargetSchema = z.object({
+  spaceId: z.string().uuid(),
+  visibility: z.enum(["public", "internal", "restricted"]),
+  state: z.enum(["draft", "published"]),
+  parentPageId: z.string().uuid().nullable().optional(),
+  allowedRoleKeys: z.array(z.enum(["owner", "admin", "editor", "viewer", "public"])).optional(),
+  allowedGroupIds: z.array(z.string().uuid()).optional()
+});
+
+const markdownPreviewSchema = z.object({
+  files: z.array(
+    z.object({
+      fileName: z.string().min(1),
+      content: z.string().min(1)
+    })
+  ).min(1)
+});
+
+const githubSchema = z.object({
+  repo: z.string().min(3),
+  branch: z.string().min(1),
+  path: z.string().min(1)
+});
+
+const googleDocsSchema = z.object({
+  documentId: z.string().min(10)
+});
+
 export const integrationsRouter = Router();
-integrationsRouter.use(requireAdmin);
 
-integrationsRouter.get("/", async (_req, res) => {
-  const result = await pool.query(`SELECT * FROM integrations ORDER BY created_at DESC`);
-  return res.json({ integrations: result.rows });
+integrationsRouter.get("/", requireAdmin, async (_req, res) => {
+  return res.json({ integrations: await listIntegrations() });
 });
 
-integrationsRouter.post("/", async (req, res) => {
+integrationsRouter.put("/:provider", requireAdmin, async (req, res) => {
+  const provider = z.enum(["github", "google_docs", "markdown_import"]).parse(req.params.provider);
   const input = integrationSchema.parse(req.body);
-  const created = await pool.query(
-    `
-      INSERT INTO integrations (provider, name, config, is_enabled)
-      VALUES ($1, $2, $3, $4)
-      RETURNING *
-    `,
-    [input.provider, input.name, JSON.stringify(input.config), input.isEnabled]
-  );
-
-  return res.status(201).json(created.rows[0]);
+  const integration = await upsertIntegration(provider, input);
+  return res.json({ integration });
+});
+
+integrationsRouter.get("/import-jobs", requireAdmin, async (_req, res) => {
+  return res.json({ jobs: await listImportJobs() });
+});
+
+integrationsRouter.post("/markdown/preview", requireEditor, async (req, res) => {
+  const input = markdownPreviewSchema.parse(req.body);
+  return res.json({ documents: previewMarkdownImports(input.files) });
+});
+
+integrationsRouter.post("/markdown/import", requireEditor, async (req, res) => {
+  const input = z.object({
+    files: markdownPreviewSchema.shape.files,
+    target: importTargetSchema
+  }).parse(req.body);
+  const result = await importMarkdownDocuments(input.files, input.target, req.user!.id);
+  return res.status(201).json(result);
+});
+
+integrationsRouter.post("/github/preview", requireEditor, async (req, res) => {
+  const input = githubSchema.parse(req.body);
+  const document = await previewGitHubImport(input.repo, input.branch, input.path);
+  return res.json({ document });
+});
+
+integrationsRouter.post("/github/import", requireEditor, async (req, res) => {
+  const input = z.object({
+    repo: githubSchema.shape.repo,
+    branch: githubSchema.shape.branch,
+    path: githubSchema.shape.path,
+    target: importTargetSchema
+  }).parse(req.body);
+  const result = await importGitHubDocument(input.repo, input.branch, input.path, input.target, req.user!.id);
+  return res.status(201).json(result);
+});
+
+integrationsRouter.post("/google-docs/preview", requireEditor, async (req, res) => {
+  const input = googleDocsSchema.parse(req.body);
+  const document = await previewGoogleDocImport(input.documentId);
+  return res.json({ document });
+});
+
+integrationsRouter.post("/google-docs/import", requireEditor, async (req, res) => {
+  const input = z.object({
+    documentId: googleDocsSchema.shape.documentId,
+    target: importTargetSchema
+  }).parse(req.body);
+  const result = await importGoogleDoc(input.documentId, input.target, req.user!.id);
+  return res.status(201).json(result);
 });
diff --git a/apps/api/src/http/routes/mcp.ts b/apps/api/src/http/routes/mcp.ts
index 564b200..f5e1795 100644
--- a/apps/api/src/http/routes/mcp.ts
+++ b/apps/api/src/http/routes/mcp.ts
@@ -2,6 +2,7 @@ import { Router } from "express";
 import { z } from "zod";
 import { createOrUpdatePage, getPageBySlug, getPageMetadata, listSpaces } from "../../services/pages.js";
 import { searchPages } from "../../services/search.js";
+import { canEditPage } from "@ledger/shared";
 
 const toolsCallSchema = z.object({
   method: z.string(),
@@ -19,11 +20,11 @@ mcpRouter.post("/", async (req, res) => {
       id: body.id,
       result: {
         tools: [
-          { name: "search_knowledge_base", description: "Search pages visible to the caller" },
-          { name: "read_page", description: "Read a page by slug if visible to the caller" },
-          { name: "list_spaces", description: "List spaces visible to the caller" },
-          { name: "get_page_metadata", description: "Get page metadata by page id" },
-          { name: "create_draft_page", description: "Create a draft page if authorized" }
+          { name: "search_knowledge_base", description: "Search knowledge base pages visible to the current auth context and return structured page summaries." },
+          { name: "read_page", description: "Read a page by slug when the current auth context is allowed to access it." },
+          { name: "list_spaces", description: "List spaces visible to the current auth context." },
+          { name: "get_page_metadata", description: "Return non-sensitive page metadata for a visible page id." },
+          { name: "create_draft_page", description: "Create a draft page in a space when the current auth context can edit content." }
         ]
       }
     });
@@ -35,30 +36,26 @@ mcpRouter.post("/", async (req, res) => {
 
     if (name === "search_knowledge_base") {
       const result = await searchPages(String(args.query ?? ""), req.user ?? null);
-      return res.json({ id: body.id, result });
+      return res.json({ id: body.id, result: { pages: result } });
     }
 
     if (name === "read_page") {
       const result = await getPageBySlug(String(args.slug ?? ""), req.user ?? null);
-      return res.json({ id: body.id, result });
+      return res.json({ id: body.id, result: result ? { page: result } : null });
     }
 
     if (name === "list_spaces") {
       const result = await listSpaces(req.user ?? null);
-      return res.json({ id: body.id, result });
+      return res.json({ id: body.id, result: { spaces: result } });
     }
 
     if (name === "get_page_metadata") {
       const result = await getPageMetadata(String(args.pageId ?? ""), req.user ?? null);
-      return res.json({ id: body.id, result });
+      return res.json({ id: body.id, result: result ? { page: result } : null });
     }
 
     if (name === "create_draft_page") {
-      if (!req.user) {
-        return res.status(401).json({ error: "Authentication required" });
-      }
-
-      if (req.user.role === "viewer" || req.user.role === "public") {
+      if (!canEditPage(req.user ?? null)) {
         return res.status(403).json({ error: "Editor access required" });
       }
 
@@ -71,10 +68,10 @@ mcpRouter.post("/", async (req, res) => {
           visibility: "internal",
           state: "draft"
         },
-        req.user.id
+        req.user!.id
       );
 
-      return res.json({ id: body.id, result });
+      return res.json({ id: body.id, result: { page: result } });
     }
   }
 
diff --git a/apps/api/src/http/routes/pages.ts b/apps/api/src/http/routes/pages.ts
index 5987d82..31fcb1d 100644
--- a/apps/api/src/http/routes/pages.ts
+++ b/apps/api/src/http/routes/pages.ts
@@ -10,6 +10,7 @@ import {
 } from "../../services/pages.js";
 import { enqueueWebhookEvent } from "../../services/webhooks.js";
 import { logAudit } from "../../services/audit.js";
+import { pool } from "../../db/pool.js";
 
 const pageSchema = z
   .object({
@@ -56,10 +57,22 @@ pagesRouter.get("/slug/:slug", async (req, res) => {
 pagesRouter.post("/", requireEditor, async (req, res) => {
   const input = pageSchema.parse(req.body);
   const result = await createOrUpdatePage(null, input, req.user!.id);
+  const workspace = await pool.query(`SELECT key FROM spaces WHERE id = $1`, [input.spaceId]);
+  const actor = {
+    id: req.user!.id,
+    name: req.user!.displayName,
+    email: req.user!.email
+  };
   await logAudit(req.user!.id, "page.create", "page", result.pageId, { slug: result.slug });
-  await enqueueWebhookEvent("page.created", { pageId: result.pageId, slug: result.slug });
+  await enqueueWebhookEvent("page.created", { pageId: result.pageId, slug: result.slug }, {
+    actor,
+    workspaceId: workspace.rows[0]?.key ?? null
+  });
   if (input.state === "published") {
-    await enqueueWebhookEvent("page.published", { pageId: result.pageId, slug: result.slug });
+    await enqueueWebhookEvent("page.published", { pageId: result.pageId, slug: result.slug }, {
+      actor,
+      workspaceId: workspace.rows[0]?.key ?? null
+    });
   }
   return res.status(201).json(result);
 });
@@ -67,8 +80,16 @@ pagesRouter.post("/", requireEditor, async (req, res) => {
 pagesRouter.put("/:pageId", requireEditor, async (req, res) => {
   const input = pageSchema.parse(req.body);
   const result = await createOrUpdatePage(req.params.pageId, input, req.user!.id);
+  const workspace = await pool.query(`SELECT key FROM spaces WHERE id = $1`, [input.spaceId]);
   await logAudit(req.user!.id, "page.update", "page", req.params.pageId, { slug: result.slug });
-  await enqueueWebhookEvent("page.updated", { pageId: req.params.pageId, slug: result.slug });
+  await enqueueWebhookEvent("page.updated", { pageId: req.params.pageId, slug: result.slug }, {
+    actor: {
+      id: req.user!.id,
+      name: req.user!.displayName,
+      email: req.user!.email
+    },
+    workspaceId: workspace.rows[0]?.key ?? null
+  });
   return res.json(result);
 });
 
@@ -90,3 +111,23 @@ pagesRouter.post("/:pageId/revisions/:revisionId/rollback", requireEditor, async
   });
   return res.json(result);
 });
+
+pagesRouter.delete("/:pageId", requireEditor, async (req, res) => {
+  const existing = await pool.query(`SELECT slug, space_id FROM pages WHERE id = $1`, [req.params.pageId]);
+  if (!existing.rowCount) {
+    return res.status(404).json({ error: "Page not found" });
+  }
+
+  await pool.query(`DELETE FROM pages WHERE id = $1`, [req.params.pageId]);
+  const workspace = await pool.query(`SELECT key FROM spaces WHERE id = $1`, [existing.rows[0].space_id]);
+  await enqueueWebhookEvent("page.deleted", { pageId: req.params.pageId, slug: existing.rows[0].slug }, {
+    actor: {
+      id: req.user!.id,
+      name: req.user!.displayName,
+      email: req.user!.email
+    },
+    workspaceId: workspace.rows[0]?.key ?? null
+  });
+  await logAudit(req.user!.id, "page.delete", "page", req.params.pageId);
+  return res.status(204).send();
+});
diff --git a/apps/api/src/http/routes/search.ts b/apps/api/src/http/routes/search.ts
index d890dc2..69314c6 100644
--- a/apps/api/src/http/routes/search.ts
+++ b/apps/api/src/http/routes/search.ts
@@ -10,7 +10,15 @@ searchRouter.get("/", async (req, res) => {
   const searchId = await recordSearch(query, req.user?.id ?? null, pages);
 
   if (pages.length === 0 && query.trim()) {
-    await enqueueWebhookEvent("search.no_results", { searchId, query });
+    await enqueueWebhookEvent("search.no_results", { searchId, query }, {
+      actor: req.user
+        ? {
+            id: req.user.id,
+            name: req.user.displayName,
+            email: req.user.email
+          }
+        : null
+    });
   }
 
   return res.json({
@@ -20,4 +28,3 @@ searchRouter.get("/", async (req, res) => {
     pages
   });
 });
-
diff --git a/apps/api/src/http/routes/webhooks.ts b/apps/api/src/http/routes/webhooks.ts
index 04b96e1..0374c5e 100644
--- a/apps/api/src/http/routes/webhooks.ts
+++ b/apps/api/src/http/routes/webhooks.ts
@@ -8,32 +8,106 @@ const webhookSchema = z.object({
   name: z.string().min(2),
   targetUrl: z.string().url(),
   signingSecret: z.string().min(8),
-  events: z.array(z.string()).min(1)
+  events: z.array(z.enum([
+    "page.created",
+    "page.updated",
+    "page.deleted",
+    "page.published",
+    "feedback.created",
+    "user.invited",
+    "search.no_results"
+  ])).min(1),
+  isActive: z.boolean().default(true)
 });
 
 export const webhooksRouter = Router();
 webhooksRouter.use(requireAdmin);
 
+function toWebhookResponse(row: Record<string, unknown>) {
+  return {
+    id: row.id,
+    name: row.name,
+    targetUrl: row.target_url,
+    isActive: row.is_active,
+    events: row.events,
+    createdAt: row.created_at
+  };
+}
+
 webhooksRouter.get("/", async (_req, res) => {
   const result = await pool.query(`SELECT * FROM webhooks ORDER BY created_at DESC`);
-  return res.json({ webhooks: result.rows });
+  return res.json({ webhooks: result.rows.map((row) => toWebhookResponse(row)) });
 });
 
 webhooksRouter.post("/", async (req, res) => {
   const input = webhookSchema.parse(req.body);
   const created = await pool.query(
     `
-      INSERT INTO webhooks (name, target_url, signing_secret, events)
-      VALUES ($1, $2, $3, $4)
+      INSERT INTO webhooks (name, target_url, signing_secret, events, is_active)
+      VALUES ($1, $2, $3, $4, $5)
       RETURNING *
     `,
-    [input.name, input.targetUrl, input.signingSecret, JSON.stringify(input.events)]
+    [input.name, input.targetUrl, input.signingSecret, JSON.stringify(input.events), input.isActive]
   );
 
   await logAudit(req.user!.id, "webhook.create", "webhook", created.rows[0].id, {
     events: input.events
   });
 
-  return res.status(201).json(created.rows[0]);
+  return res.status(201).json(toWebhookResponse(created.rows[0]));
 });
 
+webhooksRouter.put("/:webhookId", async (req, res) => {
+  const input = webhookSchema.parse(req.body);
+  const updated = await pool.query(
+    `
+      UPDATE webhooks
+      SET name = $2, target_url = $3, signing_secret = $4, events = $5, is_active = $6
+      WHERE id = $1
+      RETURNING *
+    `,
+    [
+      req.params.webhookId,
+      input.name,
+      input.targetUrl,
+      input.signingSecret,
+      JSON.stringify(input.events),
+      input.isActive
+    ]
+  );
+
+  if (!updated.rowCount) {
+    return res.status(404).json({ error: "Webhook not found" });
+  }
+
+  await logAudit(req.user!.id, "webhook.update", "webhook", req.params.webhookId, {
+    events: input.events
+  });
+
+  return res.json(toWebhookResponse(updated.rows[0]));
+});
+
+webhooksRouter.get("/:webhookId/deliveries", async (req, res) => {
+  const deliveries = await pool.query(
+    `
+      SELECT id, event_name, response_status, success, delivered_at, created_at, error_message, attempt_count
+      FROM webhook_deliveries
+      WHERE webhook_id = $1
+      ORDER BY created_at DESC
+      LIMIT 50
+    `,
+    [req.params.webhookId]
+  );
+
+  return res.json({ deliveries: deliveries.rows });
+});
+
+webhooksRouter.delete("/:webhookId", async (req, res) => {
+  const deleted = await pool.query(`DELETE FROM webhooks WHERE id = $1 RETURNING id`, [req.params.webhookId]);
+  if (!deleted.rowCount) {
+    return res.status(404).json({ error: "Webhook not found" });
+  }
+
+  await logAudit(req.user!.id, "webhook.delete", "webhook", req.params.webhookId);
+  return res.status(204).send();
+});
diff --git a/apps/api/src/services/ai.ts b/apps/api/src/services/ai.ts
new file mode 100644
index 0000000..44b859b
--- /dev/null
+++ b/apps/api/src/services/ai.ts
@@ -0,0 +1,203 @@
+import { env } from "../config/env.js";
+import { pool } from "../db/pool.js";
+import { searchPages } from "./search.js";
+import { getPageBySlug } from "./pages.js";
+import type { SessionUser } from "@ledger/shared";
+
+type ProviderConfig = {
+  provider: string;
+  model: string;
+  apiKey: string;
+  baseUrl: string | null;
+};
+
+export async function getAiSettingsRecord() {
+  const settings = await pool.query(`SELECT * FROM ai_settings ORDER BY created_at ASC LIMIT 1`);
+  return settings.rows[0] ?? null;
+}
+
+export async function upsertAiSettings(input: {
+  provider: string;
+  model: string;
+  apiKey: string | null;
+  isEnabled: boolean;
+}) {
+  const existing = await getAiSettingsRecord();
+  if (existing) {
+    const nextApiKey = input.apiKey === null ? existing.encrypted_api_key : input.apiKey;
+    const updated = await pool.query(
+      `
+        UPDATE ai_settings
+        SET provider = $2, model = $3, encrypted_api_key = $4, is_enabled = $5, updated_at = now()
+        WHERE id = $1
+        RETURNING *
+      `,
+      [existing.id, input.provider, input.model, nextApiKey, input.isEnabled]
+    );
+    return updated.rows[0];
+  }
+
+  const created = await pool.query(
+    `
+      INSERT INTO ai_settings (provider, model, encrypted_api_key, is_enabled)
+      VALUES ($1, $2, $3, $4)
+      RETURNING *
+    `,
+    [input.provider, input.model, input.apiKey, input.isEnabled]
+  );
+  return created.rows[0];
+}
+
+async function resolveProviderConfig(): Promise<ProviderConfig | null> {
+  const persisted = await getAiSettingsRecord();
+  const provider = String(persisted?.provider ?? env.AI_PROVIDER ?? "none");
+  const model = String(persisted?.model ?? env.AI_MODEL ?? "");
+  const apiKey = String(persisted?.encrypted_api_key ?? env.AI_API_KEY ?? "");
+  const isEnabled = Boolean(persisted?.is_enabled ?? provider !== "none");
+  const baseUrl = provider === "openai_compatible" ? "https://api.openai.com/v1" : null;
+
+  if (!isEnabled || provider === "none" || !model || !apiKey) {
+    return null;
+  }
+
+  return {
+    provider,
+    model,
+    apiKey,
+    baseUrl
+  };
+}
+
+async function generateWithProvider(provider: ProviderConfig, prompt: string) {
+  if (provider.provider === "openai_compatible") {
+    const response = await fetch(`${provider.baseUrl}/chat/completions`, {
+      method: "POST",
+      headers: {
+        Authorization: `Bearer ${provider.apiKey}`,
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify({
+        model: provider.model,
+        temperature: 0.2,
+        messages: [
+          {
+            role: "system",
+            content:
+              "Answer using only the provided knowledge base excerpts. If the context is insufficient, say exactly: The knowledge base does not contain enough information to answer that."
+          },
+          {
+            role: "user",
+            content: prompt
+          }
+        ]
+      })
+    });
+
+    if (!response.ok) {
+      throw new Error(`AI provider request failed with status ${response.status}`);
+    }
+
+    const body = (await response.json()) as {
+      choices?: Array<{ message?: { content?: string } }>;
+    };
+
+    return body.choices?.[0]?.message?.content?.trim() ?? "";
+  }
+
+  if (provider.provider === "anthropic_compatible") {
+    const response = await fetch("https://api.anthropic.com/v1/messages", {
+      method: "POST",
+      headers: {
+        "x-api-key": provider.apiKey,
+        "anthropic-version": "2023-06-01",
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify({
+        model: provider.model,
+        max_tokens: 500,
+        messages: [
+          {
+            role: "user",
+            content: prompt
+          }
+        ],
+        system:
+          "Answer using only the provided knowledge base excerpts. If the context is insufficient, say exactly: The knowledge base does not contain enough information to answer that."
+      })
+    });
+
+    if (!response.ok) {
+      throw new Error(`AI provider request failed with status ${response.status}`);
+    }
+
+    const body = (await response.json()) as {
+      content?: Array<{ text?: string }>;
+    };
+
+    return body.content?.map((part) => part.text ?? "").join("").trim() ?? "";
+  }
+
+  return "";
+}
+
+export async function answerQuestion(question: string, user: SessionUser | null) {
+  const pages = await searchPages(question, user);
+  if (pages.length === 0) {
+    return {
+      answer: "The knowledge base does not contain enough information to answer that.",
+      citations: [],
+      disabled: false
+    };
+  }
+
+  const provider = await resolveProviderConfig();
+  if (!provider) {
+    return {
+      answer: "",
+      citations: [],
+      disabled: true
+    };
+  }
+
+  const citations = [];
+  const contextBlocks: string[] = [];
+  for (const page of pages.slice(0, 4)) {
+    const detail = await getPageBySlug(page.slug, user);
+    if (!detail) {
+      continue;
+    }
+
+    citations.push({
+      title: detail.title,
+      slug: detail.slug
+    });
+    contextBlocks.push(`Title: ${detail.title}\nSlug: ${detail.slug}\nContent:\n${detail.bodyMarkdown.slice(0, 3000)}`);
+  }
+
+  if (contextBlocks.length === 0) {
+    return {
+      answer: "The knowledge base does not contain enough information to answer that.",
+      citations: [],
+      disabled: false
+    };
+  }
+
+  const prompt = `Question:\n${question}\n\nKnowledge base excerpts:\n\n${contextBlocks.join("\n\n---\n\n")}\n\nRespond with a concise answer grounded in the excerpts only.`;
+  const answer = await generateWithProvider(provider, prompt);
+  const normalizedAnswer =
+    answer || "The knowledge base does not contain enough information to answer that.";
+
+  await pool.query(
+    `
+      INSERT INTO ai_answer_logs (user_id, question, answer, citations)
+      VALUES ($1, $2, $3, $4)
+    `,
+    [user?.id ?? null, question, normalizedAnswer, JSON.stringify(citations)]
+  );
+
+  return {
+    answer: normalizedAnswer,
+    citations,
+    disabled: false
+  };
+}
diff --git a/apps/api/src/services/integrations.ts b/apps/api/src/services/integrations.ts
new file mode 100644
index 0000000..f5299be
--- /dev/null
+++ b/apps/api/src/services/integrations.ts
@@ -0,0 +1,483 @@
+import matter from "gray-matter";
+import type { IntegrationSummary, PageState, RoleKey, Visibility } from "@ledger/shared";
+import { pool } from "../db/pool.js";
+import { createOrUpdatePage } from "./pages.js";
+
+type ImportTarget = {
+  spaceId: string;
+  visibility: Visibility;
+  state: PageState;
+  parentPageId?: string | null;
+  allowedRoleKeys?: RoleKey[];
+  allowedGroupIds?: string[];
+};
+
+type ParsedImportDocument = {
+  title: string;
+  slug?: string;
+  excerpt?: string;
+  bodyMarkdown: string;
+  tagNames?: string[];
+  source: {
+    provider: "markdown_import" | "github" | "google_docs";
+    identifier: string;
+    url: string | null;
+    title: string | null;
+    branch?: string | null;
+    path?: string | null;
+    documentId?: string | null;
+    metadata?: Record<string, unknown>;
+  };
+};
+
+function sanitizeIntegrationConfig(provider: string, config: Record<string, unknown>) {
+  const secretKeys = ["token", "accessToken", "clientSecret", "apiKey", "signingSecret"];
+  const sanitized: Record<string, unknown> = {};
+
+  for (const [key, value] of Object.entries(config)) {
+    sanitized[key] = secretKeys.includes(key) && value ? "Configured" : value;
+  }
+
+  return sanitized;
+}
+
+function getIntegrationAvailability(provider: string, config: Record<string, unknown>) {
+  if (provider === "markdown_import") {
+    return {
+      status: "configured" as const,
+      statusMessage: "Markdown import is available."
+    };
+  }
+
+  if (provider === "github") {
+    const token = String(config.token ?? "").trim();
+    return token
+      ? {
+          status: "configured" as const,
+          statusMessage: "GitHub import is configured."
+        }
+      : {
+          status: "missing_credentials" as const,
+          statusMessage: "Add a GitHub token to enable repository imports."
+        };
+  }
+
+  const accessToken = String(config.accessToken ?? "").trim();
+  return accessToken
+    ? {
+        status: "configured" as const,
+        statusMessage: "Google Docs import is configured."
+      }
+    : {
+        status: "missing_credentials" as const,
+        statusMessage: "Add a Google access token to enable Docs imports."
+      };
+}
+
+function parsedTitleFromFrontmatter(title: string | undefined, fallback: string) {
+  const normalized = title?.trim();
+  return normalized || fallback;
+}
+
+function deriveExcerpt(bodyMarkdown: string, explicitExcerpt?: string) {
+  if (explicitExcerpt?.trim()) {
+    return explicitExcerpt.trim().slice(0, 240);
+  }
+
+  return bodyMarkdown
+    .replace(/```[\s\S]*?```/g, " ")
+    .replace(/[#>*_\-\[\]\(\)`]/g, " ")
+    .replace(/\s+/g, " ")
+    .trim()
+    .slice(0, 240);
+}
+
+function parseMarkdownDocument(content: string, fallbackTitle: string, source: ParsedImportDocument["source"]) {
+  const parsed = matter(content);
+  const frontmatter = parsed.data as Record<string, unknown>;
+  const title = parsedTitleFromFrontmatter(
+    typeof frontmatter.title === "string" ? frontmatter.title : undefined,
+    fallbackTitle
+  );
+  const excerpt = deriveExcerpt(parsed.content, typeof frontmatter.excerpt === "string" ? frontmatter.excerpt : undefined);
+  const tagNames = Array.isArray(frontmatter.tags)
+    ? frontmatter.tags.filter((tag): tag is string => typeof tag === "string")
+    : [];
+
+  return {
+    title,
+    slug: typeof frontmatter.slug === "string" ? frontmatter.slug : undefined,
+    excerpt,
+    bodyMarkdown: parsed.content.trim(),
+    tagNames,
+    source: {
+      ...source,
+      metadata: {
+        frontmatter
+      }
+    }
+  } satisfies ParsedImportDocument;
+}
+
+function formatText(text: string, style?: Record<string, unknown>) {
+  let output = text.replace(/\n/g, "");
+  if (!output) {
+    return "";
+  }
+
+  const link = style?.link as { url?: string } | undefined;
+  if (link?.url) {
+    output = `[${output}](${link.url})`;
+  }
+
+  if (style?.bold) output = `**${output}**`;
+  if (style?.italic) output = `_${output}_`;
+  if (style?.code) output = `\`${output}\``;
+  return output;
+}
+
+function googleDocsToMarkdown(doc: Record<string, unknown>) {
+  const body = (doc.body as { content?: Array<Record<string, unknown>> } | undefined)?.content ?? [];
+  const inlineObjects = (doc.inlineObjects as Record<string, { inlineObjectProperties?: { embeddedObject?: { imageProperties?: { contentUri?: string } } } }> | undefined) ?? {};
+  const lines: string[] = [];
+
+  for (const block of body) {
+    const paragraph = block.paragraph as
+      | {
+          elements?: Array<Record<string, unknown>>;
+          paragraphStyle?: { namedStyleType?: string };
+          bullet?: Record<string, unknown>;
+        }
+      | undefined;
+
+    if (!paragraph) {
+      continue;
+    }
+
+    const styleName = paragraph.paragraphStyle?.namedStyleType ?? "NORMAL_TEXT";
+    const prefix =
+      styleName === "HEADING_1"
+        ? "# "
+        : styleName === "HEADING_2"
+          ? "## "
+          : styleName === "HEADING_3"
+            ? "### "
+            : paragraph.bullet
+              ? "- "
+              : "";
+
+    let line = "";
+
+    for (const element of paragraph.elements ?? []) {
+      const textRun = element.textRun as { content?: string; textStyle?: Record<string, unknown> } | undefined;
+      if (textRun?.content) {
+        line += formatText(textRun.content, textRun.textStyle);
+      }
+
+      const inlineObjectElement = element.inlineObjectElement as { inlineObjectId?: string } | undefined;
+      if (inlineObjectElement?.inlineObjectId) {
+        const imageUri =
+          inlineObjects[inlineObjectElement.inlineObjectId]?.inlineObjectProperties?.embeddedObject?.imageProperties
+            ?.contentUri;
+        if (imageUri) {
+          line += ` ![](${imageUri})`;
+        }
+      }
+    }
+
+    const normalized = line.trim();
+    if (normalized) {
+      lines.push(`${prefix}${normalized}`);
+      lines.push("");
+    }
+  }
+
+  return lines.join("\n").trim();
+}
+
+async function fetchGitHubMarkdown(config: Record<string, unknown>, repo: string, branch: string, filePath: string) {
+  const token = String(config.token ?? "").trim();
+  if (!token) {
+    throw new Error("GitHub integration is not configured.");
+  }
+
+  const response = await fetch(
+    `https://api.github.com/repos/${repo}/contents/${filePath}?ref=${encodeURIComponent(branch)}`,
+    {
+      headers: {
+        Accept: "application/vnd.github.raw+json",
+        Authorization: `Bearer ${token}`,
+        "User-Agent": "Ledger"
+      }
+    }
+  );
+
+  if (!response.ok) {
+    throw new Error(`GitHub import failed with status ${response.status}`);
+  }
+
+  const bodyMarkdown = await response.text();
+  const fallbackTitle = filePath.split("/").pop()?.replace(/\.md$/i, "") ?? "Imported page";
+  return parseMarkdownDocument(bodyMarkdown, fallbackTitle, {
+    provider: "github",
+    identifier: `${repo}:${branch}:${filePath}`,
+    url: `https://github.com/${repo}/blob/${branch}/${filePath}`,
+    title: fallbackTitle,
+    branch,
+    path: filePath
+  });
+}
+
+async function fetchGoogleDoc(config: Record<string, unknown>, documentId: string) {
+  const accessToken = String(config.accessToken ?? "").trim();
+  if (!accessToken) {
+    throw new Error("Google Docs integration is not configured.");
+  }
+
+  const response = await fetch(`https://docs.googleapis.com/v1/documents/${documentId}`, {
+    headers: {
+      Authorization: `Bearer ${accessToken}`
+    }
+  });
+
+  if (!response.ok) {
+    throw new Error(`Google Docs import failed with status ${response.status}`);
+  }
+
+  const doc = (await response.json()) as Record<string, unknown>;
+  const bodyMarkdown = googleDocsToMarkdown(doc);
+  const title = String(doc.title ?? "Imported Google Doc");
+  return parseMarkdownDocument(bodyMarkdown, title, {
+    provider: "google_docs",
+    identifier: documentId,
+    url: `https://docs.google.com/document/d/${documentId}/edit`,
+    title,
+    documentId
+  });
+}
+
+export async function listIntegrations() {
+  const result = await pool.query(`SELECT * FROM integrations ORDER BY provider ASC, created_at DESC`);
+
+  return result.rows.map((row) => {
+    const config = row.config as Record<string, unknown>;
+    const availability = getIntegrationAvailability(row.provider, config);
+    return {
+      id: row.id,
+      provider: row.provider,
+      name: row.name,
+      isEnabled: row.is_enabled,
+      config: sanitizeIntegrationConfig(row.provider, config),
+      status: row.is_enabled ? availability.status : "disabled",
+      statusMessage: row.is_enabled ? availability.statusMessage : "Integration is disabled.",
+      createdAt: row.created_at.toISOString(),
+      updatedAt: row.updated_at.toISOString()
+    } satisfies IntegrationSummary;
+  });
+}
+
+export async function getIntegrationByProvider(provider: "github" | "google_docs" | "markdown_import") {
+  const existing = await pool.query(`SELECT * FROM integrations WHERE provider = $1 ORDER BY created_at DESC LIMIT 1`, [provider]);
+  return existing.rows[0] ?? null;
+}
+
+export async function upsertIntegration(provider: "github" | "google_docs" | "markdown_import", input: {
+  name: string;
+  config: Record<string, unknown>;
+  isEnabled: boolean;
+}) {
+  const existing = await getIntegrationByProvider(provider);
+  const mergedConfig = existing
+    ? {
+        ...(existing.config as Record<string, unknown>),
+        ...Object.fromEntries(
+          Object.entries(input.config).filter(([, value]) => value !== "")
+        )
+      }
+    : input.config;
+  if (existing) {
+    const updated = await pool.query(
+      `
+        UPDATE integrations
+        SET name = $2, config = $3, is_enabled = $4, updated_at = now()
+        WHERE id = $1
+        RETURNING *
+      `,
+      [existing.id, input.name, JSON.stringify(mergedConfig), input.isEnabled]
+    );
+    return updated.rows[0];
+  }
+
+  const created = await pool.query(
+    `
+      INSERT INTO integrations (provider, name, config, is_enabled)
+      VALUES ($1, $2, $3, $4)
+      RETURNING *
+    `,
+    [provider, input.name, JSON.stringify(mergedConfig), input.isEnabled]
+  );
+  return created.rows[0];
+}
+
+export async function listImportJobs() {
+  const result = await pool.query(`SELECT * FROM import_jobs ORDER BY created_at DESC LIMIT 50`);
+  return result.rows.map((row) => ({
+    id: row.id,
+    provider: row.provider,
+    sourceLabel: row.source_label,
+    status: row.status,
+    importedCount: row.imported_count,
+    errorMessage: row.error_message,
+    createdAt: row.created_at.toISOString(),
+    updatedAt: row.updated_at.toISOString()
+  }));
+}
+
+async function recordImportJob(provider: string, sourceLabel: string, target: ImportTarget, actorUserId: string, metadata: Record<string, unknown>) {
+  const created = await pool.query(
+    `
+      INSERT INTO import_jobs (provider, source_label, space_id, status, imported_by_user_id, metadata)
+      VALUES ($1, $2, $3, 'pending', $4, $5)
+      RETURNING id
+    `,
+    [provider, sourceLabel, target.spaceId, actorUserId, JSON.stringify(metadata)]
+  );
+  return created.rows[0].id as string;
+}
+
+async function finalizeImportJob(jobId: string, pageIds: string[]) {
+  await pool.query(
+    `
+      UPDATE import_jobs
+      SET status = 'completed', imported_count = $2, page_ids = $3, updated_at = now()
+      WHERE id = $1
+    `,
+    [jobId, pageIds.length, JSON.stringify(pageIds)]
+  );
+}
+
+async function failImportJob(jobId: string, error: string) {
+  await pool.query(
+    `
+      UPDATE import_jobs
+      SET status = 'failed', error_message = $2, updated_at = now()
+      WHERE id = $1
+    `,
+    [jobId, error]
+  );
+}
+
+async function upsertExternalSource(pageId: string, actorUserId: string, source: ParsedImportDocument["source"]) {
+  await pool.query(
+    `
+      INSERT INTO external_page_sources (
+        page_id, provider, source_url, source_title, source_branch, source_path,
+        source_document_id, source_identifier, imported_by_user_id, imported_at, last_synced_at, metadata
+      )
+      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), now(), $10)
+      ON CONFLICT (page_id) DO UPDATE
+      SET provider = EXCLUDED.provider,
+          source_url = EXCLUDED.source_url,
+          source_title = EXCLUDED.source_title,
+          source_branch = EXCLUDED.source_branch,
+          source_path = EXCLUDED.source_path,
+          source_document_id = EXCLUDED.source_document_id,
+          source_identifier = EXCLUDED.source_identifier,
+          imported_by_user_id = EXCLUDED.imported_by_user_id,
+          last_synced_at = now(),
+          metadata = EXCLUDED.metadata
+    `,
+    [
+      pageId,
+      source.provider,
+      source.url,
+      source.title,
+      source.branch ?? null,
+      source.path ?? null,
+      source.documentId ?? null,
+      source.identifier,
+      actorUserId,
+      JSON.stringify(source.metadata ?? {})
+    ]
+  );
+}
+
+async function importDocuments(documents: ParsedImportDocument[], target: ImportTarget, actorUserId: string, sourceLabel: string) {
+  const jobId = await recordImportJob(documents[0]?.source.provider ?? "markdown_import", sourceLabel, target, actorUserId, {
+    totalDocuments: documents.length
+  });
+
+  try {
+    const pageIds: string[] = [];
+    for (const document of documents) {
+      const result = await createOrUpdatePage(
+        null,
+        {
+          spaceId: target.spaceId,
+          title: document.title,
+          slug: document.slug,
+          excerpt: document.excerpt,
+          bodyMarkdown: document.bodyMarkdown,
+          visibility: target.visibility,
+          state: target.state,
+          parentPageId: target.parentPageId ?? null,
+          tagNames: document.tagNames,
+          allowedRoleKeys: target.allowedRoleKeys,
+          allowedGroupIds: target.allowedGroupIds
+        },
+        actorUserId
+      );
+
+      pageIds.push(result.pageId);
+      await upsertExternalSource(result.pageId, actorUserId, document.source);
+    }
+
+    await finalizeImportJob(jobId, pageIds);
+    return { jobId, pageIds };
+  } catch (error) {
+    await failImportJob(jobId, error instanceof Error ? error.message : "Import failed");
+    throw error;
+  }
+}
+
+export function previewMarkdownImports(files: Array<{ fileName: string; content: string }>) {
+  return files.map((file) =>
+    parseMarkdownDocument(file.content, file.fileName.replace(/\.md$/i, ""), {
+      provider: "markdown_import",
+      identifier: file.fileName,
+      url: null,
+      title: file.fileName
+    })
+  );
+}
+
+export async function importMarkdownDocuments(files: Array<{ fileName: string; content: string }>, target: ImportTarget, actorUserId: string) {
+  const documents = previewMarkdownImports(files);
+  return importDocuments(documents, target, actorUserId, files.length === 1 ? files[0].fileName : `${files.length} Markdown files`);
+}
+
+export async function previewGitHubImport(repo: string, branch: string, filePath: string) {
+  const integration = await getIntegrationByProvider("github");
+  if (!integration?.is_enabled) {
+    throw new Error("GitHub import is disabled.");
+  }
+  return fetchGitHubMarkdown(integration.config as Record<string, unknown>, repo, branch, filePath);
+}
+
+export async function importGitHubDocument(repo: string, branch: string, filePath: string, target: ImportTarget, actorUserId: string) {
+  const document = await previewGitHubImport(repo, branch, filePath);
+  return importDocuments([document], target, actorUserId, `${repo}:${branch}:${filePath}`);
+}
+
+export async function previewGoogleDocImport(documentId: string) {
+  const integration = await getIntegrationByProvider("google_docs");
+  if (!integration?.is_enabled) {
+    throw new Error("Google Docs import is disabled.");
+  }
+  return fetchGoogleDoc(integration.config as Record<string, unknown>, documentId);
+}
+
+export async function importGoogleDoc(documentId: string, target: ImportTarget, actorUserId: string) {
+  const document = await previewGoogleDocImport(documentId);
+  return importDocuments([document], target, actorUserId, `Google Doc ${documentId}`);
+}
diff --git a/apps/api/src/services/pages.ts b/apps/api/src/services/pages.ts
index 2b1e730..ccc63cf 100644
--- a/apps/api/src/services/pages.ts
+++ b/apps/api/src/services/pages.ts
@@ -20,6 +20,18 @@ type PageRecord = {
   author_name: string;
 };
 
+type SourceRow = {
+  provider: "markdown_import" | "github" | "google_docs";
+  source_url: string | null;
+  source_title: string | null;
+  source_branch: string | null;
+  source_path: string | null;
+  source_document_id: string | null;
+  imported_at: Date;
+  imported_by: string | null;
+  last_synced_at: Date | null;
+};
+
 async function getPagePermissions(pageId: string) {
   const permissions = await pool.query(
     `SELECT role_key, group_id FROM page_permissions WHERE page_id = $1`,
@@ -47,6 +59,44 @@ async function getPageTags(pageId: string): Promise<string[]> {
   return tags.rows.map((row) => row.name);
 }
 
+async function getPageSource(pageId: string) {
+  const source = await pool.query(
+    `
+      SELECT
+        eps.provider,
+        eps.source_url,
+        eps.source_title,
+        eps.source_branch,
+        eps.source_path,
+        eps.source_document_id,
+        eps.imported_at,
+        COALESCE(u.display_name, 'Unknown') AS imported_by,
+        eps.last_synced_at
+      FROM external_page_sources eps
+      LEFT JOIN users u ON u.id = eps.imported_by_user_id
+      WHERE eps.page_id = $1
+    `,
+    [pageId]
+  );
+
+  if (!source.rowCount) {
+    return null;
+  }
+
+  const row = source.rows[0] as SourceRow;
+  return {
+    provider: row.provider,
+    sourceUrl: row.source_url,
+    sourceTitle: row.source_title,
+    sourceBranch: row.source_branch,
+    sourcePath: row.source_path,
+    sourceDocumentId: row.source_document_id,
+    importedAt: row.imported_at.toISOString(),
+    importedBy: row.imported_by,
+    lastSyncedAt: row.last_synced_at?.toISOString() ?? null
+  };
+}
+
 function toSummary(row: PageRecord, tags: string[]): PageSummary {
   return {
     id: row.id,
@@ -177,7 +227,8 @@ export async function getPageBySlug(slug: string, user: SessionUser | null): Pro
     bodyHtml: rendered.bodyHtml,
     toc: rendered.toc,
     revisionId: row.revision_id,
-    authorName: row.author_name
+    authorName: row.author_name,
+    source: await getPageSource(row.id)
   };
 }
 
diff --git a/apps/api/src/services/settings.ts b/apps/api/src/services/settings.ts
index 8179d00..20a8a5b 100644
--- a/apps/api/src/services/settings.ts
+++ b/apps/api/src/services/settings.ts
@@ -70,6 +70,10 @@ export async function getAdminSettingsBundle() {
     branding,
     smtp: smtp.rows[0],
     ai: ai.rows[0],
+    mcp: {
+      endpoint: `${env.LEDGER_APP_URL.replace(':5173', ':4000')}/api/mcp`,
+      authMode: "session_cookie"
+    },
     authProviders: {
       oidc: Boolean(env.OIDC_ISSUER && env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET),
       google: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
diff --git a/apps/api/src/services/webhooks.ts b/apps/api/src/services/webhooks.ts
index 6bd4bba..34d77ad 100644
--- a/apps/api/src/services/webhooks.ts
+++ b/apps/api/src/services/webhooks.ts
@@ -1,17 +1,36 @@
+import crypto from "node:crypto";
 import { Queue } from "bullmq";
 import Redis from "ioredis";
 import { env } from "../config/env.js";
 import { pool } from "../db/pool.js";
 
-const connection = new Redis(env.REDIS_URL);
+const connection = new Redis(env.REDIS_URL, {
+  maxRetriesPerRequest: null
+});
 const queue = new Queue("ledger-jobs", { connection });
 
-export async function enqueueWebhookEvent(eventName: string, payload: Record<string, unknown>) {
+export async function enqueueWebhookEvent(
+  eventName: string,
+  data: Record<string, unknown>,
+  options?: {
+    actor?: { id: string; name: string; email: string } | null;
+    workspaceId?: string | null;
+  }
+) {
   const webhooks = await pool.query(
     `SELECT id FROM webhooks WHERE is_active = true AND events @> $1::jsonb`,
     [JSON.stringify([eventName])]
   );
 
+  const payload = {
+    id: crypto.randomUUID(),
+    event: eventName,
+    createdAt: new Date().toISOString(),
+    workspaceId: options?.workspaceId ?? null,
+    actor: options?.actor ?? null,
+    data
+  };
+
   for (const row of webhooks.rows) {
     const delivery = await pool.query(
       `INSERT INTO webhook_deliveries (webhook_id, event_name, payload)
@@ -22,6 +41,12 @@ export async function enqueueWebhookEvent(eventName: string, payload: Record<str
 
     await queue.add("webhook.deliver", {
       deliveryId: delivery.rows[0].id
+    }, {
+      attempts: 3,
+      backoff: {
+        type: "exponential",
+        delay: 5000
+      }
     });
   }
 }
diff --git a/apps/api/src/utils/webhook.ts b/apps/api/src/utils/webhook.ts
index 7b3680b..a85e2d8 100644
--- a/apps/api/src/utils/webhook.ts
+++ b/apps/api/src/utils/webhook.ts
@@ -4,3 +4,6 @@ export function signWebhook(secret: string, body: string): string {
   return crypto.createHmac("sha256", secret).update(body).digest("hex");
 }
 
+export function buildSignedWebhookBody(secret: string, timestamp: string, body: string) {
+  return signWebhook(secret, `${timestamp}.${body}`);
+}
diff --git a/apps/web/src/AppShell.tsx b/apps/web/src/AppShell.tsx
index c9cbd75..dd2d7b2 100644
--- a/apps/web/src/AppShell.tsx
+++ b/apps/web/src/AppShell.tsx
@@ -1,7 +1,17 @@
 import React, { useEffect, useMemo, useRef, useState } from "react";
 import { Link, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
-import type { PageDetail, PageSummary, SessionUser } from "@ledger/shared";
+import type {
+  AiCitation,
+  ImportJobSummary,
+  IntegrationSummary,
+  PageDetail,
+  PageSummary,
+  SessionUser,
+  WebhookDeliverySummary,
+  WebhookSummary
+} from "@ledger/shared";
 import { CommandSearch } from "./components/CommandSearch";
+import { DashboardView } from "./components/DashboardView";
 import { EmptyState } from "./components/EmptyState";
 import { FeedbackForm } from "./components/FeedbackForm";
 import { Icon } from "./components/Icon";
@@ -37,6 +47,7 @@ type Space = {
 };
 
 type PageRecordMap = Record<string, PageSummary[]>;
+type AdminFeedback = Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }>;
 
 function useSession() {
   const [user, setUser] = useState<SessionUser | null>(null);
@@ -315,6 +326,9 @@ function HomePage({
   onSearch: (query: string) => void;
 }) {
   const navigate = useNavigate();
+  const [question, setQuestion] = useState("");
+  const [answer, setAnswer] = useState<{ answer: string; citations: AiCitation[]; disabled?: boolean } | null>(null);
+  const [answerLoading, setAnswerLoading] = useState(false);
   const allPages = useMemo(
     () =>
       Object.values(pagesBySpace)
@@ -323,6 +337,29 @@ function HomePage({
     [pagesBySpace]
   );
 
+  async function askLedger(event: React.FormEvent) {
+    event.preventDefault();
+    if (!question.trim()) {
+      return;
+    }
+
+    setAnswerLoading(true);
+    try {
+      const response = await api.post<{ answer: string; citations: AiCitation[]; disabled?: boolean }>(
+        "/api/ai/answers",
+        { question }
+      );
+      setAnswer(response);
+    } catch (error) {
+      setAnswer({
+        answer: error instanceof Error ? error.message : "Could not generate an answer.",
+        citations: []
+      });
+    } finally {
+      setAnswerLoading(false);
+    }
+  }
+
   return (
     <div className="overview-layout">
       <section className="overview-hero">
@@ -387,6 +424,52 @@ function HomePage({
           </div>
         </div>
       </section>
+
+      <section className="panel">
+        <div className="panel__header">
+          <div>
+            <p className="eyebrow">AI answers</p>
+            <h3>Ask Ledger</h3>
+          </div>
+        </div>
+        <form className="stack" onSubmit={askLedger}>
+          <label className="field">
+            Ask a question
+            <input
+              value={question}
+              onChange={(event) => setQuestion(event.target.value)}
+              placeholder="How do we onboard a new teammate?"
+            />
+          </label>
+          <div className="panel__footer">
+            <button type="submit" disabled={answerLoading}>
+              {answerLoading ? "Answering..." : "Ask Ledger"}
+            </button>
+            <p className="muted">Answers are generated only from pages the current account can read.</p>
+          </div>
+        </form>
+        {answer ? (
+          <div className="answer-card">
+            {answer.disabled ? (
+              <EmptyState
+                title="AI provider not configured"
+                description="An admin can enable an OpenAI-compatible or Anthropic-compatible provider in the manage screen."
+              />
+            ) : (
+              <>
+                <p>{answer.answer}</p>
+                <div className="citation-list">
+                  {answer.citations.map((citation) => (
+                    <Link key={citation.slug} to={`/page/${citation.slug}`} className="citation-chip">
+                      {citation.title}
+                    </Link>
+                  ))}
+                </div>
+              </>
+            )}
+          </div>
+        ) : null}
+      </section>
     </div>
   );
 }
@@ -545,6 +628,12 @@ function PageView({
             <p className="doc-meta">
               Updated {new Date(page.updatedAt).toLocaleDateString()} by {page.authorName}
             </p>
+            {page.source ? (
+              <p className="doc-meta">
+                Imported from {page.source.provider.replace("_", " ")} on{" "}
+                {new Date(page.source.importedAt).toLocaleDateString()}
+              </p>
+            ) : null}
           </div>
 
           <div className="doc-actions">
@@ -597,6 +686,47 @@ function PageView({
             </div>
           </div>
         </section>
+
+        {page.source ? (
+          <section className="rail-card">
+            <p className="eyebrow">Source</p>
+            <div className="context-list">
+              <div>
+                <span>Provider</span>
+                <strong>{page.source.provider}</strong>
+              </div>
+              {page.source.sourceTitle ? (
+                <div>
+                  <span>Title</span>
+                  <strong>{page.source.sourceTitle}</strong>
+                </div>
+              ) : null}
+              {page.source.sourcePath ? (
+                <div>
+                  <span>Path</span>
+                  <strong>{page.source.sourcePath}</strong>
+                </div>
+              ) : null}
+              {page.source.sourceBranch ? (
+                <div>
+                  <span>Branch</span>
+                  <strong>{page.source.sourceBranch}</strong>
+                </div>
+              ) : null}
+              {page.source.sourceDocumentId ? (
+                <div>
+                  <span>Document ID</span>
+                  <strong>{page.source.sourceDocumentId}</strong>
+                </div>
+              ) : null}
+            </div>
+            {page.source.sourceUrl ? (
+              <a href={page.source.sourceUrl} target="_blank" rel="noreferrer" className="button-secondary inline-button">
+                Open source
+              </a>
+            ) : null}
+          </section>
+        ) : null}
       </aside>
     </div>
   );
@@ -1055,7 +1185,7 @@ export function App() {
         <Route path="/login" element={<LoginPage onLogin={setUser} />} />
         <Route path="/space/:spaceKey" element={<SpacePage spaces={spaces} pagesBySpace={pagesBySpace} />} />
         <Route path="/page/:slug" element={<PageView spaces={spaces} pagesBySpace={pagesBySpace} user={user} />} />
-        <Route path="/dashboard" element={user ? <Dashboard user={user} spaces={spaces} /> : <LoginPage onLogin={setUser} />} />
+        <Route path="/dashboard" element={user ? <DashboardView user={user} spaces={spaces} /> : <LoginPage onLogin={setUser} />} />
       </Routes>
     </DocsShell>
   );
diff --git a/apps/web/src/components/DashboardView.tsx b/apps/web/src/components/DashboardView.tsx
new file mode 100644
index 0000000..7243869
--- /dev/null
+++ b/apps/web/src/components/DashboardView.tsx
@@ -0,0 +1,609 @@
+import { useEffect, useState } from "react";
+import type {
+  ImportJobSummary,
+  IntegrationSummary,
+  SessionUser,
+  WebhookDeliverySummary,
+  WebhookSummary
+} from "@ledger/shared";
+import { api } from "../lib/api";
+import { PageEditor } from "./PageEditor";
+
+type Space = {
+  id: string;
+  name: string;
+  key: string;
+  visibility: string;
+};
+
+type AdminFeedback = Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }>;
+
+export function DashboardView({ user, spaces }: { user: SessionUser; spaces: Space[] }) {
+  const canAdmin = user.role === "admin" || user.role === "owner";
+  const canEdit = canAdmin || user.role === "editor";
+  const [analytics, setAnalytics] = useState<{
+    topSearches: Array<{ query: string; count: number }>;
+    noResults: Array<{ query: string; count: number }>;
+  } | null>(null);
+  const [feedback, setFeedback] = useState<AdminFeedback | null>(null);
+  const [integrations, setIntegrations] = useState<IntegrationSummary[]>([]);
+  const [importJobs, setImportJobs] = useState<ImportJobSummary[]>([]);
+  const [webhooks, setWebhooks] = useState<WebhookSummary[]>([]);
+  const [deliveries, setDeliveries] = useState<Record<string, WebhookDeliverySummary[]>>({});
+  const [aiSettings, setAiSettings] = useState({
+    provider: "none",
+    model: "",
+    isEnabled: false,
+    hasApiKey: false,
+    apiKey: ""
+  });
+  const [brandingForm, setBrandingForm] = useState({
+    siteName: "Ledger",
+    logoUrl: "",
+    brandColor: "#245cff",
+    footerText: "",
+    publicKnowledgeBaseEnabled: true
+  });
+  const [status, setStatus] = useState<string | null>(null);
+  const [webhookForm, setWebhookForm] = useState({
+    name: "",
+    targetUrl: "",
+    signingSecret: "",
+    isActive: true,
+    events: "page.created, page.updated"
+  });
+  const [integrationForms, setIntegrationForms] = useState<Record<string, { name: string; isEnabled: boolean; token: string; accessToken: string }>>({
+    github: { name: "GitHub", isEnabled: false, token: "", accessToken: "" },
+    google_docs: { name: "Google Docs", isEnabled: false, token: "", accessToken: "" },
+    markdown_import: { name: "Markdown import", isEnabled: true, token: "", accessToken: "" }
+  });
+  const [markdownFiles, setMarkdownFiles] = useState<Array<{ fileName: string; content: string }>>([]);
+  const [markdownPreview, setMarkdownPreview] = useState<Array<{ title: string; excerpt?: string }> | null>(null);
+  const [githubImport, setGithubImport] = useState({ repo: "", branch: "main", path: "" });
+  const [githubPreview, setGithubPreview] = useState<{ title: string; excerpt?: string } | null>(null);
+  const [googleImport, setGoogleImport] = useState({ documentId: "" });
+  const [googlePreview, setGooglePreview] = useState<{ title: string; excerpt?: string } | null>(null);
+  const [importTarget, setImportTarget] = useState({
+    spaceId: spaces[0]?.id ?? "",
+    visibility: "internal",
+    state: "draft"
+  });
+
+  async function loadAdminData() {
+    if (!canAdmin) {
+      return;
+    }
+
+    const [analyticsResponse, feedbackResponse, settingsResponse, integrationsResponse, jobsResponse, aiResponse, webhookResponse] =
+      await Promise.all([
+        api.get<{ topSearches: Array<{ query: string; count: number }>; noResults: Array<{ query: string; count: number }> }>("/api/admin/search-analytics"),
+        api.get<{ feedback: AdminFeedback }>("/api/admin/feedback"),
+        api.get<{ branding: { site_name: string; logo_url: string | null; brand_color: string; footer_text: string | null; public_knowledge_base_enabled: boolean } }>("/api/settings/admin"),
+        api.get<{ integrations: IntegrationSummary[] }>("/api/integrations"),
+        api.get<{ jobs: ImportJobSummary[] }>("/api/integrations/import-jobs"),
+        api.get<{ settings: { provider: string; model: string; isEnabled: boolean; hasApiKey: boolean } }>("/api/ai/settings"),
+        api.get<{ webhooks: WebhookSummary[] }>("/api/webhooks")
+      ]);
+
+    setAnalytics(analyticsResponse);
+    setFeedback(feedbackResponse.feedback);
+    setIntegrations(integrationsResponse.integrations);
+    setImportJobs(jobsResponse.jobs);
+    setAiSettings((current) => ({ ...current, ...aiResponse.settings, apiKey: "" }));
+    setWebhooks(webhookResponse.webhooks);
+    setBrandingForm({
+      siteName: settingsResponse.branding.site_name,
+      logoUrl: settingsResponse.branding.logo_url ?? "",
+      brandColor: settingsResponse.branding.brand_color,
+      footerText: settingsResponse.branding.footer_text ?? "",
+      publicKnowledgeBaseEnabled: settingsResponse.branding.public_knowledge_base_enabled
+    });
+  }
+
+  useEffect(() => {
+    void loadAdminData();
+  }, [canAdmin]);
+
+  async function saveBranding() {
+    await api.put("/api/settings/branding", {
+      siteName: brandingForm.siteName,
+      logoUrl: brandingForm.logoUrl || null,
+      brandColor: brandingForm.brandColor,
+      footerText: brandingForm.footerText || null,
+      publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
+    });
+    setStatus("Workspace settings saved.");
+  }
+
+  async function saveIntegration(provider: "github" | "google_docs" | "markdown_import") {
+    const form = integrationForms[provider];
+    const config =
+      provider === "github"
+        ? { token: form.token }
+        : provider === "google_docs"
+          ? { accessToken: form.accessToken }
+          : {};
+
+    await api.put(`/api/integrations/${provider}`, {
+      name: form.name,
+      isEnabled: form.isEnabled,
+      config
+    });
+    setStatus(`${provider} settings saved.`);
+    await loadAdminData();
+  }
+
+  async function loadDeliveries(webhookId: string) {
+    const response = await api.get<{ deliveries: WebhookDeliverySummary[] }>(`/api/webhooks/${webhookId}/deliveries`);
+    setDeliveries((current) => ({ ...current, [webhookId]: response.deliveries }));
+  }
+
+  async function createWebhook() {
+    await api.post("/api/webhooks", {
+      ...webhookForm,
+      events: webhookForm.events.split(",").map((value) => value.trim()).filter(Boolean)
+    });
+    setStatus("Webhook created.");
+    await loadAdminData();
+  }
+
+  async function deleteWebhook(webhookId: string) {
+    if (!window.confirm("Delete this webhook and its recent deliveries?")) return;
+    await api.delete(`/api/webhooks/${webhookId}`);
+    setStatus("Webhook deleted.");
+    await loadAdminData();
+  }
+
+  async function saveAi() {
+    await api.put("/api/ai/settings", {
+      provider: aiSettings.provider,
+      model: aiSettings.model,
+      apiKey: aiSettings.apiKey || null,
+      isEnabled: aiSettings.isEnabled
+    });
+    setStatus("AI settings saved.");
+    await loadAdminData();
+  }
+
+  async function handleMarkdownFiles(fileList: FileList | null) {
+    if (!fileList) return;
+
+    const files = await Promise.all(
+      Array.from(fileList).map(
+        (file) =>
+          new Promise<{ fileName: string; content: string }>((resolve, reject) => {
+            const reader = new FileReader();
+            reader.onload = () => resolve({ fileName: file.name, content: String(reader.result ?? "") });
+            reader.onerror = () => reject(reader.error);
+            reader.readAsText(file);
+          })
+      )
+    );
+
+    setMarkdownFiles(files);
+    const response = await api.post<{ documents: Array<{ title: string; excerpt?: string }> }>("/api/integrations/markdown/preview", {
+      files
+    });
+    setMarkdownPreview(response.documents);
+  }
+
+  async function runMarkdownImport() {
+    await api.post("/api/integrations/markdown/import", {
+      files: markdownFiles,
+      target: importTarget
+    });
+    setStatus("Markdown import completed.");
+    await loadAdminData();
+  }
+
+  async function previewGithubImport() {
+    const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/github/preview", githubImport);
+    setGithubPreview(response.document);
+  }
+
+  async function runGithubImport() {
+    await api.post("/api/integrations/github/import", {
+      ...githubImport,
+      target: importTarget
+    });
+    setStatus("GitHub import completed.");
+    await loadAdminData();
+  }
+
+  async function previewGoogleImport() {
+    const response = await api.post<{ document: { title: string; excerpt?: string } }>("/api/integrations/google-docs/preview", googleImport);
+    setGooglePreview(response.document);
+  }
+
+  async function runGoogleImport() {
+    await api.post("/api/integrations/google-docs/import", {
+      ...googleImport,
+      target: importTarget
+    });
+    setStatus("Google Docs import completed.");
+    await loadAdminData();
+  }
+
+  return (
+    <div className="manage-layout">
+      <section className="panel manage-hero">
+        <div>
+          <p className="eyebrow">Manage workspace</p>
+          <h1>{user.displayName}</h1>
+          <p className="lede">Publish docs, import trusted sources, configure AI, and control integrations with an audit-friendly admin surface.</p>
+        </div>
+        <div className="manage-hero__meta">
+          <span className="pill">{user.role}</span>
+          <span className="pill">{spaces.length} collections</span>
+        </div>
+      </section>
+
+      {status ? <p className="muted">{status}</p> : null}
+
+      <div className="manage-grid">
+        {canEdit ? <PageEditor spaces={spaces} /> : null}
+
+        <section className="panel" id="imports">
+          <div className="panel__header">
+            <div>
+              <p className="eyebrow">Imports</p>
+              <h3>Bring documentation into Ledger</h3>
+            </div>
+          </div>
+          <div className="field-grid">
+            <label className="field">
+              Target collection
+              <select value={importTarget.spaceId} onChange={(event) => setImportTarget((current) => ({ ...current, spaceId: event.target.value }))}>
+                {spaces.map((space) => (
+                  <option key={space.id} value={space.id}>
+                    {space.name}
+                  </option>
+                ))}
+              </select>
+            </label>
+            <label className="field">
+              State
+              <select value={importTarget.state} onChange={(event) => setImportTarget((current) => ({ ...current, state: event.target.value }))}>
+                <option value="draft">Draft</option>
+                <option value="published">Published</option>
+              </select>
+            </label>
+          </div>
+          <div className="field-grid">
+            <label className="field">
+              Visibility
+              <select value={importTarget.visibility} onChange={(event) => setImportTarget((current) => ({ ...current, visibility: event.target.value }))}>
+                <option value="internal">Internal</option>
+                <option value="public">Public</option>
+                <option value="restricted">Restricted</option>
+              </select>
+            </label>
+            <div className="field field-note">
+              Browser note
+              <p className="muted">Ledger supports multiple Markdown files in the browser. Folder upload support depends on the browser, so multiple-file import is the reliable default.</p>
+            </div>
+          </div>
+
+          <div className="import-grid">
+            <section className="subpanel">
+              <h4>Markdown</h4>
+              <input type="file" accept=".md,.markdown,text/markdown" multiple onChange={(event) => void handleMarkdownFiles(event.target.files)} />
+              {markdownPreview ? (
+                <div className="preview-list">
+                  {markdownPreview.map((document) => (
+                    <div key={document.title} className="preview-item">
+                      <strong>{document.title}</strong>
+                      <p>{document.excerpt ?? "No excerpt available."}</p>
+                    </div>
+                  ))}
+                  <button onClick={runMarkdownImport}>Import Markdown</button>
+                </div>
+              ) : null}
+            </section>
+
+            <section className="subpanel">
+              <h4>GitHub</h4>
+              <label className="field">
+                Repository
+                <input value={githubImport.repo} onChange={(event) => setGithubImport((current) => ({ ...current, repo: event.target.value }))} placeholder="org/repo" />
+              </label>
+              <div className="field-grid">
+                <label className="field">
+                  Branch
+                  <input value={githubImport.branch} onChange={(event) => setGithubImport((current) => ({ ...current, branch: event.target.value }))} />
+                </label>
+                <label className="field">
+                  Path
+                  <input value={githubImport.path} onChange={(event) => setGithubImport((current) => ({ ...current, path: event.target.value }))} placeholder="docs/getting-started.md" />
+                </label>
+              </div>
+              <div className="panel__footer">
+                <button className="button-secondary" onClick={previewGithubImport}>Preview</button>
+                <button onClick={runGithubImport}>Import</button>
+              </div>
+              {githubPreview ? <div className="preview-item"><strong>{githubPreview.title}</strong><p>{githubPreview.excerpt ?? "No excerpt available."}</p></div> : null}
+            </section>
+
+            <section className="subpanel">
+              <h4>Google Docs</h4>
+              <label className="field">
+                Document ID
+                <input value={googleImport.documentId} onChange={(event) => setGoogleImport({ documentId: event.target.value })} placeholder="Google Doc ID" />
+              </label>
+              <div className="panel__footer">
+                <button className="button-secondary" onClick={previewGoogleImport}>Preview</button>
+                <button onClick={runGoogleImport}>Import</button>
+              </div>
+              {googlePreview ? <div className="preview-item"><strong>{googlePreview.title}</strong><p>{googlePreview.excerpt ?? "No excerpt available."}</p></div> : null}
+            </section>
+          </div>
+        </section>
+
+        {canAdmin ? (
+          <>
+            <section className="panel" id="settings">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">Branding</p>
+                  <h3>Workspace settings</h3>
+                </div>
+              </div>
+              <label className="field">
+                Site name
+                <input value={brandingForm.siteName} onChange={(event) => setBrandingForm((current) => ({ ...current, siteName: event.target.value }))} />
+              </label>
+              <div className="field-grid">
+                <label className="field">
+                  Brand color
+                  <input value={brandingForm.brandColor} onChange={(event) => setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))} />
+                </label>
+                <label className="field">
+                  Logo URL
+                  <input value={brandingForm.logoUrl} onChange={(event) => setBrandingForm((current) => ({ ...current, logoUrl: event.target.value }))} />
+                </label>
+              </div>
+              <label className="field">
+                Footer text
+                <input value={brandingForm.footerText} onChange={(event) => setBrandingForm((current) => ({ ...current, footerText: event.target.value }))} />
+              </label>
+              <label className="checkbox-row checkbox-card">
+                <input type="checkbox" checked={brandingForm.publicKnowledgeBaseEnabled} onChange={(event) => setBrandingForm((current) => ({ ...current, publicKnowledgeBaseEnabled: event.target.checked }))} />
+                <span>Public knowledge base enabled</span>
+              </label>
+              <div className="panel__footer">
+                <button onClick={saveBranding}>Save settings</button>
+              </div>
+            </section>
+
+            <section className="panel" id="integrations">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">Integrations</p>
+                  <h3>Provider configuration</h3>
+                </div>
+              </div>
+              <div className="integration-list">
+                {(["github", "google_docs", "markdown_import"] as const).map((provider) => {
+                  const currentIntegration = integrations.find((integration) => integration.provider === provider);
+                  const form = integrationForms[provider];
+                  return (
+                    <div key={provider} className="integration-card">
+                      <div className="feedback-item__header">
+                        <strong>{provider.replace("_", " ")}</strong>
+                        <span className={`badge badge-${currentIntegration?.status === "configured" ? "public" : "restricted"}`}>
+                          {currentIntegration?.statusMessage ?? "Not configured"}
+                        </span>
+                      </div>
+                      <label className="field">
+                        Name
+                        <input value={form.name} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], name: event.target.value } }))} />
+                      </label>
+                      {provider === "github" ? (
+                        <label className="field">
+                          GitHub token
+                          <input type="password" value={form.token} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], token: event.target.value } }))} />
+                        </label>
+                      ) : null}
+                      {provider === "google_docs" ? (
+                        <label className="field">
+                          Google access token
+                          <input type="password" value={form.accessToken} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], accessToken: event.target.value } }))} />
+                        </label>
+                      ) : null}
+                      <label className="checkbox-row checkbox-card">
+                        <input type="checkbox" checked={form.isEnabled} onChange={(event) => setIntegrationForms((current) => ({ ...current, [provider]: { ...current[provider], isEnabled: event.target.checked } }))} />
+                        <span>Enable {provider.replace("_", " ")}</span>
+                      </label>
+                      <div className="panel__footer">
+                        <button onClick={() => saveIntegration(provider)}>Save integration</button>
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+            </section>
+
+            <section className="panel" id="webhooks">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">Webhooks</p>
+                  <h3>Signed event delivery</h3>
+                </div>
+              </div>
+              <div className="field-grid">
+                <label className="field">
+                  Name
+                  <input value={webhookForm.name} onChange={(event) => setWebhookForm((current) => ({ ...current, name: event.target.value }))} />
+                </label>
+                <label className="field">
+                  Target URL
+                  <input value={webhookForm.targetUrl} onChange={(event) => setWebhookForm((current) => ({ ...current, targetUrl: event.target.value }))} />
+                </label>
+              </div>
+              <label className="field">
+                Signing secret
+                <input value={webhookForm.signingSecret} onChange={(event) => setWebhookForm((current) => ({ ...current, signingSecret: event.target.value }))} />
+              </label>
+              <label className="field">
+                Events
+                <input value={webhookForm.events} onChange={(event) => setWebhookForm((current) => ({ ...current, events: event.target.value }))} />
+              </label>
+              <label className="checkbox-row checkbox-card">
+                <input type="checkbox" checked={webhookForm.isActive} onChange={(event) => setWebhookForm((current) => ({ ...current, isActive: event.target.checked }))} />
+                <span>Active</span>
+              </label>
+              <p className="muted">Ledger signs `timestamp.body` with HMAC SHA-256 and sends `X-Ledger-Event`, `X-Ledger-Timestamp`, and `X-Ledger-Signature` headers.</p>
+              <div className="panel__footer">
+                <button onClick={createWebhook}>Create webhook</button>
+              </div>
+              <div className="feedback-list">
+                {webhooks.map((webhook) => (
+                  <div key={webhook.id} className="feedback-item">
+                    <div className="feedback-item__header">
+                      <strong>{webhook.name}</strong>
+                      <span className={`badge badge-${webhook.isActive ? "public" : "restricted"}`}>{webhook.isActive ? "Active" : "Disabled"}</span>
+                    </div>
+                    <p>{webhook.targetUrl}</p>
+                    <p className="muted">{webhook.events.join(", ")}</p>
+                    <div className="panel__footer">
+                      <button className="button-secondary" onClick={() => loadDeliveries(webhook.id)}>Load deliveries</button>
+                      <button className="button-secondary" onClick={() => deleteWebhook(webhook.id)}>Delete</button>
+                    </div>
+                    {deliveries[webhook.id]?.length ? (
+                      <div className="delivery-list">
+                        {deliveries[webhook.id].map((delivery) => (
+                          <div key={delivery.id} className="delivery-item">
+                            <strong>{delivery.eventName}</strong>
+                            <span>{delivery.responseStatus ?? "pending"}</span>
+                            <span>{delivery.success ? "Success" : delivery.errorMessage ?? "Failed"}</span>
+                          </div>
+                        ))}
+                      </div>
+                    ) : null}
+                  </div>
+                ))}
+              </div>
+            </section>
+
+            <section className="panel" id="ai">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">AI settings</p>
+                  <h3>Permissions-aware answers</h3>
+                </div>
+              </div>
+              <div className="field-grid">
+                <label className="field">
+                  Provider
+                  <select value={aiSettings.provider} onChange={(event) => setAiSettings((current) => ({ ...current, provider: event.target.value }))}>
+                    <option value="none">Disabled</option>
+                    <option value="openai_compatible">OpenAI-compatible</option>
+                    <option value="anthropic_compatible">Anthropic-compatible</option>
+                  </select>
+                </label>
+                <label className="field">
+                  Model
+                  <input value={aiSettings.model} onChange={(event) => setAiSettings((current) => ({ ...current, model: event.target.value }))} />
+                </label>
+              </div>
+              <label className="field">
+                API key
+                <input type="password" value={aiSettings.apiKey} onChange={(event) => setAiSettings((current) => ({ ...current, apiKey: event.target.value }))} placeholder={aiSettings.hasApiKey ? "Configured. Enter a new key to rotate it." : "Paste provider API key"} />
+              </label>
+              <label className="checkbox-row checkbox-card">
+                <input type="checkbox" checked={aiSettings.isEnabled} onChange={(event) => setAiSettings((current) => ({ ...current, isEnabled: event.target.checked }))} />
+                <span>Enable AI answers</span>
+              </label>
+              <p className="muted">AI uses the same page visibility rules as search and document reads, so restricted content is never exposed to the wrong user.</p>
+              <div className="panel__footer">
+                <button onClick={saveAi}>Save AI settings</button>
+              </div>
+            </section>
+
+            <section className="panel" id="mcp">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">MCP</p>
+                  <h3>Ledger MCP server</h3>
+                </div>
+              </div>
+              <p className="muted">Endpoint: `/api/mcp`</p>
+              <p className="muted">Tools: `search_knowledge_base`, `read_page`, `list_spaces`, `get_page_metadata`, `create_draft_page`</p>
+              <p className="muted">Auth: same session context as the web app.</p>
+            </section>
+
+            <section className="panel">
+              <div className="panel__header">
+                <div>
+                  <p className="eyebrow">Import history</p>
+                  <h3>Recent jobs</h3>
+                </div>
+              </div>
+              <div className="feedback-list">
+                {importJobs.map((job) => (
+                  <div key={job.id} className="feedback-item">
+                    <div className="feedback-item__header">
+                      <strong>{job.sourceLabel}</strong>
+                      <span className={`badge badge-${job.status === "completed" ? "public" : "restricted"}`}>{job.status}</span>
+                    </div>
+                    <p>{job.provider} • {job.importedCount} pages</p>
+                    {job.errorMessage ? <p className="muted">{job.errorMessage}</p> : null}
+                  </div>
+                ))}
+              </div>
+            </section>
+
+            {analytics ? (
+              <section className="panel">
+                <div className="panel__header">
+                  <div>
+                    <p className="eyebrow">Analytics</p>
+                    <h3>Search performance</h3>
+                  </div>
+                </div>
+                <div className="analytics-grid">
+                  <div>
+                    <strong>Top searches</strong>
+                    {analytics.topSearches.map((item) => (
+                      <p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
+                    ))}
+                  </div>
+                  <div>
+                    <strong>No results</strong>
+                    {analytics.noResults.map((item) => (
+                      <p key={item.query}>{item.query} <span className="muted">({item.count})</span></p>
+                    ))}
+                  </div>
+                </div>
+              </section>
+            ) : null}
+
+            {feedback ? (
+              <section className="panel">
+                <div className="panel__header">
+                  <div>
+                    <p className="eyebrow">Feedback queue</p>
+                    <h3>Reader responses</h3>
+                  </div>
+                </div>
+                <div className="feedback-list">
+                  {feedback.map((item) => (
+                    <div key={item.id} className="feedback-item">
+                      <div className="feedback-item__header">
+                        <strong>{item.page_title}</strong>
+                        <span className={`badge ${item.helpful ? "badge-public" : "badge-restricted"}`}>
+                          {item.helpful ? "Helpful" : "Not helpful"}
+                        </span>
+                      </div>
+                      <p>{item.comment ?? "No comment provided."}</p>
+                    </div>
+                  ))}
+                </div>
+              </section>
+            ) : null}
+          </>
+        ) : null}
+      </div>
+    </div>
+  );
+}
diff --git a/apps/web/src/components/DocsSidebar.tsx b/apps/web/src/components/DocsSidebar.tsx
index 9454587..f534f75 100644
--- a/apps/web/src/components/DocsSidebar.tsx
+++ b/apps/web/src/components/DocsSidebar.tsx
@@ -150,6 +150,22 @@ export function DocsSidebar({
             <Icon name="settings" className="icon icon-sm" />
             <span>Manage</span>
           </Link>
+          <Link className="sidebar-nav__item" to="/dashboard#imports" onClick={() => closeSidebarOnMobile(onClose)}>
+            <Icon name="plus" className="icon icon-sm" />
+            <span>Imports</span>
+          </Link>
+          <Link className="sidebar-nav__item" to="/dashboard#integrations" onClick={() => closeSidebarOnMobile(onClose)}>
+            <Icon name="collection" className="icon icon-sm" />
+            <span>Integrations</span>
+          </Link>
+          <Link className="sidebar-nav__item" to="/dashboard#webhooks" onClick={() => closeSidebarOnMobile(onClose)}>
+            <Icon name="external" className="icon icon-sm" />
+            <span>Webhooks</span>
+          </Link>
+          <Link className="sidebar-nav__item" to="/dashboard#ai" onClick={() => closeSidebarOnMobile(onClose)}>
+            <Icon name="spark" className="icon icon-sm" />
+            <span>AI</span>
+          </Link>
         </nav>
 
         <section className="sidebar-section">
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index 2fe5623..c2c9a3c 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -878,6 +878,57 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
   place-items: center;
 }
 
+.answer-card,
+.subpanel,
+.integration-card,
+.preview-item,
+.delivery-item {
+  border: 1px solid var(--line);
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.92);
+  padding: 1rem 1.1rem;
+}
+
+.citation-list,
+.delivery-list,
+.preview-list {
+  display: grid;
+  gap: 0.75rem;
+}
+
+.citation-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 0.45rem 0.75rem;
+  border-radius: 999px;
+  background: rgba(36, 92, 255, 0.08);
+  color: var(--accent-strong);
+}
+
+.import-grid,
+.integration-list {
+  display: grid;
+  gap: 1rem;
+}
+
+.import-grid {
+  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+}
+
+.integration-list {
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.field-note p {
+  margin: 0;
+}
+
+.inline-button {
+  display: inline-flex;
+  justify-content: center;
+  margin-top: 1rem;
+}
+
 .mobile-only {
   display: none;
 }
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index f9ffd02..f6bdce7 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,13 +1,15 @@
 const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000";
 
 async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const headers = new Headers(init?.headers);
+  if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
+    headers.set("Content-Type", "application/json");
+  }
+
   const response = await fetch(`${API_BASE}${path}`, {
     ...init,
     credentials: "include",
-    headers: {
-      "Content-Type": "application/json",
-      ...(init?.headers ?? {})
-    }
+    headers
   });
 
   if (!response.ok) {
@@ -27,12 +29,15 @@ export const api = {
   post: <T>(path: string, body?: unknown) =>
     request<T>(path, {
       method: "POST",
-      body: body ? JSON.stringify(body) : undefined
+      body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined
     }),
   put: <T>(path: string, body: unknown) =>
     request<T>(path, {
       method: "PUT",
       body: JSON.stringify(body)
+    }),
+  delete: <T>(path: string) =>
+    request<T>(path, {
+      method: "DELETE"
     })
 };
-
diff --git a/apps/worker/package.json b/apps/worker/package.json
index 6459c84..b3f3695 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -10,9 +10,12 @@
   "dependencies": {
     "bullmq": "^5.12.12",
     "dotenv": "^16.4.5",
-    "ioredis": "^5.4.1"
+    "ioredis": "^5.4.1",
+    "pg": "^8.13.0"
   },
   "devDependencies": {
+    "@types/pg": "^8.11.10",
+    "@types/node": "^22.8.6",
     "tsx": "^4.19.1",
     "typescript": "^5.6.3"
   }
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 69f2c5d..649ae5a 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -1,17 +1,105 @@
 import "dotenv/config";
+import crypto from "node:crypto";
 import { Worker } from "bullmq";
 import Redis from "ioredis";
+import { Pool } from "pg";
 
 const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
   maxRetriesPerRequest: null
 });
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL ?? "postgresql://ledger:ledger@localhost:5432/ledger"
+});
+
+function buildSignedWebhookBody(secret: string, timestamp: string, body: string) {
+  return crypto.createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
+}
+
+async function deliverWebhook(deliveryId: string) {
+  const delivery = await pool.query(
+    `
+      SELECT
+        wd.id,
+        wd.event_name,
+        wd.payload,
+        wd.attempt_count,
+        w.target_url,
+        w.signing_secret,
+        w.is_active
+      FROM webhook_deliveries wd
+      JOIN webhooks w ON w.id = wd.webhook_id
+      WHERE wd.id = $1
+    `,
+    [deliveryId]
+  );
+
+  if (!delivery.rowCount) {
+    return { ignored: true };
+  }
+
+  const row = delivery.rows[0];
+  if (!row.is_active) {
+    return { skipped: true };
+  }
+
+  const payload = JSON.stringify(row.payload);
+  const timestamp = new Date().toISOString();
+  const signature = buildSignedWebhookBody(row.signing_secret, timestamp, payload);
+
+  try {
+    const response = await fetch(row.target_url, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        "X-Ledger-Event": row.event_name,
+        "X-Ledger-Timestamp": timestamp,
+        "X-Ledger-Signature": signature
+      },
+      body: payload
+    });
+
+    const responseBody = await response.text();
+    await pool.query(
+      `
+        UPDATE webhook_deliveries
+        SET response_status = $2,
+            response_body = $3,
+            success = $4,
+            delivered_at = now(),
+            error_message = NULL,
+            attempt_count = $5
+        WHERE id = $1
+      `,
+      [deliveryId, response.status, responseBody.slice(0, 5000), response.ok, Number(row.attempt_count ?? 0) + 1]
+    );
+
+    if (!response.ok) {
+      throw new Error(`Webhook returned ${response.status}`);
+    }
+
+    return { delivered: true };
+  } catch (error) {
+    await pool.query(
+      `
+        UPDATE webhook_deliveries
+        SET success = false,
+            error_message = $2,
+            delivered_at = now(),
+            attempt_count = $3
+        WHERE id = $1
+      `,
+      [deliveryId, error instanceof Error ? error.message : "Delivery failed", Number(row.attempt_count ?? 0) + 1]
+    );
+    throw error;
+  }
+}
 
 const worker = new Worker(
   "ledger-jobs",
   async (job) => {
     if (job.name === "webhook.deliver") {
       console.log(`processing webhook delivery ${job.id}`);
-      return { delivered: true };
+      return deliverWebhook(String(job.data.deliveryId));
     }
 
     if (job.name === "analytics.rollup") {
diff --git a/ledger-deploy-ui.zip b/ledger-deploy-ui.zip
deleted file mode 100644
index e96d85a..0000000
Binary files a/ledger-deploy-ui.zip and /dev/null differ
diff --git a/ledger-deploy.zip b/ledger-deploy.zip
deleted file mode 100644
index 6d801a7..0000000
Binary files a/ledger-deploy.zip and /dev/null differ
diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts
index 59fc42d..f39b253 100644
--- a/packages/shared/src/contracts.ts
+++ b/packages/shared/src/contracts.ts
@@ -37,6 +37,19 @@ export interface PageDetail extends PageSummary {
   toc: Array<{ id: string; text: string; level: number }>;
   revisionId: string;
   authorName: string;
+  source: ExternalSourceMetadata | null;
+}
+
+export interface ExternalSourceMetadata {
+  provider: "markdown_import" | "github" | "google_docs";
+  sourceUrl: string | null;
+  sourceTitle: string | null;
+  sourceBranch: string | null;
+  sourcePath: string | null;
+  sourceDocumentId: string | null;
+  importedAt: string;
+  importedBy: string | null;
+  lastSyncedAt: string | null;
 }
 
 export interface SearchResponse {
@@ -74,6 +87,54 @@ export interface AiSettings {
   enabled: boolean;
 }
 
+export interface IntegrationSummary {
+  id: string;
+  provider: "github" | "google_docs" | "markdown_import";
+  name: string;
+  isEnabled: boolean;
+  config: Record<string, unknown>;
+  status: "configured" | "missing_credentials" | "disabled";
+  statusMessage: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface ImportJobSummary {
+  id: string;
+  provider: string;
+  sourceLabel: string;
+  status: string;
+  importedCount: number;
+  errorMessage: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface WebhookSummary {
+  id: string;
+  name: string;
+  targetUrl: string;
+  isActive: boolean;
+  events: string[];
+  createdAt: string;
+}
+
+export interface WebhookDeliverySummary {
+  id: string;
+  eventName: string;
+  responseStatus: number | null;
+  success: boolean | null;
+  deliveredAt: string | null;
+  createdAt: string;
+  errorMessage: string | null;
+  attemptCount: number;
+}
+
+export interface AiCitation {
+  title: string;
+  slug: string;
+}
+
 export interface PageUpsertInput {
   spaceId: string;
   title: string;