public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
Build enterprise imports webhooks and AI admin workflows
5600eb6
.../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 += ` `;
+ }
+ }
+ }
+
+ 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;