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
First commit v2
4527dbd
.env.example | 50 +++
.gitignore | 11 +
README.md | 132 +++++++-
apps/api/package.json | 45 +++
apps/api/src/app.ts | 77 +++++
apps/api/src/config/env.ts | 39 +++
apps/api/src/db/migrate.ts | 54 ++++
apps/api/src/db/migrations/001_init.sql | 237 +++++++++++++++
apps/api/src/db/pool.ts | 7 +
apps/api/src/db/seed.ts | 153 ++++++++++
apps/api/src/express.d.ts | 11 +
apps/api/src/http/middleware/auth.ts | 56 ++++
apps/api/src/http/middleware/error-handler.ts | 15 +
apps/api/src/http/routes/admin.ts | 65 ++++
apps/api/src/http/routes/ai.ts | 37 +++
apps/api/src/http/routes/attachments.ts | 68 +++++
apps/api/src/http/routes/auth.ts | 95 ++++++
apps/api/src/http/routes/feedback.ts | 45 +++
apps/api/src/http/routes/integrations.ts | 33 ++
apps/api/src/http/routes/mcp.ts | 82 +++++
apps/api/src/http/routes/pages.ts | 92 ++++++
apps/api/src/http/routes/roles.ts | 10 +
apps/api/src/http/routes/search.ts | 23 ++
apps/api/src/http/routes/settings.ts | 41 +++
apps/api/src/http/routes/spaces.ts | 10 +
apps/api/src/http/routes/webhooks.ts | 39 +++
apps/api/src/http/types.ts | 7 +
apps/api/src/index.ts | 8 +
apps/api/src/services/audit.ts | 16 +
apps/api/src/services/auth.ts | 54 ++++
apps/api/src/services/pages.ts | 374 +++++++++++++++++++++++
apps/api/src/services/search.ts | 80 +++++
apps/api/src/services/settings.ts | 81 +++++
apps/api/src/services/webhooks.ts | 27 ++
apps/api/src/test/auth.test.ts | 23 ++
apps/api/src/test/http.test.ts | 84 ++++++
apps/api/src/test/markdown.test.ts | 19 ++
apps/api/src/test/rbac.test.ts | 28 ++
apps/api/src/test/search.test.ts | 17 ++
apps/api/src/test/webhook.test.ts | 11 +
apps/api/src/utils/markdown.ts | 71 +++++
apps/api/src/utils/slug.ts | 9 +
apps/api/src/utils/webhook.ts | 6 +
apps/api/tsconfig.json | 14 +
apps/web/index.html | 13 +
apps/web/package.json | 24 ++
apps/web/src/App.tsx | 418 ++++++++++++++++++++++++++
apps/web/src/components/FeedbackForm.tsx | 41 +++
apps/web/src/components/PageEditor.tsx | 129 ++++++++
apps/web/src/components/PageSidebar.tsx | 21 ++
apps/web/src/components/SearchBar.tsx | 28 ++
apps/web/src/lib/api.ts | 38 +++
apps/web/src/main.tsx | 13 +
apps/web/src/styles.css | 300 ++++++++++++++++++
apps/web/tsconfig.json | 13 +
apps/web/vite.config.ts | 10 +
apps/worker/package.json | 19 ++
apps/worker/src/index.ts | 33 ++
apps/worker/tsconfig.json | 10 +
docker-compose.yml | 63 ++++
infra/postgres/init.sql | 1 +
ledger-deploy.zip | Bin 0 -> 47046 bytes
package.json | 22 ++
packages/shared/package.json | 11 +
packages/shared/src/contracts.ts | 89 ++++++
packages/shared/src/index.ts | 2 +
packages/shared/src/rbac.ts | 57 ++++
packages/shared/tsconfig.json | 11 +
storage/uploads/.gitkeep | 1 +
tsconfig.base.json | 14 +
70 files changed, 3836 insertions(+), 1 deletion(-)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 apps/api/package.json
create mode 100644 apps/api/src/app.ts
create mode 100644 apps/api/src/config/env.ts
create mode 100644 apps/api/src/db/migrate.ts
create mode 100644 apps/api/src/db/migrations/001_init.sql
create mode 100644 apps/api/src/db/pool.ts
create mode 100644 apps/api/src/db/seed.ts
create mode 100644 apps/api/src/express.d.ts
create mode 100644 apps/api/src/http/middleware/auth.ts
create mode 100644 apps/api/src/http/middleware/error-handler.ts
create mode 100644 apps/api/src/http/routes/admin.ts
create mode 100644 apps/api/src/http/routes/ai.ts
create mode 100644 apps/api/src/http/routes/attachments.ts
create mode 100644 apps/api/src/http/routes/auth.ts
create mode 100644 apps/api/src/http/routes/feedback.ts
create mode 100644 apps/api/src/http/routes/integrations.ts
create mode 100644 apps/api/src/http/routes/mcp.ts
create mode 100644 apps/api/src/http/routes/pages.ts
create mode 100644 apps/api/src/http/routes/roles.ts
create mode 100644 apps/api/src/http/routes/search.ts
create mode 100644 apps/api/src/http/routes/settings.ts
create mode 100644 apps/api/src/http/routes/spaces.ts
create mode 100644 apps/api/src/http/routes/webhooks.ts
create mode 100644 apps/api/src/http/types.ts
create mode 100644 apps/api/src/index.ts
create mode 100644 apps/api/src/services/audit.ts
create mode 100644 apps/api/src/services/auth.ts
create mode 100644 apps/api/src/services/pages.ts
create mode 100644 apps/api/src/services/search.ts
create mode 100644 apps/api/src/services/settings.ts
create mode 100644 apps/api/src/services/webhooks.ts
create mode 100644 apps/api/src/test/auth.test.ts
create mode 100644 apps/api/src/test/http.test.ts
create mode 100644 apps/api/src/test/markdown.test.ts
create mode 100644 apps/api/src/test/rbac.test.ts
create mode 100644 apps/api/src/test/search.test.ts
create mode 100644 apps/api/src/test/webhook.test.ts
create mode 100644 apps/api/src/utils/markdown.ts
create mode 100644 apps/api/src/utils/slug.ts
create mode 100644 apps/api/src/utils/webhook.ts
create mode 100644 apps/api/tsconfig.json
create mode 100644 apps/web/index.html
create mode 100644 apps/web/package.json
create mode 100644 apps/web/src/App.tsx
create mode 100644 apps/web/src/components/FeedbackForm.tsx
create mode 100644 apps/web/src/components/PageEditor.tsx
create mode 100644 apps/web/src/components/PageSidebar.tsx
create mode 100644 apps/web/src/components/SearchBar.tsx
create mode 100644 apps/web/src/lib/api.ts
create mode 100644 apps/web/src/main.tsx
create mode 100644 apps/web/src/styles.css
create mode 100644 apps/web/tsconfig.json
create mode 100644 apps/web/vite.config.ts
create mode 100644 apps/worker/package.json
create mode 100644 apps/worker/src/index.ts
create mode 100644 apps/worker/tsconfig.json
create mode 100644 docker-compose.yml
create mode 100644 infra/postgres/init.sql
create mode 100644 ledger-deploy.zip
create mode 100644 package.json
create mode 100644 packages/shared/package.json
create mode 100644 packages/shared/src/contracts.ts
create mode 100644 packages/shared/src/index.ts
create mode 100644 packages/shared/src/rbac.ts
create mode 100644 packages/shared/tsconfig.json
create mode 100644 storage/uploads/.gitkeep
create mode 100644 tsconfig.base.json
Diff
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0b1a019
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,50 @@
+NODE_ENV=development
+LEDGER_APP_URL=http://localhost:5173
+LEDGER_API_URL=http://localhost:4000
+VITE_API_URL=http://localhost:4000
+PORT=4000
+
+POSTGRES_DB=ledger
+POSTGRES_USER=ledger
+POSTGRES_PASSWORD=ledger
+DATABASE_URL=postgresql://ledger:ledger@postgres:5432/ledger
+
+REDIS_URL=redis://redis:6379
+
+JWT_SECRET=change-me
+SESSION_COOKIE_NAME=ledger_session
+PASSWORD_PEPPER=change-me
+
+LOCAL_STORAGE_ROOT=/app/storage/uploads
+STORAGE_PROVIDER=local
+S3_ENDPOINT=
+S3_REGION=
+S3_BUCKET=
+S3_ACCESS_KEY_ID=
+S3_SECRET_ACCESS_KEY=
+
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASSWORD=
[email protected]
+SMTP_FROM_NAME=Ledger
+
+OIDC_ISSUER=
+OIDC_CLIENT_ID=
+OIDC_CLIENT_SECRET=
+
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+MICROSOFT_CLIENT_ID=
+MICROSOFT_CLIENT_SECRET=
+SLACK_CLIENT_ID=
+SLACK_CLIENT_SECRET=
+
+GITHUB_INTEGRATION_ENABLED=false
+GOOGLE_DOCS_INTEGRATION_ENABLED=false
+AI_PROVIDER=none
+AI_MODEL=
+AI_API_KEY=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..71e2b35
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules
+dist
+.turbo
+.env
+.env.local
+coverage
+.DS_Store
+apps/api/uploads
+storage/uploads/*
+!storage/uploads/.gitkeep
+apps/api/.data
diff --git a/README.md b/README.md
index 60d13ca..028f0d0 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,131 @@
-"# ledger"
+# Ledger
+
+Ledger is a production-minded monorepo for a secure knowledge base platform with public and internal content on the same foundation.
+
+This repository currently ships a real MVP foundation:
+
+- React frontend with public browsing, sign-in, search, page viewing, feedback, dashboard, and basic branding settings
+- Node.js API with local auth, HTTP-only cookie sessions, RBAC-aware page visibility, revisioned Markdown pages, search logging, feedback, webhooks, integrations config, MCP tools, and local attachment storage
+- PostgreSQL schema and seed data for the core entities
+- Redis-backed worker for async jobs
+- Docker Compose for local development
+- Basic tests for permission logic, markdown sanitization, auth helpers, search matching, webhook signing, and key HTTP flows
+
+## Monorepo layout
+
+- `apps/web`: React frontend
+- `apps/api`: Express API, migrations, seed data, tests
+- `apps/worker`: Redis/BullMQ worker
+- `packages/shared`: shared contracts and RBAC helpers
+- `infra/postgres`: database bootstrap
+- `storage/uploads`: local-first attachment storage
+
+## MVP scope implemented
+
+Working vertical slices:
+
+- Public and internal spaces/pages
+- Secure backend permission checks for page reads and search results
+- Local email/password auth with HTTP-only cookies
+- Built-in roles and group-aware restricted pages
+- Revisioned Markdown pages with sanitized HTML rendering and table of contents
+- Feedback capture with analytics visibility
+- Search logging and no-result webhook event generation
+- Basic admin settings for branding
+- Webhook configuration and queued delivery records
+- Local attachment upload metadata + file persistence
+- MCP endpoint for search, page read, space listing, metadata lookup, and draft creation
+
+Large areas intentionally left at foundation level for follow-up work:
+
+- OIDC and optional OAuth provider handlers
+- SMTP delivery workflows and branded email templates
+- Full import/sync pipelines for GitHub, Google Docs, and bulk Markdown
+- External AI provider adapters
+- Full webhook delivery execution and retries
+- Rich editor UX, revision diff UI, and attachment browser UI
+
+Those features already have schema support and API/config footholds where appropriate, but the MVP prioritizes a secure core over placeholder-heavy surface area.
+
+## Local development
+
+1. Copy `.env.example` to `.env`
+2. Start the stack:
+
+```bash
+docker compose up
+```
+
+Services:
+
+- Frontend: [http://localhost:5173](http://localhost:5173)
+- API: [http://localhost:4000](http://localhost:4000)
+- Postgres: `localhost:5432`
+- Redis: `localhost:6379`
+
+## Without Docker
+
+```bash
+npm install
+npm run db:migrate
+npm run db:seed
+npm run dev:api
+npm run dev:web
+npm run dev:worker
+```
+
+## Seeded accounts
+
+All seeded users use password `Password123!`.
+
+- `[email protected]`
+- `[email protected]`
+- `[email protected]`
+- `[email protected]`
+
+## Important endpoints
+
+- `POST /api/auth/login`
+- `GET /api/auth/session`
+- `GET /api/spaces`
+- `GET /api/pages/space/:spaceKey`
+- `GET /api/pages/slug/:slug`
+- `POST /api/pages`
+- `GET /api/search?q=...`
+- `POST /api/feedback`
+- `GET /api/settings/public`
+- `GET /api/settings/admin`
+- `PUT /api/settings/branding`
+- `GET /api/admin/search-analytics`
+- `GET /api/admin/feedback`
+- `POST /api/attachments`
+- `GET /api/webhooks`
+- `POST /api/webhooks`
+- `GET /api/integrations`
+- `POST /api/integrations`
+- `POST /api/ai/answers`
+- `POST /api/mcp`
+
+## Tests
+
+```bash
+npm test
+```
+
+## Security defaults in this foundation
+
+- Backend-enforced permission checks
+- Sanitized Markdown rendering
+- Secure password hashing with bcrypt
+- HTTP-only session cookies
+- Rate limiting on auth and search endpoints
+- Audit log records for key admin/editor actions
+- Secrets sourced from environment variables
+
+## Next recommended steps
+
+1. Add OIDC and optional OAuth callback flows.
+2. Replace the MVP AI fallback with a provider adapter interface and citation-aware answer generation.
+3. Execute real webhook deliveries with retry and signing headers.
+4. Add multipart attachment uploads and an attachment picker in the editor.
+5. Expand admin UI for users, groups, roles, webhooks, and integrations.
diff --git a/apps/api/package.json b/apps/api/package.json
new file mode 100644
index 0000000..6a35c57
--- /dev/null
+++ b/apps/api/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@ledger/api",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.json",
+ "db:migrate": "tsx src/db/migrate.ts",
+ "db:seed": "tsx src/db/seed.ts",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@ledger/shared": "0.1.0",
+ "bcryptjs": "^2.4.3",
+ "bullmq": "^5.12.12",
+ "cookie-parser": "^1.4.7",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.21.1",
+ "express-rate-limit": "^7.4.1",
+ "gray-matter": "^4.0.3",
+ "helmet": "^8.0.0",
+ "ioredis": "^5.4.1",
+ "jsonwebtoken": "^9.0.2",
+ "marked": "^14.1.2",
+ "pg": "^8.13.0",
+ "sanitize-html": "^2.13.1",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "@types/cookie-parser": "^1.4.8",
+ "@types/cors": "^2.8.17",
+ "@types/express": "^5.0.0",
+ "@types/jsonwebtoken": "^9.0.7",
+ "@types/node": "^22.8.6",
+ "@types/pg": "^8.11.10",
+ "supertest": "^7.0.0",
+ "tsx": "^4.19.1",
+ "typescript": "^5.6.3",
+ "vitest": "^2.1.3"
+ }
+}
+
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
new file mode 100644
index 0000000..bb40f68
--- /dev/null
+++ b/apps/api/src/app.ts
@@ -0,0 +1,77 @@
+import cookieParser from "cookie-parser";
+import cors from "cors";
+import express from "express";
+import rateLimit from "express-rate-limit";
+import helmet from "helmet";
+import { env } from "./config/env.js";
+import { sessionMiddleware } from "./http/middleware/auth.js";
+import { errorHandler } from "./http/middleware/error-handler.js";
+import { adminRouter } from "./http/routes/admin.js";
+import { aiRouter } from "./http/routes/ai.js";
+import { attachmentsRouter } from "./http/routes/attachments.js";
+import { authRouter } from "./http/routes/auth.js";
+import { feedbackRouter } from "./http/routes/feedback.js";
+import { mcpRouter } from "./http/routes/mcp.js";
+import { pagesRouter } from "./http/routes/pages.js";
+import { rolesRouter } from "./http/routes/roles.js";
+import { searchRouter } from "./http/routes/search.js";
+import { spacesRouter } from "./http/routes/spaces.js";
+import { settingsRouter } from "./http/routes/settings.js";
+import { webhooksRouter } from "./http/routes/webhooks.js";
+import { integrationsRouter } from "./http/routes/integrations.js";
+
+export function createApp() {
+ const app = express();
+
+ app.use(helmet());
+ app.use(
+ cors({
+ origin: env.LEDGER_APP_URL,
+ credentials: true
+ })
+ );
+ app.use(express.json({ limit: "1mb" }));
+ app.use(cookieParser());
+ app.use(sessionMiddleware);
+
+ app.use(
+ "/api/auth",
+ rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 20,
+ standardHeaders: true,
+ legacyHeaders: false
+ }),
+ authRouter
+ );
+
+ app.use(
+ "/api/search",
+ rateLimit({
+ windowMs: 60 * 1000,
+ max: 60,
+ standardHeaders: true,
+ legacyHeaders: false
+ }),
+ searchRouter
+ );
+
+ app.get("/health", (_req, res) => {
+ res.json({ ok: true, service: "ledger-api" });
+ });
+
+ app.use("/api/pages", pagesRouter);
+ app.use("/api/attachments", attachmentsRouter);
+ app.use("/api/spaces", spacesRouter);
+ app.use("/api/roles", rolesRouter);
+ app.use("/api/feedback", feedbackRouter);
+ app.use("/api/settings", settingsRouter);
+ app.use("/api/admin", adminRouter);
+ app.use("/api/webhooks", webhooksRouter);
+ app.use("/api/integrations", integrationsRouter);
+ app.use("/api/ai", aiRouter);
+ app.use("/api/mcp", mcpRouter);
+
+ app.use(errorHandler);
+ return app;
+}
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
new file mode 100644
index 0000000..5c44dc0
--- /dev/null
+++ b/apps/api/src/config/env.ts
@@ -0,0 +1,39 @@
+import "dotenv/config";
+import { z } from "zod";
+
+const envSchema = z.object({
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
+ PORT: z.coerce.number().default(4000),
+ LEDGER_APP_URL: z.string().url().default("http://localhost:5173"),
+ DATABASE_URL: z.string().default("postgresql://ledger:ledger@localhost:5432/ledger"),
+ REDIS_URL: z.string().default("redis://localhost:6379"),
+ JWT_SECRET: z.string().min(8).default("change-me"),
+ SESSION_COOKIE_NAME: z.string().default("ledger_session"),
+ PASSWORD_PEPPER: z.string().min(4).default("change-me"),
+ STORAGE_PROVIDER: z.enum(["local", "s3"]).default("local"),
+ LOCAL_STORAGE_ROOT: z.string().default("storage/uploads"),
+ SMTP_HOST: z.string().optional(),
+ SMTP_PORT: z.coerce.number().default(587),
+ SMTP_USER: z.string().optional(),
+ SMTP_PASSWORD: z.string().optional(),
+ SMTP_FROM_EMAIL: z.string().email().default("[email protected]"),
+ SMTP_FROM_NAME: z.string().default("Ledger"),
+ AI_PROVIDER: z.string().default("none"),
+ AI_MODEL: z.string().default(""),
+ AI_API_KEY: z.string().default(""),
+ OIDC_ISSUER: z.string().optional(),
+ OIDC_CLIENT_ID: z.string().optional(),
+ OIDC_CLIENT_SECRET: z.string().optional(),
+ GOOGLE_CLIENT_ID: z.string().optional(),
+ GOOGLE_CLIENT_SECRET: z.string().optional(),
+ DISCORD_CLIENT_ID: z.string().optional(),
+ DISCORD_CLIENT_SECRET: z.string().optional(),
+ MICROSOFT_CLIENT_ID: z.string().optional(),
+ MICROSOFT_CLIENT_SECRET: z.string().optional(),
+ SLACK_CLIENT_ID: z.string().optional(),
+ SLACK_CLIENT_SECRET: z.string().optional()
+});
+
+export const env = envSchema.parse(process.env);
+export const isProduction = env.NODE_ENV === "production";
+
diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts
new file mode 100644
index 0000000..e15cfcb
--- /dev/null
+++ b/apps/api/src/db/migrate.ts
@@ -0,0 +1,54 @@
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { pool } from "./pool.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+async function main() {
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ id SERIAL PRIMARY KEY,
+ filename TEXT NOT NULL UNIQUE,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `);
+
+ const files = (await readdir(path.join(__dirname, "migrations")))
+ .filter((file) => file.endsWith(".sql"))
+ .sort();
+
+ for (const filename of files) {
+ const existing = await pool.query(
+ "SELECT 1 FROM schema_migrations WHERE filename = $1",
+ [filename]
+ );
+
+ if (existing.rowCount) {
+ continue;
+ }
+
+ const sql = await readFile(path.join(__dirname, "migrations", filename), "utf8");
+
+ await pool.query("BEGIN");
+ try {
+ await pool.query(sql);
+ await pool.query("INSERT INTO schema_migrations (filename) VALUES ($1)", [filename]);
+ await pool.query("COMMIT");
+ console.log(`applied migration ${filename}`);
+ } catch (error) {
+ await pool.query("ROLLBACK");
+ throw error;
+ }
+ }
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await pool.end();
+ });
+
diff --git a/apps/api/src/db/migrations/001_init.sql b/apps/api/src/db/migrations/001_init.sql
new file mode 100644
index 0000000..68ca046
--- /dev/null
+++ b/apps/api/src/db/migrations/001_init.sql
@@ -0,0 +1,237 @@
+CREATE TABLE IF NOT EXISTS roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ key TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT,
+ display_name TEXT NOT NULL,
+ primary_role_id UUID NOT NULL REFERENCES roles(id),
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS group_memberships (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(user_id, group_id)
+);
+
+CREATE TABLE IF NOT EXISTS spaces (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ key TEXT NOT NULL UNIQUE,
+ description TEXT,
+ visibility TEXT NOT NULL CHECK (visibility IN ('public', 'internal', 'restricted')),
+ created_by_user_id UUID REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS pages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ parent_page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
+ title TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ excerpt TEXT,
+ visibility TEXT NOT NULL CHECK (visibility IN ('public', 'internal', 'restricted')),
+ state TEXT NOT NULL CHECK (state IN ('draft', 'published')),
+ is_public BOOLEAN NOT NULL DEFAULT false,
+ current_revision_id UUID,
+ owner_user_id UUID REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS page_revisions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ slug TEXT NOT NULL,
+ excerpt TEXT,
+ visibility TEXT NOT NULL CHECK (visibility IN ('public', 'internal', 'restricted')),
+ state TEXT NOT NULL CHECK (state IN ('draft', 'published')),
+ body_markdown TEXT NOT NULL,
+ frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb,
+ tag_snapshot JSONB NOT NULL DEFAULT '[]'::jsonb,
+ edited_by_user_id UUID REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+ALTER TABLE pages
+ ADD CONSTRAINT pages_current_revision_fk
+ FOREIGN KEY (current_revision_id) REFERENCES page_revisions(id) ON DELETE SET NULL;
+
+CREATE TABLE IF NOT EXISTS page_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ role_key TEXT REFERENCES roles(key) ON DELETE CASCADE,
+ group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CHECK (role_key IS NOT NULL OR group_id IS NOT NULL)
+);
+
+CREATE TABLE IF NOT EXISTS tags (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS page_tags (
+ page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (page_id, tag_id)
+);
+
+CREATE TABLE IF NOT EXISTS attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
+ file_name TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ storage_path TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ created_by_user_id UUID REFERENCES users(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS feedback (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ page_revision_id UUID REFERENCES page_revisions(id) ON DELETE SET NULL,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ helpful BOOLEAN NOT NULL,
+ comment TEXT,
+ is_reviewed BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS searches (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ query TEXT NOT NULL,
+ visible_scope TEXT NOT NULL,
+ results_count INT NOT NULL DEFAULT 0,
+ resulted_in_support_action BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS search_results (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ search_id UUID NOT NULL REFERENCES searches(id) ON DELETE CASCADE,
+ page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
+ position INT NOT NULL,
+ clicked BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS webhooks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ target_url TEXT NOT NULL,
+ signing_secret TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ events JSONB NOT NULL DEFAULT '[]'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS webhook_deliveries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
+ event_name TEXT NOT NULL,
+ payload JSONB NOT NULL,
+ response_status INT,
+ response_body TEXT,
+ delivered_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS integrations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ provider TEXT NOT NULL,
+ name TEXT NOT NULL,
+ config JSONB NOT NULL DEFAULT '{}'::jsonb,
+ is_enabled BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS integration_sync_jobs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
+ source_identifier TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ 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 smtp_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ host TEXT,
+ port INT NOT NULL DEFAULT 587,
+ username TEXT,
+ password_encrypted TEXT,
+ from_email TEXT NOT NULL,
+ from_name TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS branding_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ site_name TEXT NOT NULL DEFAULT 'Ledger',
+ logo_url TEXT,
+ brand_color TEXT NOT NULL DEFAULT '#245cff',
+ footer_text TEXT,
+ public_knowledge_base_enabled BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS ai_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ provider TEXT NOT NULL DEFAULT 'none',
+ model TEXT NOT NULL DEFAULT '',
+ encrypted_api_key TEXT,
+ is_enabled BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT NOT NULL,
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_pages_space_id ON pages(space_id);
+CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id ON pages(parent_page_id);
+CREATE INDEX IF NOT EXISTS idx_pages_visibility_state ON pages(visibility, state);
+CREATE INDEX IF NOT EXISTS idx_page_revisions_page_id ON page_revisions(page_id);
+CREATE INDEX IF NOT EXISTS idx_feedback_page_id ON feedback(page_id);
+CREATE INDEX IF NOT EXISTS idx_searches_query ON searches(query);
+CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON webhook_deliveries(webhook_id);
+CREATE INDEX IF NOT EXISTS idx_integration_sync_jobs_integration_id ON integration_sync_jobs(integration_id);
+
+CREATE INDEX IF NOT EXISTS idx_page_revisions_search
+ ON page_revisions
+ USING GIN (to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body_markdown, '')));
diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts
new file mode 100644
index 0000000..8299a41
--- /dev/null
+++ b/apps/api/src/db/pool.ts
@@ -0,0 +1,7 @@
+import { Pool } from "pg";
+import { env } from "../config/env.js";
+
+export const pool = new Pool({
+ connectionString: env.DATABASE_URL
+});
+
diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts
new file mode 100644
index 0000000..f891e9a
--- /dev/null
+++ b/apps/api/src/db/seed.ts
@@ -0,0 +1,153 @@
+import { hashPassword } from "../services/auth.js";
+import { pool } from "./pool.js";
+
+async function main() {
+ const roleKeys = [
+ ["owner", "Owner"],
+ ["admin", "Admin"],
+ ["editor", "Editor"],
+ ["viewer", "Viewer"],
+ ["public", "Public"]
+ ];
+
+ for (const [key, name] of roleKeys) {
+ await pool.query(
+ `INSERT INTO roles (key, name) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
+ [key, name]
+ );
+ }
+
+ await pool.query(
+ `INSERT INTO groups (name, description) VALUES ('engineering', 'Internal engineering team')
+ ON CONFLICT (name) DO NOTHING`
+ );
+
+ const roles = await pool.query(`SELECT id, key FROM roles`);
+ const roleMap = new Map(roles.rows.map((row) => [row.key, row.id]));
+
+ const users = [
+ ["[email protected]", "Ledger Owner", "owner"],
+ ["[email protected]", "Ledger Admin", "admin"],
+ ["[email protected]", "Ledger Editor", "editor"],
+ ["[email protected]", "Ledger Viewer", "viewer"]
+ ];
+
+ for (const [email, displayName, roleKey] of users) {
+ await pool.query(
+ `
+ INSERT INTO users (email, password_hash, display_name, primary_role_id)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (email) DO NOTHING
+ `,
+ [email, await hashPassword("Password123!"), displayName, roleMap.get(roleKey)]
+ );
+ }
+
+ const owner = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
+ const editor = await pool.query(`SELECT id FROM users WHERE email = '[email protected]'`);
+ const engineeringGroup = await pool.query(`SELECT id FROM groups WHERE name = 'engineering'`);
+
+ await pool.query(
+ `INSERT INTO group_memberships (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [editor.rows[0].id, engineeringGroup.rows[0].id]
+ );
+
+ await pool.query(
+ `
+ INSERT INTO spaces (name, key, description, visibility, created_by_user_id)
+ VALUES
+ ('Docs', 'docs', 'Public product documentation', 'public', $1),
+ ('Ops', 'ops', 'Internal runbooks and procedures', 'internal', $1)
+ ON CONFLICT (key) DO NOTHING
+ `,
+ [owner.rows[0].id]
+ );
+
+ const docsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'docs'`);
+ const opsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'ops'`);
+
+ const existingPublic = await pool.query(`SELECT id FROM pages WHERE slug = 'welcome-to-ledger'`);
+ if (!existingPublic.rowCount) {
+ const page = await pool.query(
+ `
+ INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
+ VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', true, $2)
+ RETURNING id
+ `,
+ [docsSpace.rows[0].id, owner.rows[0].id]
+ );
+
+ const revision = await pool.query(
+ `
+ INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id)
+ VALUES ($1, 'Welcome to Ledger', 'welcome-to-ledger', 'Ledger helps teams publish trusted answers.', 'public', 'published', '# Welcome to Ledger\n\nLedger is a secure knowledge base for public docs and internal answers.\n\n## What you can do\n\n- Publish public docs\n- Keep internal pages permission-aware\n- Search across trusted knowledge', $2)
+ RETURNING id
+ `,
+ [page.rows[0].id, owner.rows[0].id]
+ );
+
+ await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [page.rows[0].id, revision.rows[0].id]);
+ }
+
+ const existingInternal = await pool.query(`SELECT id FROM pages WHERE slug = 'incident-runbook'`);
+ if (!existingInternal.rowCount) {
+ const page = await pool.query(
+ `
+ INSERT INTO pages (space_id, title, slug, excerpt, visibility, state, is_public, owner_user_id)
+ VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', false, $2)
+ RETURNING id
+ `,
+ [opsSpace.rows[0].id, owner.rows[0].id]
+ );
+
+ const revision = await pool.query(
+ `
+ INSERT INTO page_revisions (page_id, title, slug, excerpt, visibility, state, body_markdown, edited_by_user_id, tag_snapshot)
+ VALUES ($1, 'Incident Runbook', 'incident-runbook', 'Internal escalation workflow for incidents.', 'restricted', 'published', '# Incident Runbook\n\nUse this page when triaging a Sev-1 incident.\n\n## First 10 minutes\n\n- Declare incident commander\n- Open incident channel\n- Update status page', $2, '["internal","ops"]'::jsonb)
+ RETURNING id
+ `,
+ [page.rows[0].id, owner.rows[0].id]
+ );
+
+ await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [page.rows[0].id, revision.rows[0].id]);
+ await pool.query(`INSERT INTO page_permissions (page_id, group_id) VALUES ($1, $2)`, [
+ page.rows[0].id,
+ engineeringGroup.rows[0].id
+ ]);
+ }
+
+ await pool.query(
+ `
+ INSERT INTO branding_settings (site_name, logo_url, brand_color, footer_text, public_knowledge_base_enabled)
+ SELECT 'Ledger', NULL, '#245cff', 'Built for fast, trusted answers.', true
+ WHERE NOT EXISTS (SELECT 1 FROM branding_settings)
+ `
+ );
+
+ await pool.query(
+ `
+ INSERT INTO smtp_settings (host, port, username, from_email, from_name)
+ SELECT NULL, 587, NULL, '[email protected]', 'Ledger'
+ WHERE NOT EXISTS (SELECT 1 FROM smtp_settings)
+ `
+ );
+
+ await pool.query(
+ `
+ INSERT INTO ai_settings (provider, model, is_enabled)
+ SELECT 'none', '', false
+ WHERE NOT EXISTS (SELECT 1 FROM ai_settings)
+ `
+ );
+
+ console.log("Seeded Ledger demo data");
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await pool.end();
+ });
diff --git a/apps/api/src/express.d.ts b/apps/api/src/express.d.ts
new file mode 100644
index 0000000..784e5b9
--- /dev/null
+++ b/apps/api/src/express.d.ts
@@ -0,0 +1,11 @@
+import type { SessionUser } from "@ledger/shared";
+
+declare global {
+ namespace Express {
+ interface Request {
+ user: SessionUser | null;
+ }
+ }
+}
+
+export {};
diff --git a/apps/api/src/http/middleware/auth.ts b/apps/api/src/http/middleware/auth.ts
new file mode 100644
index 0000000..d6b875e
--- /dev/null
+++ b/apps/api/src/http/middleware/auth.ts
@@ -0,0 +1,56 @@
+import type { NextFunction, Response } from "express";
+import { canEditPage, canManageSettings, hasRole } from "@ledger/shared";
+import { env } from "../../config/env.js";
+import { getUserForSession, verifySessionToken } from "../../services/auth.js";
+import type { AppRequest } from "../types.js";
+
+export async function sessionMiddleware(req: AppRequest, _res: Response, next: NextFunction) {
+ const token = req.cookies?.[env.SESSION_COOKIE_NAME];
+ req.user = null;
+
+ if (!token) {
+ return next();
+ }
+
+ try {
+ const payload = verifySessionToken(token);
+ req.user = await getUserForSession(payload.id);
+ } catch {
+ req.user = null;
+ }
+
+ next();
+}
+
+export function requireAuthenticated(req: AppRequest, res: Response, next: NextFunction) {
+ if (!req.user) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ next();
+}
+
+export function requireEditor(req: AppRequest, res: Response, next: NextFunction) {
+ if (!canEditPage(req.user)) {
+ return res.status(403).json({ error: "Editor access required" });
+ }
+
+ next();
+}
+
+export function requireAdmin(req: AppRequest, res: Response, next: NextFunction) {
+ if (!canManageSettings(req.user)) {
+ return res.status(403).json({ error: "Admin access required" });
+ }
+
+ next();
+}
+
+export function requireViewer(req: AppRequest, res: Response, next: NextFunction) {
+ if (!hasRole(req.user, "viewer")) {
+ return res.status(403).json({ error: "Viewer access required" });
+ }
+
+ next();
+}
+
diff --git a/apps/api/src/http/middleware/error-handler.ts b/apps/api/src/http/middleware/error-handler.ts
new file mode 100644
index 0000000..3703e0f
--- /dev/null
+++ b/apps/api/src/http/middleware/error-handler.ts
@@ -0,0 +1,15 @@
+import type { NextFunction, Request, Response } from "express";
+import { ZodError } from "zod";
+
+export function errorHandler(error: unknown, _req: Request, res: Response, _next: NextFunction) {
+ if (error instanceof ZodError) {
+ return res.status(400).json({
+ error: "Validation failed",
+ details: error.flatten()
+ });
+ }
+
+ console.error(error);
+ return res.status(500).json({ error: "Internal server error" });
+}
+
diff --git a/apps/api/src/http/routes/admin.ts b/apps/api/src/http/routes/admin.ts
new file mode 100644
index 0000000..67db366
--- /dev/null
+++ b/apps/api/src/http/routes/admin.ts
@@ -0,0 +1,65 @@
+import { Router } from "express";
+import { requireAdmin } from "../middleware/auth.js";
+import { pool } from "../../db/pool.js";
+
+export const adminRouter = Router();
+adminRouter.use(requireAdmin);
+
+adminRouter.get("/users", async (_req, res) => {
+ const result = await pool.query(
+ `
+ SELECT u.id, u.email, u.display_name, r.key AS role_key
+ FROM users u
+ JOIN roles r ON r.id = u.primary_role_id
+ ORDER BY u.created_at ASC
+ `
+ );
+ return res.json({ users: result.rows });
+});
+
+adminRouter.get("/groups", async (_req, res) => {
+ const result = await pool.query(`SELECT * FROM groups ORDER BY name ASC`);
+ return res.json({ groups: result.rows });
+});
+
+adminRouter.get("/feedback", async (_req, res) => {
+ const result = await pool.query(
+ `
+ SELECT f.*, p.title AS page_title
+ FROM feedback f
+ JOIN pages p ON p.id = f.page_id
+ ORDER BY f.created_at DESC
+ LIMIT 100
+ `
+ );
+ return res.json({ feedback: result.rows });
+});
+
+adminRouter.get("/search-analytics", async (_req, res) => {
+ const topSearches = await pool.query(
+ `
+ SELECT query, COUNT(*)::int AS count
+ FROM searches
+ GROUP BY query
+ ORDER BY count DESC, query ASC
+ LIMIT 10
+ `
+ );
+
+ const noResults = await pool.query(
+ `
+ SELECT query, COUNT(*)::int AS count
+ FROM searches
+ WHERE results_count = 0
+ GROUP BY query
+ ORDER BY count DESC, query ASC
+ LIMIT 10
+ `
+ );
+
+ return res.json({
+ topSearches: topSearches.rows,
+ noResults: noResults.rows
+ });
+});
+
diff --git a/apps/api/src/http/routes/ai.ts b/apps/api/src/http/routes/ai.ts
new file mode 100644
index 0000000..9b6aa79
--- /dev/null
+++ b/apps/api/src/http/routes/ai.ts
@@ -0,0 +1,37 @@
+import { Router } from "express";
+import { z } from "zod";
+import { env } from "../../config/env.js";
+import { searchPages } from "../../services/search.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 citations = results.slice(0, 3).map((page) => ({
+ title: page.title,
+ slug: page.slug
+ }));
+
+ 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.`;
+
+ return res.json({ answer, citations });
+});
+
diff --git a/apps/api/src/http/routes/attachments.ts b/apps/api/src/http/routes/attachments.ts
new file mode 100644
index 0000000..58afc8c
--- /dev/null
+++ b/apps/api/src/http/routes/attachments.ts
@@ -0,0 +1,68 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import crypto from "node:crypto";
+import { Router } from "express";
+import { z } from "zod";
+import { requireEditor } from "../middleware/auth.js";
+import { env } from "../../config/env.js";
+import { pool } from "../../db/pool.js";
+import { getPageMetadata } from "../../services/pages.js";
+
+const attachmentSchema = z.object({
+ pageId: z.string().uuid(),
+ fileName: z.string().min(1),
+ contentType: z.string().min(3),
+ base64Data: z.string().min(1)
+});
+
+export const attachmentsRouter = Router();
+
+attachmentsRouter.get("/", async (req, res) => {
+ const pageId = String(req.query.pageId ?? "");
+ const page = await getPageMetadata(pageId, req.user ?? null);
+ if (!page) {
+ return res.status(404).json({ error: "Page not found" });
+ }
+
+ const result = await pool.query(
+ `SELECT id, page_id, file_name, content_type, storage_path, size_bytes, created_at
+ FROM attachments
+ WHERE page_id = $1
+ ORDER BY created_at DESC`,
+ [pageId]
+ );
+
+ return res.json({ attachments: result.rows });
+});
+
+attachmentsRouter.post("/", requireEditor, async (req, res) => {
+ const input = attachmentSchema.parse(req.body);
+ const page = await getPageMetadata(input.pageId, req.user ?? null);
+ if (!page) {
+ return res.status(404).json({ error: "Page not found" });
+ }
+
+ if (env.STORAGE_PROVIDER !== "local") {
+ return res.status(400).json({ error: "Only local storage is enabled in this MVP" });
+ }
+
+ const buffer = Buffer.from(input.base64Data, "base64");
+ const fileId = crypto.randomUUID();
+ const ext = path.extname(input.fileName);
+ const fileName = `${fileId}${ext}`;
+
+ await mkdir(env.LOCAL_STORAGE_ROOT, { recursive: true });
+ const storagePath = path.join(env.LOCAL_STORAGE_ROOT, fileName);
+ await writeFile(storagePath, buffer);
+
+ const created = await pool.query(
+ `
+ INSERT INTO attachments (page_id, file_name, content_type, storage_path, size_bytes, created_by_user_id)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, file_name, storage_path, size_bytes
+ `,
+ [input.pageId, input.fileName, input.contentType, storagePath, buffer.length, req.user!.id]
+ );
+
+ return res.status(201).json(created.rows[0]);
+});
diff --git a/apps/api/src/http/routes/auth.ts b/apps/api/src/http/routes/auth.ts
new file mode 100644
index 0000000..d3bccc2
--- /dev/null
+++ b/apps/api/src/http/routes/auth.ts
@@ -0,0 +1,95 @@
+import { Router } from "express";
+import { z } from "zod";
+import { createSessionToken, getUserForSession, hashPassword, verifyPassword } from "../../services/auth.js";
+import { pool } from "../../db/pool.js";
+import { env, isProduction } from "../../config/env.js";
+import { logAudit } from "../../services/audit.js";
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8)
+});
+
+const registerSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+ displayName: z.string().min(2)
+});
+
+export const authRouter = Router();
+
+authRouter.post("/register", async (req, res) => {
+ const input = registerSchema.parse(req.body);
+ const existing = await pool.query(`SELECT id FROM users WHERE email = $1`, [input.email]);
+
+ if (existing.rowCount) {
+ return res.status(409).json({ error: "User already exists" });
+ }
+
+ const role = await pool.query(`SELECT id FROM roles WHERE key = 'viewer'`);
+ const user = await pool.query(
+ `
+ INSERT INTO users (email, password_hash, display_name, primary_role_id)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id
+ `,
+ [input.email, await hashPassword(input.password), input.displayName, role.rows[0].id]
+ );
+
+ const sessionUser = await getUserForSession(user.rows[0].id);
+ const token = createSessionToken(sessionUser!);
+
+ res.cookie(env.SESSION_COOKIE_NAME, token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: isProduction
+ });
+
+ await logAudit(sessionUser!.id, "auth.register", "user", sessionUser!.id, {
+ email: sessionUser!.email
+ });
+
+ return res.status(201).json({ user: sessionUser });
+});
+
+authRouter.post("/login", async (req, res) => {
+ const input = loginSchema.parse(req.body);
+ const user = await pool.query(
+ `
+ SELECT u.id, u.password_hash
+ FROM users u
+ WHERE u.email = $1 AND u.is_active = true
+ `,
+ [input.email]
+ );
+
+ if (!user.rowCount || !user.rows[0].password_hash) {
+ return res.status(401).json({ error: "Invalid credentials" });
+ }
+
+ const passwordValid = await verifyPassword(input.password, user.rows[0].password_hash);
+ if (!passwordValid) {
+ return res.status(401).json({ error: "Invalid credentials" });
+ }
+
+ const sessionUser = await getUserForSession(user.rows[0].id);
+ const token = createSessionToken(sessionUser!);
+
+ res.cookie(env.SESSION_COOKIE_NAME, token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: isProduction
+ });
+
+ await logAudit(sessionUser!.id, "auth.login", "user", sessionUser!.id);
+ return res.json({ user: sessionUser });
+});
+
+authRouter.post("/logout", async (_req, res) => {
+ res.clearCookie(env.SESSION_COOKIE_NAME);
+ return res.status(204).send();
+});
+
+authRouter.get("/session", async (req, res) => {
+ return res.json({ user: req.user ?? null });
+});
diff --git a/apps/api/src/http/routes/feedback.ts b/apps/api/src/http/routes/feedback.ts
new file mode 100644
index 0000000..82378a5
--- /dev/null
+++ b/apps/api/src/http/routes/feedback.ts
@@ -0,0 +1,45 @@
+import { Router } from "express";
+import { z } from "zod";
+import { pool } from "../../db/pool.js";
+import { enqueueWebhookEvent } from "../../services/webhooks.js";
+import { logAudit } from "../../services/audit.js";
+
+const feedbackSchema = z.object({
+ pageId: z.string().uuid(),
+ revisionId: z.string().uuid().optional(),
+ helpful: z.boolean(),
+ comment: z.string().max(1000).optional()
+});
+
+export const feedbackRouter = Router();
+
+feedbackRouter.post("/", async (req, res) => {
+ const input = feedbackSchema.parse(req.body);
+
+ const result = await pool.query(
+ `
+ INSERT INTO feedback (page_id, page_revision_id, user_id, helpful, comment)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id
+ `,
+ [
+ input.pageId,
+ input.revisionId ?? null,
+ req.user?.id ?? null,
+ input.helpful,
+ input.comment ?? null
+ ]
+ );
+
+ await enqueueWebhookEvent("feedback.created", {
+ feedbackId: result.rows[0].id,
+ pageId: input.pageId,
+ helpful: input.helpful
+ });
+
+ await logAudit(req.user?.id ?? null, "feedback.create", "feedback", result.rows[0].id, {
+ pageId: input.pageId
+ });
+
+ return res.status(201).json({ feedbackId: result.rows[0].id });
+});
diff --git a/apps/api/src/http/routes/integrations.ts b/apps/api/src/http/routes/integrations.ts
new file mode 100644
index 0000000..bb8c7a0
--- /dev/null
+++ b/apps/api/src/http/routes/integrations.ts
@@ -0,0 +1,33 @@
+import { Router } from "express";
+import { z } from "zod";
+import { requireAdmin } from "../middleware/auth.js";
+import { pool } from "../../db/pool.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()
+});
+
+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.post("/", async (req, res) => {
+ 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]);
+});
diff --git a/apps/api/src/http/routes/mcp.ts b/apps/api/src/http/routes/mcp.ts
new file mode 100644
index 0000000..564b200
--- /dev/null
+++ b/apps/api/src/http/routes/mcp.ts
@@ -0,0 +1,82 @@
+import { Router } from "express";
+import { z } from "zod";
+import { createOrUpdatePage, getPageBySlug, getPageMetadata, listSpaces } from "../../services/pages.js";
+import { searchPages } from "../../services/search.js";
+
+const toolsCallSchema = z.object({
+ method: z.string(),
+ params: z.record(z.any()).optional(),
+ id: z.union([z.string(), z.number()]).optional()
+});
+
+export const mcpRouter = Router();
+
+mcpRouter.post("/", async (req, res) => {
+ const body = toolsCallSchema.parse(req.body);
+
+ if (body.method === "tools/list") {
+ return res.json({
+ 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" }
+ ]
+ }
+ });
+ }
+
+ if (body.method === "tools/call") {
+ const name = String(body.params?.name ?? "");
+ const args = body.params?.arguments ?? {};
+
+ if (name === "search_knowledge_base") {
+ const result = await searchPages(String(args.query ?? ""), req.user ?? null);
+ return res.json({ id: body.id, result });
+ }
+
+ if (name === "read_page") {
+ const result = await getPageBySlug(String(args.slug ?? ""), req.user ?? null);
+ return res.json({ id: body.id, result });
+ }
+
+ if (name === "list_spaces") {
+ const result = await listSpaces(req.user ?? null);
+ return res.json({ id: body.id, result });
+ }
+
+ if (name === "get_page_metadata") {
+ const result = await getPageMetadata(String(args.pageId ?? ""), req.user ?? null);
+ return res.json({ id: body.id, result });
+ }
+
+ 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") {
+ return res.status(403).json({ error: "Editor access required" });
+ }
+
+ const result = await createOrUpdatePage(
+ null,
+ {
+ spaceId: String(args.spaceId),
+ title: String(args.title),
+ bodyMarkdown: String(args.bodyMarkdown),
+ visibility: "internal",
+ state: "draft"
+ },
+ req.user.id
+ );
+
+ return res.json({ id: body.id, result });
+ }
+ }
+
+ return res.status(400).json({ error: "Unsupported MCP method" });
+});
diff --git a/apps/api/src/http/routes/pages.ts b/apps/api/src/http/routes/pages.ts
new file mode 100644
index 0000000..5987d82
--- /dev/null
+++ b/apps/api/src/http/routes/pages.ts
@@ -0,0 +1,92 @@
+import { Router } from "express";
+import { z } from "zod";
+import { requireEditor } from "../middleware/auth.js";
+import {
+ createOrUpdatePage,
+ getPageBySlug,
+ listPageRevisions,
+ listPagesForSpace,
+ rollbackPageRevision
+} from "../../services/pages.js";
+import { enqueueWebhookEvent } from "../../services/webhooks.js";
+import { logAudit } from "../../services/audit.js";
+
+const pageSchema = z
+ .object({
+ spaceId: z.string().uuid(),
+ title: z.string().min(3),
+ slug: z.string().optional(),
+ bodyMarkdown: z.string().min(1),
+ excerpt: z.string().max(240).optional(),
+ visibility: z.enum(["public", "internal", "restricted"]),
+ state: z.enum(["draft", "published"]),
+ parentPageId: z.string().uuid().nullable().optional(),
+ tagNames: z.array(z.string()).optional(),
+ allowedRoleKeys: z.array(z.enum(["owner", "admin", "editor", "viewer", "public"])).optional(),
+ allowedGroupIds: z.array(z.string().uuid()).optional()
+ })
+ .superRefine((input, context) => {
+ const permissionsCount =
+ (input.allowedRoleKeys?.length ?? 0) + (input.allowedGroupIds?.length ?? 0);
+
+ if (input.visibility === "restricted" && permissionsCount === 0) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Restricted pages require at least one role or group permission",
+ path: ["allowedRoleKeys"]
+ });
+ }
+ });
+
+export const pagesRouter = Router();
+
+pagesRouter.get("/space/:spaceKey", async (req, res) => {
+ const pages = await listPagesForSpace(req.params.spaceKey, req.user ?? null);
+ return res.json({ pages });
+});
+
+pagesRouter.get("/slug/:slug", async (req, res) => {
+ const page = await getPageBySlug(req.params.slug, req.user ?? null);
+ if (!page) {
+ return res.status(404).json({ error: "Page not found" });
+ }
+ return res.json({ page });
+});
+
+pagesRouter.post("/", requireEditor, async (req, res) => {
+ const input = pageSchema.parse(req.body);
+ const result = await createOrUpdatePage(null, input, req.user!.id);
+ await logAudit(req.user!.id, "page.create", "page", result.pageId, { slug: result.slug });
+ await enqueueWebhookEvent("page.created", { pageId: result.pageId, slug: result.slug });
+ if (input.state === "published") {
+ await enqueueWebhookEvent("page.published", { pageId: result.pageId, slug: result.slug });
+ }
+ return res.status(201).json(result);
+});
+
+pagesRouter.put("/:pageId", requireEditor, async (req, res) => {
+ const input = pageSchema.parse(req.body);
+ const result = await createOrUpdatePage(req.params.pageId, input, req.user!.id);
+ 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 });
+ return res.json(result);
+});
+
+pagesRouter.get("/:pageId/revisions", async (req, res) => {
+ const revisions = await listPageRevisions(req.params.pageId, req.user ?? null);
+ if (!revisions) {
+ return res.status(404).json({ error: "Page not found" });
+ }
+ return res.json({ revisions });
+});
+
+pagesRouter.post("/:pageId/revisions/:revisionId/rollback", requireEditor, async (req, res) => {
+ const result = await rollbackPageRevision(req.params.pageId, req.params.revisionId, req.user!.id);
+ if (!result) {
+ return res.status(404).json({ error: "Revision not found" });
+ }
+ await logAudit(req.user!.id, "page.rollback", "page", req.params.pageId, {
+ revisionId: req.params.revisionId
+ });
+ return res.json(result);
+});
diff --git a/apps/api/src/http/routes/roles.ts b/apps/api/src/http/routes/roles.ts
new file mode 100644
index 0000000..8bb91fb
--- /dev/null
+++ b/apps/api/src/http/routes/roles.ts
@@ -0,0 +1,10 @@
+import { Router } from "express";
+import { pool } from "../../db/pool.js";
+
+export const rolesRouter = Router();
+
+rolesRouter.get("/", async (_req, res) => {
+ const result = await pool.query(`SELECT id, key, name FROM roles ORDER BY created_at ASC`);
+ return res.json({ roles: result.rows });
+});
+
diff --git a/apps/api/src/http/routes/search.ts b/apps/api/src/http/routes/search.ts
new file mode 100644
index 0000000..d890dc2
--- /dev/null
+++ b/apps/api/src/http/routes/search.ts
@@ -0,0 +1,23 @@
+import { Router } from "express";
+import { searchPages, recordSearch } from "../../services/search.js";
+import { enqueueWebhookEvent } from "../../services/webhooks.js";
+
+export const searchRouter = Router();
+
+searchRouter.get("/", async (req, res) => {
+ const query = String(req.query.q ?? "");
+ const pages = await searchPages(query, req.user ?? null);
+ const searchId = await recordSearch(query, req.user?.id ?? null, pages);
+
+ if (pages.length === 0 && query.trim()) {
+ await enqueueWebhookEvent("search.no_results", { searchId, query });
+ }
+
+ return res.json({
+ query,
+ total: pages.length,
+ searchId,
+ pages
+ });
+});
+
diff --git a/apps/api/src/http/routes/settings.ts b/apps/api/src/http/routes/settings.ts
new file mode 100644
index 0000000..9437748
--- /dev/null
+++ b/apps/api/src/http/routes/settings.ts
@@ -0,0 +1,41 @@
+import { Router } from "express";
+import { z } from "zod";
+import { requireAdmin } from "../middleware/auth.js";
+import { getAdminSettingsBundle, getBrandingSettings, upsertBrandingSettings } from "../../services/settings.js";
+import { logAudit } from "../../services/audit.js";
+
+const brandingSchema = z.object({
+ siteName: z.string().min(2),
+ logoUrl: z.string().url().nullable(),
+ brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
+ footerText: z.string().max(200).nullable(),
+ publicKnowledgeBaseEnabled: z.boolean()
+});
+
+export const settingsRouter = Router();
+
+settingsRouter.get("/public", async (_req, res) => {
+ const branding = await getBrandingSettings();
+ return res.json({
+ branding: {
+ siteName: branding.site_name,
+ logoUrl: branding.logo_url,
+ brandColor: branding.brand_color,
+ footerText: branding.footer_text,
+ publicKnowledgeBaseEnabled: branding.public_knowledge_base_enabled
+ }
+ });
+});
+
+settingsRouter.get("/admin", requireAdmin, async (_req, res) => {
+ const settings = await getAdminSettingsBundle();
+ return res.json(settings);
+});
+
+settingsRouter.put("/branding", requireAdmin, async (req, res) => {
+ const input = brandingSchema.parse(req.body);
+ const updated = await upsertBrandingSettings(input);
+ await logAudit(req.user!.id, "settings.branding.update", "branding_settings", updated.id);
+ return res.json(updated);
+});
+
diff --git a/apps/api/src/http/routes/spaces.ts b/apps/api/src/http/routes/spaces.ts
new file mode 100644
index 0000000..bcb9a3c
--- /dev/null
+++ b/apps/api/src/http/routes/spaces.ts
@@ -0,0 +1,10 @@
+import { Router } from "express";
+import { listSpaces } from "../../services/pages.js";
+
+export const spacesRouter = Router();
+
+spacesRouter.get("/", async (req, res) => {
+ const spaces = await listSpaces(req.user ?? null);
+ return res.json({ spaces });
+});
+
diff --git a/apps/api/src/http/routes/webhooks.ts b/apps/api/src/http/routes/webhooks.ts
new file mode 100644
index 0000000..04b96e1
--- /dev/null
+++ b/apps/api/src/http/routes/webhooks.ts
@@ -0,0 +1,39 @@
+import { Router } from "express";
+import { z } from "zod";
+import { requireAdmin } from "../middleware/auth.js";
+import { pool } from "../../db/pool.js";
+import { logAudit } from "../../services/audit.js";
+
+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)
+});
+
+export const webhooksRouter = Router();
+webhooksRouter.use(requireAdmin);
+
+webhooksRouter.get("/", async (_req, res) => {
+ const result = await pool.query(`SELECT * FROM webhooks ORDER BY created_at DESC`);
+ return res.json({ webhooks: result.rows });
+});
+
+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)
+ RETURNING *
+ `,
+ [input.name, input.targetUrl, input.signingSecret, JSON.stringify(input.events)]
+ );
+
+ await logAudit(req.user!.id, "webhook.create", "webhook", created.rows[0].id, {
+ events: input.events
+ });
+
+ return res.status(201).json(created.rows[0]);
+});
+
diff --git a/apps/api/src/http/types.ts b/apps/api/src/http/types.ts
new file mode 100644
index 0000000..135fab0
--- /dev/null
+++ b/apps/api/src/http/types.ts
@@ -0,0 +1,7 @@
+import type { Request } from "express";
+import type { SessionUser } from "@ledger/shared";
+
+export type AppRequest = Request & {
+ user: SessionUser | null;
+};
+
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
new file mode 100644
index 0000000..3ad1e0c
--- /dev/null
+++ b/apps/api/src/index.ts
@@ -0,0 +1,8 @@
+import { createApp } from "./app.js";
+import { env } from "./config/env.js";
+
+const app = createApp();
+
+app.listen(env.PORT, () => {
+ console.log(`Ledger API listening on port ${env.PORT}`);
+});
diff --git a/apps/api/src/services/audit.ts b/apps/api/src/services/audit.ts
new file mode 100644
index 0000000..74b48b8
--- /dev/null
+++ b/apps/api/src/services/audit.ts
@@ -0,0 +1,16 @@
+import { pool } from "../db/pool.js";
+
+export async function logAudit(
+ actorUserId: string | null,
+ action: string,
+ resourceType: string,
+ resourceId: string,
+ metadata: Record<string, unknown> = {}
+) {
+ await pool.query(
+ `INSERT INTO audit_logs (actor_user_id, action, resource_type, resource_id, metadata)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [actorUserId, action, resourceType, resourceId, JSON.stringify(metadata)]
+ );
+}
+
diff --git a/apps/api/src/services/auth.ts b/apps/api/src/services/auth.ts
new file mode 100644
index 0000000..6a094ba
--- /dev/null
+++ b/apps/api/src/services/auth.ts
@@ -0,0 +1,54 @@
+import bcrypt from "bcryptjs";
+import jwt from "jsonwebtoken";
+import type { SessionUser } from "@ledger/shared";
+import { env } from "../config/env.js";
+import { pool } from "../db/pool.js";
+
+export async function hashPassword(password: string): Promise<string> {
+ return bcrypt.hash(`${password}${env.PASSWORD_PEPPER}`, 12);
+}
+
+export async function verifyPassword(password: string, hash: string): Promise<boolean> {
+ return bcrypt.compare(`${password}${env.PASSWORD_PEPPER}`, hash);
+}
+
+export function createSessionToken(user: SessionUser): string {
+ return jwt.sign(user, env.JWT_SECRET, { expiresIn: "7d" });
+}
+
+export function verifySessionToken(token: string): SessionUser {
+ return jwt.verify(token, env.JWT_SECRET) as SessionUser;
+}
+
+export async function getUserForSession(userId: string): Promise<SessionUser | null> {
+ const result = await pool.query(
+ `
+ SELECT
+ u.id,
+ u.email,
+ u.display_name,
+ r.key AS role_key,
+ COALESCE(array_agg(gm.group_id) FILTER (WHERE gm.group_id IS NOT NULL), '{}') AS group_ids
+ FROM users u
+ JOIN roles r ON r.id = u.primary_role_id
+ LEFT JOIN group_memberships gm ON gm.user_id = u.id
+ WHERE u.id = $1 AND u.is_active = true
+ GROUP BY u.id, r.key
+ `,
+ [userId]
+ );
+
+ if (!result.rowCount) {
+ return null;
+ }
+
+ const row = result.rows[0];
+ return {
+ id: row.id,
+ email: row.email,
+ displayName: row.display_name,
+ role: row.role_key,
+ groupIds: row.group_ids
+ };
+}
+
diff --git a/apps/api/src/services/pages.ts b/apps/api/src/services/pages.ts
new file mode 100644
index 0000000..2b1e730
--- /dev/null
+++ b/apps/api/src/services/pages.ts
@@ -0,0 +1,374 @@
+import type { PageDetail, PageSummary, PageUpsertInput, RoleKey, SessionUser } from "@ledger/shared";
+import { canReadVisibility } from "@ledger/shared";
+import { pool } from "../db/pool.js";
+import { renderMarkdown } from "../utils/markdown.js";
+import { slugify } from "../utils/slug.js";
+
+type PageRecord = {
+ id: string;
+ space_id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ visibility: "public" | "internal" | "restricted";
+ state: "draft" | "published";
+ is_public: boolean;
+ parent_page_id: string | null;
+ updated_at: Date;
+ revision_id: string;
+ body_markdown: string;
+ author_name: string;
+};
+
+async function getPagePermissions(pageId: string) {
+ const permissions = await pool.query(
+ `SELECT role_key, group_id FROM page_permissions WHERE page_id = $1`,
+ [pageId]
+ );
+
+ return {
+ roleKeys: permissions.rows.map((row) => row.role_key).filter(Boolean) as RoleKey[],
+ groupIds: permissions.rows.map((row) => row.group_id).filter(Boolean) as string[]
+ };
+}
+
+async function getPageTags(pageId: string): Promise<string[]> {
+ const tags = await pool.query(
+ `
+ SELECT t.name
+ FROM page_tags pt
+ JOIN tags t ON t.id = pt.tag_id
+ WHERE pt.page_id = $1
+ ORDER BY t.name ASC
+ `,
+ [pageId]
+ );
+
+ return tags.rows.map((row) => row.name);
+}
+
+function toSummary(row: PageRecord, tags: string[]): PageSummary {
+ return {
+ id: row.id,
+ spaceId: row.space_id,
+ title: row.title,
+ slug: row.slug,
+ excerpt: row.excerpt,
+ visibility: row.visibility,
+ state: row.state,
+ isPublic: row.is_public,
+ parentPageId: row.parent_page_id,
+ tags,
+ updatedAt: row.updated_at.toISOString()
+ };
+}
+
+async function upsertTags(pageId: string, tagNames: string[] = []) {
+ await pool.query("DELETE FROM page_tags WHERE page_id = $1", [pageId]);
+
+ for (const tagName of tagNames) {
+ const normalized = tagName.trim().toLowerCase();
+ if (!normalized) continue;
+
+ const tag = await pool.query(
+ `
+ INSERT INTO tags (name)
+ VALUES ($1)
+ ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
+ RETURNING id
+ `,
+ [normalized]
+ );
+
+ await pool.query(
+ `INSERT INTO page_tags (page_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [pageId, tag.rows[0].id]
+ );
+ }
+}
+
+async function upsertPermissions(
+ pageId: string,
+ allowedRoleKeys: RoleKey[] = [],
+ allowedGroupIds: string[] = []
+) {
+ await pool.query("DELETE FROM page_permissions WHERE page_id = $1", [pageId]);
+
+ for (const roleKey of allowedRoleKeys) {
+ await pool.query(`INSERT INTO page_permissions (page_id, role_key) VALUES ($1, $2)`, [
+ pageId,
+ roleKey
+ ]);
+ }
+
+ for (const groupId of allowedGroupIds) {
+ await pool.query(`INSERT INTO page_permissions (page_id, group_id) VALUES ($1, $2)`, [
+ pageId,
+ groupId
+ ]);
+ }
+}
+
+export async function listSpaces(user: SessionUser | null) {
+ const result = await pool.query(`SELECT id, name, key, visibility FROM spaces ORDER BY name ASC`);
+ return result.rows.filter((row) => canReadVisibility(user, row.visibility, [], []));
+}
+
+export async function listPagesForSpace(spaceKey: string, user: SessionUser | null) {
+ const result = await pool.query(
+ `
+ SELECT
+ p.id, p.space_id, p.title, p.slug, p.excerpt, p.visibility, p.state, p.is_public,
+ p.parent_page_id, p.updated_at, pr.id AS revision_id, pr.body_markdown,
+ COALESCE(u.display_name, 'Unknown') AS author_name
+ FROM pages p
+ JOIN spaces s ON s.id = p.space_id
+ JOIN page_revisions pr ON pr.id = p.current_revision_id
+ LEFT JOIN users u ON u.id = pr.edited_by_user_id
+ WHERE s.key = $1 AND p.state = 'published'
+ ORDER BY p.title ASC
+ `,
+ [spaceKey]
+ );
+
+ const pages: PageSummary[] = [];
+ for (const row of result.rows as PageRecord[]) {
+ const permissions = await getPagePermissions(row.id);
+ if (!canReadVisibility(user, row.visibility, permissions.roleKeys, permissions.groupIds)) {
+ continue;
+ }
+ pages.push(toSummary(row, await getPageTags(row.id)));
+ }
+
+ return pages;
+}
+
+export async function getPageBySlug(slug: string, user: SessionUser | null): Promise<PageDetail | null> {
+ const result = await pool.query(
+ `
+ SELECT
+ p.id, p.space_id, p.title, p.slug, p.excerpt, p.visibility, p.state, p.is_public,
+ p.parent_page_id, p.updated_at, pr.id AS revision_id, pr.body_markdown,
+ COALESCE(u.display_name, 'Unknown') AS author_name
+ FROM pages p
+ JOIN page_revisions pr ON pr.id = p.current_revision_id
+ LEFT JOIN users u ON u.id = pr.edited_by_user_id
+ WHERE p.slug = $1
+ `,
+ [slug]
+ );
+
+ if (!result.rowCount) {
+ return null;
+ }
+
+ const row = result.rows[0] as PageRecord;
+ const permissions = await getPagePermissions(row.id);
+ if (!canReadVisibility(user, row.visibility, permissions.roleKeys, permissions.groupIds)) {
+ return null;
+ }
+
+ const tags = await getPageTags(row.id);
+ const rendered = renderMarkdown(row.body_markdown);
+
+ return {
+ ...toSummary(row, tags),
+ bodyMarkdown: row.body_markdown,
+ bodyHtml: rendered.bodyHtml,
+ toc: rendered.toc,
+ revisionId: row.revision_id,
+ authorName: row.author_name
+ };
+}
+
+export async function createOrUpdatePage(existingPageId: string | null, input: PageUpsertInput, actorUserId: string) {
+ const rendered = renderMarkdown(input.bodyMarkdown);
+ const slug = slugify(input.slug ?? input.title);
+ const isPublic = input.visibility === "public" && input.state === "published";
+
+ await pool.query("BEGIN");
+ try {
+ const pageResult = existingPageId
+ ? await pool.query(
+ `
+ UPDATE pages
+ SET
+ space_id = $2,
+ title = $3,
+ slug = $4,
+ excerpt = $5,
+ visibility = $6,
+ state = $7,
+ is_public = $8,
+ parent_page_id = $9,
+ updated_at = now()
+ WHERE id = $1
+ RETURNING id
+ `,
+ [
+ existingPageId,
+ input.spaceId,
+ input.title,
+ slug,
+ input.excerpt ?? null,
+ input.visibility,
+ input.state,
+ isPublic,
+ input.parentPageId ?? null
+ ]
+ )
+ : await pool.query(
+ `
+ INSERT INTO pages (
+ space_id, parent_page_id, title, slug, excerpt, visibility, state, is_public, owner_user_id
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING id
+ `,
+ [
+ input.spaceId,
+ input.parentPageId ?? null,
+ input.title,
+ slug,
+ input.excerpt ?? null,
+ input.visibility,
+ input.state,
+ isPublic,
+ actorUserId
+ ]
+ );
+
+ const pageId = pageResult.rows[0].id;
+
+ const revision = await pool.query(
+ `
+ INSERT INTO page_revisions (
+ page_id, title, slug, excerpt, visibility, state, body_markdown, frontmatter, tag_snapshot, edited_by_user_id
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING id
+ `,
+ [
+ pageId,
+ input.title,
+ slug,
+ input.excerpt ?? null,
+ input.visibility,
+ input.state,
+ rendered.bodyMarkdown,
+ JSON.stringify(rendered.frontmatter),
+ JSON.stringify(input.tagNames ?? []),
+ actorUserId
+ ]
+ );
+
+ await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [
+ pageId,
+ revision.rows[0].id
+ ]);
+
+ await upsertTags(pageId, input.tagNames);
+ await upsertPermissions(pageId, input.allowedRoleKeys, input.allowedGroupIds);
+
+ await pool.query("COMMIT");
+ return { pageId, revisionId: revision.rows[0].id, slug };
+ } catch (error) {
+ await pool.query("ROLLBACK");
+ throw error;
+ }
+}
+
+export async function getPageMetadata(pageId: string, user: SessionUser | null) {
+ const page = await pool.query(
+ `
+ SELECT id, title, slug, visibility, state, updated_at
+ FROM pages
+ WHERE id = $1
+ `,
+ [pageId]
+ );
+
+ if (!page.rowCount) {
+ return null;
+ }
+
+ const permissions = await getPagePermissions(pageId);
+ const row = page.rows[0];
+
+ if (!canReadVisibility(user, row.visibility, permissions.roleKeys, permissions.groupIds)) {
+ return null;
+ }
+
+ return {
+ ...row,
+ permissions
+ };
+}
+
+export async function listPageRevisions(pageId: string, user: SessionUser | null) {
+ const metadata = await getPageMetadata(pageId, user);
+ if (!metadata) {
+ return null;
+ }
+
+ const revisions = await pool.query(
+ `
+ SELECT pr.id, pr.title, pr.slug, pr.visibility, pr.state, pr.created_at,
+ COALESCE(u.display_name, 'Unknown') AS editor_name
+ FROM page_revisions pr
+ LEFT JOIN users u ON u.id = pr.edited_by_user_id
+ WHERE pr.page_id = $1
+ ORDER BY pr.created_at DESC
+ `,
+ [pageId]
+ );
+
+ return revisions.rows;
+}
+
+export async function rollbackPageRevision(pageId: string, revisionId: string, actorUserId: string) {
+ const revision = await pool.query(`SELECT * FROM page_revisions WHERE page_id = $1 AND id = $2`, [
+ pageId,
+ revisionId
+ ]);
+
+ if (!revision.rowCount) {
+ return null;
+ }
+
+ const currentPage = await pool.query(`SELECT space_id, parent_page_id FROM pages WHERE id = $1`, [pageId]);
+ const row = revision.rows[0];
+
+ return createOrUpdatePage(
+ pageId,
+ {
+ spaceId: currentPage.rows[0].space_id,
+ parentPageId: currentPage.rows[0].parent_page_id,
+ title: row.title,
+ slug: row.slug,
+ bodyMarkdown: row.body_markdown,
+ excerpt: row.excerpt,
+ visibility: row.visibility,
+ state: row.state,
+ tagNames: row.tag_snapshot
+ },
+ actorUserId
+ );
+}
+
+export async function getSearchCandidates() {
+ const result = await pool.query(
+ `
+ SELECT
+ p.id, p.space_id, p.title, p.slug, p.excerpt, p.visibility, p.state, p.is_public,
+ p.parent_page_id, p.updated_at, pr.id AS revision_id, pr.body_markdown,
+ COALESCE(u.display_name, 'Unknown') AS author_name
+ FROM pages p
+ JOIN page_revisions pr ON pr.id = p.current_revision_id
+ LEFT JOIN users u ON u.id = pr.edited_by_user_id
+ WHERE p.state = 'published'
+ `
+ );
+
+ return result.rows as PageRecord[];
+}
diff --git a/apps/api/src/services/search.ts b/apps/api/src/services/search.ts
new file mode 100644
index 0000000..8a0f40d
--- /dev/null
+++ b/apps/api/src/services/search.ts
@@ -0,0 +1,80 @@
+import type { PageSummary, SessionUser } from "@ledger/shared";
+import { canReadVisibility } from "@ledger/shared";
+import { pool } from "../db/pool.js";
+import { getSearchCandidates } from "./pages.js";
+
+export function matchesSearchQuery(query: string, title: string, bodyMarkdown: string): boolean {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) {
+ return false;
+ }
+
+ return (
+ title.toLowerCase().includes(normalizedQuery) ||
+ bodyMarkdown.toLowerCase().includes(normalizedQuery)
+ );
+}
+
+export async function searchPages(query: string, user: SessionUser | null): Promise<PageSummary[]> {
+ const candidates = await getSearchCandidates();
+ const visible: PageSummary[] = [];
+
+ for (const row of candidates) {
+ if (!matchesSearchQuery(query, row.title, row.body_markdown)) {
+ continue;
+ }
+
+ const permissions = await pool.query(
+ `SELECT role_key, group_id FROM page_permissions WHERE page_id = $1`,
+ [row.id]
+ );
+
+ const roleKeys = permissions.rows.map((permission) => permission.role_key).filter(Boolean);
+ const groupIds = permissions.rows.map((permission) => permission.group_id).filter(Boolean);
+
+ if (!canReadVisibility(user, row.visibility, roleKeys, groupIds)) {
+ continue;
+ }
+
+ const tags = await pool.query(
+ `SELECT t.name FROM page_tags pt JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id = $1`,
+ [row.id]
+ );
+
+ visible.push({
+ id: row.id,
+ spaceId: row.space_id,
+ title: row.title,
+ slug: row.slug,
+ excerpt: row.excerpt,
+ visibility: row.visibility,
+ state: row.state,
+ isPublic: row.is_public,
+ parentPageId: row.parent_page_id,
+ tags: tags.rows.map((tag) => tag.name),
+ updatedAt: row.updated_at.toISOString()
+ });
+ }
+
+ return visible.slice(0, 20);
+}
+
+export async function recordSearch(query: string, userId: string | null, results: PageSummary[]) {
+ const search = await pool.query(
+ `
+ INSERT INTO searches (user_id, query, visible_scope, results_count)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id
+ `,
+ [userId, query, userId ? "authenticated" : "public", results.length]
+ );
+
+ for (const [index, result] of results.entries()) {
+ await pool.query(
+ `INSERT INTO search_results (search_id, page_id, position) VALUES ($1, $2, $3)`,
+ [search.rows[0].id, result.id, index + 1]
+ );
+ }
+
+ return search.rows[0].id as string;
+}
diff --git a/apps/api/src/services/settings.ts b/apps/api/src/services/settings.ts
new file mode 100644
index 0000000..8179d00
--- /dev/null
+++ b/apps/api/src/services/settings.ts
@@ -0,0 +1,81 @@
+import { env } from "../config/env.js";
+import { pool } from "../db/pool.js";
+
+export async function getBrandingSettings() {
+ const result = await pool.query(`SELECT * FROM branding_settings ORDER BY created_at ASC LIMIT 1`);
+ return (
+ result.rows[0] ?? {
+ id: "default",
+ site_name: "Ledger",
+ logo_url: null,
+ brand_color: "#245cff",
+ footer_text: "Built for fast, trusted answers.",
+ public_knowledge_base_enabled: true
+ }
+ );
+}
+
+export async function upsertBrandingSettings(input: {
+ siteName: string;
+ logoUrl: string | null;
+ brandColor: string;
+ footerText: string | null;
+ publicKnowledgeBaseEnabled: boolean;
+}) {
+ const existing = await getBrandingSettings();
+ if (existing) {
+ const result = await pool.query(
+ `
+ UPDATE branding_settings
+ SET site_name = $2, logo_url = $3, brand_color = $4, footer_text = $5,
+ public_knowledge_base_enabled = $6, updated_at = now()
+ WHERE id = $1
+ RETURNING *
+ `,
+ [
+ existing.id,
+ input.siteName,
+ input.logoUrl,
+ input.brandColor,
+ input.footerText,
+ input.publicKnowledgeBaseEnabled
+ ]
+ );
+ return result.rows[0];
+ }
+
+ const created = await pool.query(
+ `
+ INSERT INTO branding_settings (site_name, logo_url, brand_color, footer_text, public_knowledge_base_enabled)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *
+ `,
+ [
+ input.siteName,
+ input.logoUrl,
+ input.brandColor,
+ input.footerText,
+ input.publicKnowledgeBaseEnabled
+ ]
+ );
+ return created.rows[0];
+}
+
+export async function getAdminSettingsBundle() {
+ const branding = await getBrandingSettings();
+ const smtp = await pool.query(`SELECT * FROM smtp_settings ORDER BY created_at ASC LIMIT 1`);
+ const ai = await pool.query(`SELECT * FROM ai_settings ORDER BY created_at ASC LIMIT 1`);
+
+ return {
+ branding,
+ smtp: smtp.rows[0],
+ ai: ai.rows[0],
+ authProviders: {
+ oidc: Boolean(env.OIDC_ISSUER && env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET),
+ google: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
+ discord: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
+ microsoft: Boolean(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET),
+ slack: Boolean(env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET)
+ }
+ };
+}
diff --git a/apps/api/src/services/webhooks.ts b/apps/api/src/services/webhooks.ts
new file mode 100644
index 0000000..6bd4bba
--- /dev/null
+++ b/apps/api/src/services/webhooks.ts
@@ -0,0 +1,27 @@
+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 queue = new Queue("ledger-jobs", { connection });
+
+export async function enqueueWebhookEvent(eventName: string, payload: Record<string, unknown>) {
+ const webhooks = await pool.query(
+ `SELECT id FROM webhooks WHERE is_active = true AND events @> $1::jsonb`,
+ [JSON.stringify([eventName])]
+ );
+
+ for (const row of webhooks.rows) {
+ const delivery = await pool.query(
+ `INSERT INTO webhook_deliveries (webhook_id, event_name, payload)
+ VALUES ($1, $2, $3)
+ RETURNING id`,
+ [row.id, eventName, JSON.stringify(payload)]
+ );
+
+ await queue.add("webhook.deliver", {
+ deliveryId: delivery.rows[0].id
+ });
+ }
+}
diff --git a/apps/api/src/test/auth.test.ts b/apps/api/src/test/auth.test.ts
new file mode 100644
index 0000000..e20085b
--- /dev/null
+++ b/apps/api/src/test/auth.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { createSessionToken, hashPassword, verifyPassword, verifySessionToken } from "../services/auth.js";
+
+describe("auth helpers", () => {
+ it("hashes and verifies passwords", async () => {
+ const hash = await hashPassword("Password123!");
+ await expect(verifyPassword("Password123!", hash)).resolves.toBe(true);
+ await expect(verifyPassword("WrongPassword!", hash)).resolves.toBe(false);
+ });
+
+ it("creates and verifies session tokens", () => {
+ const token = createSessionToken({
+ id: "user-1",
+ email: "[email protected]",
+ displayName: "User",
+ role: "viewer",
+ groupIds: []
+ });
+
+ expect(verifySessionToken(token).email).toBe("[email protected]");
+ });
+});
+
diff --git a/apps/api/src/test/http.test.ts b/apps/api/src/test/http.test.ts
new file mode 100644
index 0000000..935c8e4
--- /dev/null
+++ b/apps/api/src/test/http.test.ts
@@ -0,0 +1,84 @@
+import request from "supertest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const queryMock = vi.fn();
+const enqueueWebhookEvent = vi.fn();
+const verifyPassword = vi.fn();
+const getUserForSession = vi.fn();
+const createSessionToken = vi.fn(() => "token");
+
+vi.mock("../db/pool.js", () => ({
+ pool: {
+ query: queryMock
+ }
+}));
+
+vi.mock("../services/webhooks.js", () => ({
+ enqueueWebhookEvent
+}));
+
+vi.mock("../services/auth.js", () => ({
+ verifyPassword,
+ getUserForSession,
+ createSessionToken,
+ verifySessionToken: vi.fn(),
+ hashPassword: vi.fn()
+}));
+
+describe("http flows", () => {
+ beforeEach(() => {
+ queryMock.mockReset();
+ enqueueWebhookEvent.mockReset();
+ verifyPassword.mockReset();
+ getUserForSession.mockReset();
+ createSessionToken.mockClear();
+ });
+
+ it("logs in with valid credentials", async () => {
+ queryMock
+ .mockResolvedValueOnce({
+ rowCount: 1,
+ rows: [{ id: "user-1", password_hash: "hash" }]
+ })
+ .mockResolvedValueOnce({ rowCount: 1, rows: [] });
+ verifyPassword.mockResolvedValueOnce(true);
+ getUserForSession.mockResolvedValueOnce({
+ id: "user-1",
+ email: "[email protected]",
+ displayName: "Owner",
+ role: "owner",
+ groupIds: []
+ });
+
+ const { createApp } = await import("../app.js");
+ const response = await request(createApp()).post("/api/auth/login").send({
+ email: "[email protected]",
+ password: "Password123!"
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.user.email).toBe("[email protected]");
+ expect(response.headers["set-cookie"]).toBeTruthy();
+ });
+
+ it("creates feedback records", async () => {
+ queryMock
+ .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: "feedback-1" }] })
+ .mockResolvedValueOnce({ rowCount: 1, rows: [] });
+
+ const { createApp } = await import("../app.js");
+ const response = await request(createApp()).post("/api/feedback").send({
+ pageId: "27ef6842-a7e5-41ab-a18a-f2d0ca9d1d85",
+ revisionId: "8f8381fd-e2d3-4093-a2c6-59c59f119abc",
+ helpful: true,
+ comment: "Solved it"
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body.feedbackId).toBe("feedback-1");
+ expect(enqueueWebhookEvent).toHaveBeenCalledWith(
+ "feedback.created",
+ expect.objectContaining({ feedbackId: "feedback-1" })
+ );
+ });
+});
diff --git a/apps/api/src/test/markdown.test.ts b/apps/api/src/test/markdown.test.ts
new file mode 100644
index 0000000..cf8d77b
--- /dev/null
+++ b/apps/api/src/test/markdown.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { renderMarkdown } from "../utils/markdown.js";
+
+describe("markdown rendering", () => {
+ it("sanitizes unsafe html", () => {
+ const rendered = renderMarkdown("# Hello\n\n<script>alert('xss')</script><b>safe</b>");
+ expect(rendered.bodyHtml).not.toContain("<script>");
+ expect(rendered.bodyHtml).toContain("<b>safe</b>");
+ });
+
+ it("builds a table of contents from headings", () => {
+ const rendered = renderMarkdown("# One\n\n## Two");
+ expect(rendered.toc).toEqual([
+ { id: "one", text: "One", level: 1 },
+ { id: "two", text: "Two", level: 2 }
+ ]);
+ });
+});
+
diff --git a/apps/api/src/test/rbac.test.ts b/apps/api/src/test/rbac.test.ts
new file mode 100644
index 0000000..ffc77bf
--- /dev/null
+++ b/apps/api/src/test/rbac.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { canReadVisibility } from "@ledger/shared";
+
+describe("RBAC visibility", () => {
+ const viewer = {
+ id: "user-1",
+ email: "[email protected]",
+ displayName: "Viewer",
+ role: "viewer" as const,
+ groupIds: []
+ };
+
+ it("allows public pages for anonymous users", () => {
+ expect(canReadVisibility(null, "public")).toBe(true);
+ });
+
+ it("blocks internal pages for anonymous users", () => {
+ expect(canReadVisibility(null, "internal")).toBe(false);
+ });
+
+ it("allows restricted pages only when group and role match", () => {
+ expect(canReadVisibility(viewer, "restricted", ["viewer"], ["group-1"])).toBe(false);
+ expect(
+ canReadVisibility({ ...viewer, groupIds: ["group-1"] }, "restricted", ["viewer"], ["group-1"])
+ ).toBe(true);
+ });
+});
+
diff --git a/apps/api/src/test/search.test.ts b/apps/api/src/test/search.test.ts
new file mode 100644
index 0000000..a59b794
--- /dev/null
+++ b/apps/api/src/test/search.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { matchesSearchQuery } from "../services/search.js";
+
+describe("search matching", () => {
+ it("matches content in page title", () => {
+ expect(matchesSearchQuery("ledger", "Ledger setup", "body")).toBe(true);
+ });
+
+ it("matches content in markdown body", () => {
+ expect(matchesSearchQuery("incident", "Runbook", "Major incident workflow")).toBe(true);
+ });
+
+ it("does not match blank queries", () => {
+ expect(matchesSearchQuery(" ", "Runbook", "body")).toBe(false);
+ });
+});
+
diff --git a/apps/api/src/test/webhook.test.ts b/apps/api/src/test/webhook.test.ts
new file mode 100644
index 0000000..8dd05bb
--- /dev/null
+++ b/apps/api/src/test/webhook.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { signWebhook } from "../utils/webhook.js";
+
+describe("webhook signing", () => {
+ it("generates deterministic hmac signatures", () => {
+ expect(signWebhook("secret", '{"event":"page.created"}')).toBe(
+ "3bfc8e9ff415966f1405d2ea4c17d5de495bf2c0d7feb228a5e6584cfed3316f"
+ );
+ });
+});
+
diff --git a/apps/api/src/utils/markdown.ts b/apps/api/src/utils/markdown.ts
new file mode 100644
index 0000000..a90f3fe
--- /dev/null
+++ b/apps/api/src/utils/markdown.ts
@@ -0,0 +1,71 @@
+import matter from "gray-matter";
+import { marked } from "marked";
+import sanitizeHtml from "sanitize-html";
+
+export interface RenderedMarkdown {
+ frontmatter: Record<string, unknown>;
+ bodyMarkdown: string;
+ bodyHtml: string;
+ toc: Array<{ id: string; text: string; level: number }>;
+}
+
+function headingId(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+export function renderMarkdown(source: string): RenderedMarkdown {
+ const parsed = matter(source);
+ const headings: Array<{ id: string; text: string; level: number }> = [];
+
+ const lexer = marked.lexer(parsed.content);
+ for (const token of lexer) {
+ if (token.type === "heading") {
+ headings.push({
+ id: headingId(token.text),
+ text: token.text,
+ level: token.depth
+ });
+ }
+ }
+
+ const renderer = new marked.Renderer();
+ renderer.heading = ({ tokens, depth }) => {
+ const text = tokens.map((token) => ("text" in token ? token.text : "")).join("");
+ const id = headingId(text);
+ return `<h${depth} id="${id}">${text}</h${depth}>`;
+ };
+
+ const rawHtml = marked.parse(parsed.content, { renderer }) as string;
+ const safeHtml = sanitizeHtml(rawHtml, {
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "img",
+ "table",
+ "thead",
+ "tbody",
+ "tr",
+ "th",
+ "td"
+ ]),
+ allowedAttributes: {
+ a: ["href", "name", "target", "rel"],
+ img: ["src", "alt", "title"],
+ "*": ["id"]
+ },
+ allowedSchemes: ["http", "https", "mailto"]
+ });
+
+ return {
+ frontmatter: parsed.data,
+ bodyMarkdown: parsed.content,
+ bodyHtml: safeHtml,
+ toc: headings
+ };
+}
+
diff --git a/apps/api/src/utils/slug.ts b/apps/api/src/utils/slug.ts
new file mode 100644
index 0000000..3058174
--- /dev/null
+++ b/apps/api/src/utils/slug.ts
@@ -0,0 +1,9 @@
+export function slugify(input: string): string {
+ return input
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+ .slice(0, 100);
+}
+
diff --git a/apps/api/src/utils/webhook.ts b/apps/api/src/utils/webhook.ts
new file mode 100644
index 0000000..7b3680b
--- /dev/null
+++ b/apps/api/src/utils/webhook.ts
@@ -0,0 +1,6 @@
+import crypto from "node:crypto";
+
+export function signWebhook(secret: string, body: string): string {
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
+}
+
diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json
new file mode 100644
index 0000000..b6bd7ba
--- /dev/null
+++ b/apps/api/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": [
+ "node"
+ ]
+ },
+ "include": [
+ "src"
+ ]
+}
+
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..c5d00e0
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Ledger</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+</html>
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..7322150
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@ledger/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build"
+ },
+ "dependencies": {
+ "@ledger/shared": "0.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.27.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.2",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.9"
+ }
+}
+
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..ea6b07e
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,418 @@
+import React, { useEffect, useState } from "react";
+import { Link, Route, Routes, useNavigate, useParams } from "react-router-dom";
+import type { PageDetail, PageSummary, SessionUser } from "@ledger/shared";
+import { FeedbackForm } from "./components/FeedbackForm";
+import { PageEditor } from "./components/PageEditor";
+import { PageSidebar } from "./components/PageSidebar";
+import { SearchBar } from "./components/SearchBar";
+import { api } from "./lib/api";
+
+type BrandingResponse = {
+ branding: {
+ siteName: string;
+ brandColor: string;
+ footerText: string | null;
+ publicKnowledgeBaseEnabled: boolean;
+ };
+};
+
+function useSession() {
+ const [user, setUser] = useState<SessionUser | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .get<{ user: SessionUser | null }>("/api/auth/session")
+ .then((response) => setUser(response.user))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return { user, setUser, loading };
+}
+
+function Shell({
+ children,
+ branding,
+ user,
+ onLogout
+}: {
+ children: React.ReactNode;
+ branding: BrandingResponse["branding"] | null;
+ user: SessionUser | null;
+ onLogout: () => Promise<void>;
+}) {
+ return (
+ <div className="app-shell">
+ <header className="topbar">
+ <Link to="/" className="brand">
+ <span className="brand-mark" style={{ background: branding?.brandColor ?? "#245cff" }} />
+ <div>
+ <strong>{branding?.siteName ?? "Ledger"}</strong>
+ <small>Trusted answers for teams</small>
+ </div>
+ </Link>
+ <nav className="topnav">
+ <Link to="/">Knowledge base</Link>
+ {user ? <Link to="/dashboard">Dashboard</Link> : <Link to="/login">Sign in</Link>}
+ {user ? (
+ <button className="button-secondary" onClick={onLogout}>
+ Sign out
+ </button>
+ ) : null}
+ </nav>
+ </header>
+ <main>{children}</main>
+ <footer className="footer">{branding?.footerText ?? "Built for fast, trusted answers."}</footer>
+ </div>
+ );
+}
+
+function HomePage() {
+ const navigate = useNavigate();
+ const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string; visibility: string }>>([]);
+ const [results, setResults] = useState<PageSummary[]>([]);
+
+ useEffect(() => {
+ api
+ .get<{ spaces: Array<{ id: string; name: string; key: string; visibility: string }> }>("/api/spaces")
+ .then((response) => setSpaces(response.spaces))
+ .catch(() => setSpaces([]));
+ }, []);
+
+ async function runSearch(query: string) {
+ const response = await api.get<{ pages: PageSummary[] }>(
+ `/api/search?q=${encodeURIComponent(query)}`
+ );
+ setResults(response.pages);
+ }
+
+ return (
+ <div className="hero-layout">
+ <section className="hero-card">
+ <p className="eyebrow">Open-source knowledge base</p>
+ <h1>Find the answer fast. Keep internal knowledge protected.</h1>
+ <p className="lede">
+ Ledger gives teams a public docs experience and an internal knowledge base on the same
+ foundation, with backend-enforced visibility rules.
+ </p>
+ <SearchBar onSearch={runSearch} />
+ </section>
+ <section className="grid">
+ <div className="card">
+ <h3>Spaces</h3>
+ <div className="stack">
+ {spaces.map((space) => (
+ <button key={space.id} className="space-link" onClick={() => navigate(`/space/${space.key}`)}>
+ <span>{space.name}</span>
+ <small>{space.visibility}</small>
+ </button>
+ ))}
+ </div>
+ </div>
+ <div className="card">
+ <h3>Search results</h3>
+ <div className="stack">
+ {results.length === 0 ? <p className="muted">Search the knowledge base to see results.</p> : null}
+ {results.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="result-item">
+ <strong>{page.title}</strong>
+ <span>{page.excerpt}</span>
+ </Link>
+ ))}
+ </div>
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function SpacePage() {
+ const { spaceKey = "" } = useParams();
+ const [pages, setPages] = useState<PageSummary[]>([]);
+
+ useEffect(() => {
+ api.get<{ pages: PageSummary[] }>(`/api/pages/space/${spaceKey}`).then((response) => setPages(response.pages));
+ }, [spaceKey]);
+
+ return (
+ <div className="content-layout">
+ <PageSidebar pages={pages} title={`Space: ${spaceKey}`} />
+ <section className="card">
+ <h2>{spaceKey}</h2>
+ <p className="muted">Select a page from the sidebar.</p>
+ </section>
+ </div>
+ );
+}
+
+function PageView() {
+ const { slug = "" } = useParams();
+ const [page, setPage] = useState<PageDetail | null>(null);
+ const [related, setRelated] = useState<PageSummary[]>([]);
+
+ useEffect(() => {
+ api.get<{ page: PageDetail }>(`/api/pages/slug/${slug}`).then((response) => {
+ setPage(response.page);
+ return api.get<{ pages: PageSummary[] }>(`/api/pages/space/${response.page.spaceId}`);
+ }).then((response) => setRelated(response.pages))
+ .catch(() => {
+ setPage(null);
+ setRelated([]);
+ });
+ }, [slug]);
+
+ if (!page) {
+ return <section className="card">Page not found or not visible to your account.</section>;
+ }
+
+ return (
+ <div className="content-layout">
+ <PageSidebar pages={related} title="Pages" />
+ <article className="page-card">
+ <div className="page-card__header">
+ <div>
+ <p className="eyebrow">{page.visibility}</p>
+ <h1>{page.title}</h1>
+ </div>
+ <span className={`pill pill-${page.visibility}`}>{page.state}</span>
+ </div>
+ <div className="toc">
+ {page.toc.map((item) => (
+ <a key={item.id} href={`#${item.id}`}>
+ {item.text}
+ </a>
+ ))}
+ </div>
+ <div className="markdown" dangerouslySetInnerHTML={{ __html: page.bodyHtml }} />
+ <FeedbackForm pageId={page.id} revisionId={page.revisionId} />
+ </article>
+ </div>
+ );
+}
+
+function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
+ const navigate = useNavigate();
+ const [email, setEmail] = useState("[email protected]");
+ const [password, setPassword] = useState("Password123!");
+ const [error, setError] = useState<string | null>(null);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ try {
+ const response = await api.post<{ user: SessionUser }>("/api/auth/login", { email, password });
+ onLogin(response.user);
+ navigate("/dashboard");
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : "Could not sign in");
+ }
+ }
+
+ return (
+ <section className="auth-card">
+ <p className="eyebrow">Sign in</p>
+ <h1>Access internal knowledge</h1>
+ <form className="stack" onSubmit={submit}>
+ <label>
+ Email
+ <input value={email} onChange={(event) => setEmail(event.target.value)} />
+ </label>
+ <label>
+ Password
+ <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
+ </label>
+ <button type="submit">Sign in</button>
+ </form>
+ {error ? <p className="muted">{error}</p> : null}
+ </section>
+ );
+}
+
+function Dashboard({ user }: { user: SessionUser }) {
+ const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string }>>([]);
+ const [analytics, setAnalytics] = useState<{
+ topSearches: Array<{ query: string; count: number }>;
+ noResults: Array<{ query: string; count: number }>;
+ } | null>(null);
+ const [feedback, setFeedback] = useState<Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> | null>(null);
+ const [brandingForm, setBrandingForm] = useState({
+ siteName: "Ledger",
+ logoUrl: "",
+ brandColor: "#245cff",
+ footerText: "",
+ publicKnowledgeBaseEnabled: true
+ });
+ const [settingsStatus, setSettingsStatus] = useState<string | null>(null);
+
+ useEffect(() => {
+ api
+ .get<{ spaces: Array<{ id: string; name: string; key: string }> }>("/api/spaces")
+ .then((response) => setSpaces(response.spaces));
+ }, []);
+
+ useEffect(() => {
+ if (user.role === "admin" || user.role === "owner") {
+ api
+ .get<{
+ topSearches: Array<{ query: string; count: number }>;
+ noResults: Array<{ query: string; count: number }>;
+ }>("/api/admin/search-analytics")
+ .then(setAnalytics);
+ api
+ .get<{ feedback: Array<{ id: string; page_title: string; helpful: boolean; comment: string | null }> }>(
+ "/api/admin/feedback"
+ )
+ .then((response) => setFeedback(response.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").then((response) => {
+ const branding = response.branding;
+ setBrandingForm({
+ siteName: branding.site_name,
+ logoUrl: branding.logo_url ?? "",
+ brandColor: branding.brand_color,
+ footerText: branding.footer_text ?? "",
+ publicKnowledgeBaseEnabled: branding.public_knowledge_base_enabled
+ });
+ });
+ }
+ }, [user.role]);
+
+ async function saveBranding() {
+ try {
+ await api.put("/api/settings/branding", {
+ siteName: brandingForm.siteName,
+ logoUrl: brandingForm.logoUrl || null,
+ brandColor: brandingForm.brandColor,
+ footerText: brandingForm.footerText || null,
+ publicKnowledgeBaseEnabled: brandingForm.publicKnowledgeBaseEnabled
+ });
+ setSettingsStatus("Branding saved.");
+ } catch (error) {
+ setSettingsStatus(error instanceof Error ? error.message : "Could not save branding");
+ }
+ }
+
+ return (
+ <div className="dashboard-grid">
+ <section className="card">
+ <p className="eyebrow">Signed in as {user.displayName}</p>
+ <h2>Knowledge base dashboard</h2>
+ <p className="muted">Role: {user.role}</p>
+ </section>
+ {user.role === "editor" || user.role === "admin" || user.role === "owner" ? (
+ <PageEditor spaces={spaces} />
+ ) : null}
+ {analytics ? (
+ <section className="card">
+ <h3>Search analytics</h3>
+ <div className="split">
+ <div>
+ <strong>Top searches</strong>
+ {analytics.topSearches.map((item) => (
+ <p key={item.query}>{item.query} ({item.count})</p>
+ ))}
+ </div>
+ <div>
+ <strong>No results</strong>
+ {analytics.noResults.map((item) => (
+ <p key={item.query}>{item.query} ({item.count})</p>
+ ))}
+ </div>
+ </div>
+ </section>
+ ) : null}
+ {feedback ? (
+ <section className="card">
+ <h3>Feedback queue</h3>
+ {feedback.map((item) => (
+ <div key={item.id} className="feedback-item">
+ <strong>{item.page_title}</strong>
+ <span>{item.helpful ? "Helpful" : "Not helpful"}</span>
+ <p>{item.comment ?? "No comment"}</p>
+ </div>
+ ))}
+ </section>
+ ) : null}
+ {user.role === "admin" || user.role === "owner" ? (
+ <section className="card">
+ <h3>Brand settings</h3>
+ <div className="stack">
+ <label>
+ Site name
+ <input
+ value={brandingForm.siteName}
+ onChange={(event) =>
+ setBrandingForm((current) => ({ ...current, siteName: event.target.value }))
+ }
+ />
+ </label>
+ <label>
+ Brand color
+ <input
+ value={brandingForm.brandColor}
+ onChange={(event) =>
+ setBrandingForm((current) => ({ ...current, brandColor: event.target.value }))
+ }
+ />
+ </label>
+ <label>
+ Footer text
+ <input
+ value={brandingForm.footerText}
+ onChange={(event) =>
+ setBrandingForm((current) => ({ ...current, footerText: event.target.value }))
+ }
+ />
+ </label>
+ <label className="checkbox-row">
+ <input
+ type="checkbox"
+ checked={brandingForm.publicKnowledgeBaseEnabled}
+ onChange={(event) =>
+ setBrandingForm((current) => ({
+ ...current,
+ publicKnowledgeBaseEnabled: event.target.checked
+ }))
+ }
+ />
+ Public knowledge base enabled
+ </label>
+ <button onClick={saveBranding}>Save branding</button>
+ {settingsStatus ? <p className="muted">{settingsStatus}</p> : null}
+ </div>
+ </section>
+ ) : null}
+ </div>
+ );
+}
+
+export function App() {
+ const { user, setUser, loading } = useSession();
+ const [branding, setBranding] = useState<BrandingResponse["branding"] | null>(null);
+
+ useEffect(() => {
+ api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
+ }, []);
+
+ async function logout() {
+ await api.post("/api/auth/logout");
+ setUser(null);
+ }
+
+ if (loading) {
+ return <div className="loading-screen">Loading Ledger...</div>;
+ }
+
+ return (
+ <Shell branding={branding} user={user} onLogout={logout}>
+ <Routes>
+ <Route path="/" element={<HomePage />} />
+ <Route path="/login" element={<LoginPage onLogin={setUser} />} />
+ <Route path="/space/:spaceKey" element={<SpacePage />} />
+ <Route path="/page/:slug" element={<PageView />} />
+ <Route
+ path="/dashboard"
+ element={user ? <Dashboard user={user} /> : <LoginPage onLogin={setUser} />}
+ />
+ </Routes>
+ </Shell>
+ );
+}
diff --git a/apps/web/src/components/FeedbackForm.tsx b/apps/web/src/components/FeedbackForm.tsx
new file mode 100644
index 0000000..10a1004
--- /dev/null
+++ b/apps/web/src/components/FeedbackForm.tsx
@@ -0,0 +1,41 @@
+import { useState } from "react";
+import { api } from "../lib/api";
+
+export function FeedbackForm({ pageId, revisionId }: { pageId: string; revisionId: string }) {
+ const [message, setMessage] = useState("");
+ const [status, setStatus] = useState<null | string>(null);
+
+ async function submit(helpful: boolean) {
+ try {
+ await api.post("/api/feedback", {
+ pageId,
+ revisionId,
+ helpful,
+ comment: message || undefined
+ });
+ setStatus("Thanks for the feedback.");
+ setMessage("");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save feedback.");
+ }
+ }
+
+ return (
+ <section className="card">
+ <h3>Was this page helpful?</h3>
+ <textarea
+ value={message}
+ onChange={(event) => setMessage(event.target.value)}
+ placeholder="Optional comment"
+ />
+ <div className="row">
+ <button onClick={() => submit(true)}>Helpful</button>
+ <button className="button-secondary" onClick={() => submit(false)}>
+ Not helpful
+ </button>
+ </div>
+ {status ? <p className="muted">{status}</p> : null}
+ </section>
+ );
+}
+
diff --git a/apps/web/src/components/PageEditor.tsx b/apps/web/src/components/PageEditor.tsx
new file mode 100644
index 0000000..f458694
--- /dev/null
+++ b/apps/web/src/components/PageEditor.tsx
@@ -0,0 +1,129 @@
+import { useState } from "react";
+import { api } from "../lib/api";
+
+const emptyPage = {
+ spaceId: "",
+ title: "",
+ slug: "",
+ bodyMarkdown: "# New page\n\nStart writing...",
+ excerpt: "",
+ visibility: "internal",
+ state: "draft",
+ tagNames: [] as string[]
+};
+
+export function PageEditor({ spaces }: { spaces: Array<{ id: string; name: string; key: string }> }) {
+ const [form, setForm] = useState({
+ ...emptyPage,
+ spaceId: spaces[0]?.id ?? ""
+ });
+ const [status, setStatus] = useState<string | null>(null);
+
+ async function submit() {
+ try {
+ const result = await api.post<{ slug: string }>("/api/pages", {
+ ...form,
+ tagNames: form.tagNames,
+ allowedRoleKeys: form.visibility === "restricted" ? ["viewer"] : []
+ });
+ setStatus(`Saved page ${result.slug}`);
+ setForm({
+ ...emptyPage,
+ spaceId: spaces[0]?.id ?? ""
+ });
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not save page");
+ }
+ }
+
+ return (
+ <section className="card">
+ <div className="row">
+ <h3>Create page</h3>
+ <span className="pill">Editor</span>
+ </div>
+ <label>
+ Space
+ <select
+ value={form.spaceId}
+ onChange={(event) => setForm((current) => ({ ...current, spaceId: event.target.value }))}
+ >
+ {spaces.map((space) => (
+ <option key={space.id} value={space.id}>
+ {space.name}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label>
+ Title
+ <input
+ value={form.title}
+ onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
+ />
+ </label>
+ <label>
+ Slug
+ <input
+ value={form.slug}
+ onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
+ />
+ </label>
+ <label>
+ Excerpt
+ <input
+ value={form.excerpt}
+ onChange={(event) => setForm((current) => ({ ...current, excerpt: event.target.value }))}
+ />
+ </label>
+ <div className="split">
+ <label>
+ Visibility
+ <select
+ value={form.visibility}
+ onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))}
+ >
+ <option value="public">Public</option>
+ <option value="internal">Internal</option>
+ <option value="restricted">Restricted</option>
+ </select>
+ </label>
+ <label>
+ State
+ <select
+ value={form.state}
+ onChange={(event) => setForm((current) => ({ ...current, state: event.target.value }))}
+ >
+ <option value="draft">Draft</option>
+ <option value="published">Published</option>
+ </select>
+ </label>
+ </div>
+ <label>
+ Tags
+ <input
+ placeholder="comma,separated,tags"
+ onChange={(event) =>
+ setForm((current) => ({
+ ...current,
+ tagNames: event.target.value
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter(Boolean)
+ }))
+ }
+ />
+ </label>
+ <label>
+ Markdown
+ <textarea
+ className="editor-textarea"
+ value={form.bodyMarkdown}
+ onChange={(event) => setForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
+ />
+ </label>
+ <button onClick={submit}>Save page</button>
+ {status ? <p className="muted">{status}</p> : null}
+ </section>
+ );
+}
diff --git a/apps/web/src/components/PageSidebar.tsx b/apps/web/src/components/PageSidebar.tsx
new file mode 100644
index 0000000..2ce269c
--- /dev/null
+++ b/apps/web/src/components/PageSidebar.tsx
@@ -0,0 +1,21 @@
+import { Link } from "react-router-dom";
+import type { PageSummary } from "@ledger/shared";
+
+export function PageSidebar({ pages, title }: { pages: PageSummary[]; title: string }) {
+ return (
+ <aside className="sidebar-card">
+ <div className="sidebar-card__header">
+ <h3>{title}</h3>
+ </div>
+ <nav className="page-tree">
+ {pages.map((page) => (
+ <Link key={page.id} to={`/page/${page.slug}`} className="page-tree__item">
+ <span>{page.title}</span>
+ <small>{page.visibility}</small>
+ </Link>
+ ))}
+ </nav>
+ </aside>
+ );
+}
+
diff --git a/apps/web/src/components/SearchBar.tsx b/apps/web/src/components/SearchBar.tsx
new file mode 100644
index 0000000..b3dfdc6
--- /dev/null
+++ b/apps/web/src/components/SearchBar.tsx
@@ -0,0 +1,28 @@
+import { FormEvent, useState } from "react";
+
+export function SearchBar({
+ initialQuery = "",
+ onSearch
+}: {
+ initialQuery?: string;
+ onSearch: (query: string) => void;
+}) {
+ const [query, setQuery] = useState(initialQuery);
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ onSearch(query);
+ }
+
+ return (
+ <form className="search-bar" onSubmit={handleSubmit}>
+ <input
+ value={query}
+ onChange={(event) => setQuery(event.target.value)}
+ placeholder="Search trusted answers"
+ />
+ <button type="submit">Search</button>
+ </form>
+ );
+}
+
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
new file mode 100644
index 0000000..f9ffd02
--- /dev/null
+++ b/apps/web/src/lib/api.ts
@@ -0,0 +1,38 @@
+const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000";
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+ const response = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {})
+ }
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({ error: "Request failed" }));
+ throw new Error(body.error ?? "Request failed");
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return response.json() as Promise<T>;
+}
+
+export const api = {
+ get: <T>(path: string) => request<T>(path),
+ post: <T>(path: string, body?: unknown) =>
+ request<T>(path, {
+ method: "POST",
+ body: body ? JSON.stringify(body) : undefined
+ }),
+ put: <T>(path: string, body: unknown) =>
+ request<T>(path, {
+ method: "PUT",
+ body: JSON.stringify(body)
+ })
+};
+
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..3703070
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { App } from "./App";
+import "./styles.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+ <React.StrictMode>
+ <BrowserRouter>
+ <App />
+ </BrowserRouter>
+ </React.StrictMode>
+);
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
new file mode 100644
index 0000000..7e37c95
--- /dev/null
+++ b/apps/web/src/styles.css
@@ -0,0 +1,300 @@
+:root {
+ font-family: "Segoe UI", "Helvetica Neue", sans-serif;
+ color: #132238;
+ background:
+ radial-gradient(circle at top left, rgba(36, 92, 255, 0.14), transparent 28%),
+ linear-gradient(180deg, #f7f9fd 0%, #eef3fb 100%);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ border-radius: 14px;
+ padding: 0.8rem 1.1rem;
+ background: #245cff;
+ color: white;
+ cursor: pointer;
+}
+
+.button-secondary {
+ background: #e6ecff;
+ color: #16306b;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border: 1px solid #d5def0;
+ border-radius: 14px;
+ padding: 0.8rem 0.9rem;
+ background: white;
+}
+
+textarea {
+ min-height: 110px;
+ resize: vertical;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+}
+
+.topbar,
+.footer,
+main {
+ width: min(1180px, calc(100% - 2rem));
+ margin: 0 auto;
+}
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.2rem 0;
+}
+
+.brand {
+ display: flex;
+ gap: 0.9rem;
+ align-items: center;
+}
+
+.brand-mark {
+ width: 20px;
+ height: 20px;
+ border-radius: 7px;
+ display: inline-block;
+}
+
+.brand small,
+.muted {
+ color: #60708b;
+}
+
+.topnav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+main {
+ padding: 1rem 0 2rem;
+}
+
+.hero-layout,
+.dashboard-grid {
+ display: grid;
+ gap: 1.25rem;
+}
+
+.hero-layout {
+ grid-template-columns: 1.2fr 1fr;
+}
+
+.grid,
+.content-layout {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: 1.25rem;
+}
+
+.dashboard-grid {
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+}
+
+.hero-card,
+.card,
+.page-card,
+.auth-card,
+.sidebar-card {
+ background: rgba(255, 255, 255, 0.88);
+ backdrop-filter: blur(18px);
+ border: 1px solid rgba(198, 210, 234, 0.8);
+ border-radius: 24px;
+ padding: 1.4rem;
+ box-shadow: 0 22px 60px rgba(29, 55, 107, 0.08);
+}
+
+.hero-card {
+ padding: 2rem;
+}
+
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.8rem;
+ color: #245cff;
+}
+
+.lede {
+ font-size: 1.05rem;
+ line-height: 1.6;
+ color: #33435f;
+}
+
+.search-bar,
+.row,
+.split {
+ display: flex;
+ gap: 0.8rem;
+}
+
+.search-bar input {
+ flex: 1;
+}
+
+.split > * {
+ flex: 1;
+}
+
+.stack {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.space-link,
+.result-item,
+.page-tree__item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.8rem;
+ padding: 0.9rem 1rem;
+ border-radius: 16px;
+ background: #f5f8ff;
+}
+
+.space-link {
+ width: 100%;
+ color: #132238;
+ background: #f5f8ff;
+}
+
+.page-card__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.pill {
+ padding: 0.35rem 0.7rem;
+ border-radius: 999px;
+ background: #eff3ff;
+ color: #20428f;
+ font-size: 0.85rem;
+}
+
+.pill-public {
+ background: #edf8ee;
+ color: #226538;
+}
+
+.pill-internal,
+.pill-restricted {
+ background: #fff3d6;
+ color: #815600;
+}
+
+.toc {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+ margin: 1rem 0 1.5rem;
+}
+
+.toc a {
+ color: #245cff;
+}
+
+.markdown {
+ line-height: 1.7;
+}
+
+.markdown h1,
+.markdown h2,
+.markdown h3 {
+ scroll-margin-top: 100px;
+}
+
+.markdown pre,
+.markdown code {
+ background: #eef3fb;
+ border-radius: 12px;
+}
+
+.editor-textarea {
+ min-height: 240px;
+}
+
+.auth-card {
+ max-width: 480px;
+ margin: 4rem auto;
+}
+
+.feedback-item {
+ border-top: 1px solid #e5ecf8;
+ padding-top: 0.8rem;
+ margin-top: 0.8rem;
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+.checkbox-row input {
+ width: auto;
+}
+
+.loading-screen {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+}
+
+.footer {
+ padding: 1rem 0 2rem;
+ color: #60708b;
+}
+
+@media (max-width: 900px) {
+ .hero-layout,
+ .grid,
+ .content-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar,
+ .topnav,
+ .page-card__header,
+ .search-bar,
+ .row,
+ .split {
+ flex-direction: column;
+ }
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..5768d1b
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "types": [
+ "vite/client"
+ ]
+ },
+ "include": [
+ "src"
+ ]
+}
+
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..cadfa39
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173
+ }
+});
+
diff --git a/apps/worker/package.json b/apps/worker/package.json
new file mode 100644
index 0000000..6459c84
--- /dev/null
+++ b/apps/worker/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@ledger/worker",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "bullmq": "^5.12.12",
+ "dotenv": "^16.4.5",
+ "ioredis": "^5.4.1"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.1",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
new file mode 100644
index 0000000..ca56ae1
--- /dev/null
+++ b/apps/worker/src/index.ts
@@ -0,0 +1,33 @@
+import "dotenv/config";
+import { Worker } from "bullmq";
+import Redis from "ioredis";
+
+const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
+
+const worker = new Worker(
+ "ledger-jobs",
+ async (job) => {
+ if (job.name === "webhook.deliver") {
+ console.log(`processing webhook delivery ${job.id}`);
+ return { delivered: true };
+ }
+
+ if (job.name === "analytics.rollup") {
+ console.log(`processing analytics rollup ${job.id}`);
+ return { rolledUp: true };
+ }
+
+ return { ignored: true };
+ },
+ { connection }
+);
+
+worker.on("completed", (job) => {
+ console.log(`job completed: ${job.name}:${job.id}`);
+});
+
+worker.on("failed", (job, error) => {
+ console.error(`job failed: ${job?.name}:${job?.id}`, error);
+});
+
+console.log("Ledger worker started");
diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json
new file mode 100644
index 0000000..a47cede
--- /dev/null
+++ b/apps/worker/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bdd9441
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,63 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-ledger}
+ POSTGRES_USER: ${POSTGRES_USER:-ledger}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ledger}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+
+ api:
+ image: node:20-bookworm
+ working_dir: /app
+ command: sh -c "npm install && npm run db:migrate && npm run db:seed && npm run dev:api"
+ env_file:
+ - .env
+ ports:
+ - "4000:4000"
+ volumes:
+ - .:/app
+ - ./storage/uploads:/app/storage/uploads
+ depends_on:
+ - postgres
+ - redis
+
+ web:
+ image: node:20-bookworm
+ working_dir: /app
+ command: sh -c "npm install && npm run dev:web -- --host 0.0.0.0"
+ env_file:
+ - .env
+ ports:
+ - "5173:5173"
+ volumes:
+ - .:/app
+ depends_on:
+ - api
+
+ worker:
+ image: node:20-bookworm
+ working_dir: /app
+ command: sh -c "npm install && npm run dev:worker"
+ env_file:
+ - .env
+ volumes:
+ - .:/app
+ depends_on:
+ - api
+ - redis
+
+volumes:
+ postgres_data:
+ redis_data:
diff --git a/infra/postgres/init.sql b/infra/postgres/init.sql
new file mode 100644
index 0000000..9744c61
--- /dev/null
+++ b/infra/postgres/init.sql
@@ -0,0 +1 @@
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
diff --git a/ledger-deploy.zip b/ledger-deploy.zip
new file mode 100644
index 0000000..6d801a7
Binary files /dev/null and b/ledger-deploy.zip differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8f0ecbb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "ledger",
+ "private": true,
+ "version": "0.1.0",
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ],
+ "scripts": {
+ "dev": "npm run dev --workspace @ledger/api",
+ "dev:api": "npm run dev --workspace @ledger/api",
+ "dev:web": "npm run dev --workspace @ledger/web",
+ "dev:worker": "npm run dev --workspace @ledger/worker",
+ "build": "npm run build --workspaces",
+ "test": "npm run test --workspace @ledger/api",
+ "db:migrate": "npm run db:migrate --workspace @ledger/api",
+ "db:seed": "npm run db:seed --workspace @ledger/api"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
new file mode 100644
index 0000000..c61bbb5
--- /dev/null
+++ b/packages/shared/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@ledger/shared",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json"
+ }
+}
diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts
new file mode 100644
index 0000000..59fc42d
--- /dev/null
+++ b/packages/shared/src/contracts.ts
@@ -0,0 +1,89 @@
+export type RoleKey = "owner" | "admin" | "editor" | "viewer" | "public";
+export type Visibility = "public" | "internal" | "restricted";
+export type PageState = "draft" | "published";
+
+export interface SessionUser {
+ id: string;
+ email: string;
+ displayName: string;
+ role: RoleKey;
+ groupIds: string[];
+}
+
+export interface SpaceSummary {
+ id: string;
+ name: string;
+ key: string;
+ visibility: Visibility;
+}
+
+export interface PageSummary {
+ id: string;
+ spaceId: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ visibility: Visibility;
+ state: PageState;
+ isPublic: boolean;
+ parentPageId: string | null;
+ tags: string[];
+ updatedAt: string;
+}
+
+export interface PageDetail extends PageSummary {
+ bodyMarkdown: string;
+ bodyHtml: string;
+ toc: Array<{ id: string; text: string; level: number }>;
+ revisionId: string;
+ authorName: string;
+}
+
+export interface SearchResponse {
+ query: string;
+ total: number;
+ pages: PageSummary[];
+}
+
+export interface FeedbackPayload {
+ pageId: string;
+ revisionId?: string;
+ helpful: boolean;
+ comment?: string;
+}
+
+export interface BrandingSettings {
+ siteName: string;
+ logoUrl: string | null;
+ brandColor: string;
+ footerText: string | null;
+ publicKnowledgeBaseEnabled: boolean;
+}
+
+export interface SmtpSettings {
+ host: string;
+ port: number;
+ username: string;
+ fromEmail: string;
+ fromName: string;
+}
+
+export interface AiSettings {
+ provider: string;
+ model: string;
+ enabled: boolean;
+}
+
+export interface PageUpsertInput {
+ spaceId: string;
+ title: string;
+ slug?: string;
+ bodyMarkdown: string;
+ excerpt?: string;
+ visibility: Visibility;
+ state: PageState;
+ parentPageId?: string | null;
+ tagNames?: string[];
+ allowedRoleKeys?: RoleKey[];
+ allowedGroupIds?: string[];
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
new file mode 100644
index 0000000..c9c12fe
--- /dev/null
+++ b/packages/shared/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./contracts";
+export * from "./rbac";
diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts
new file mode 100644
index 0000000..278d934
--- /dev/null
+++ b/packages/shared/src/rbac.ts
@@ -0,0 +1,57 @@
+import type { RoleKey, SessionUser, Visibility } from "./contracts";
+
+const roleWeight: Record<RoleKey, number> = {
+ public: 0,
+ viewer: 1,
+ editor: 2,
+ admin: 3,
+ owner: 4
+};
+
+export function hasRole(user: SessionUser | null, required: RoleKey): boolean {
+ const current = user?.role ?? "public";
+ return roleWeight[current] >= roleWeight[required];
+}
+
+export function canReadVisibility(
+ user: SessionUser | null,
+ visibility: Visibility,
+ allowedRoles: RoleKey[] = [],
+ allowedGroupIds: string[] = []
+): boolean {
+ if (visibility === "public") {
+ return true;
+ }
+
+ if (!user) {
+ return false;
+ }
+
+ if (visibility === "internal") {
+ return hasRole(user, "viewer");
+ }
+
+ if (hasRole(user, "admin")) {
+ return true;
+ }
+
+ if (allowedRoles.length === 0 && allowedGroupIds.length === 0) {
+ return false;
+ }
+
+ const roleAllowed =
+ allowedRoles.length === 0 || allowedRoles.some((role) => hasRole(user, role));
+ const groupAllowed =
+ allowedGroupIds.length === 0 ||
+ allowedGroupIds.some((groupId) => user.groupIds.includes(groupId));
+
+ return roleAllowed && groupAllowed;
+}
+
+export function canEditPage(user: SessionUser | null): boolean {
+ return hasRole(user, "editor");
+}
+
+export function canManageSettings(user: SessionUser | null): boolean {
+ return hasRole(user, "admin");
+}
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
new file mode 100644
index 0000000..44b9c17
--- /dev/null
+++ b/packages/shared/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/storage/uploads/.gitkeep b/storage/uploads/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/storage/uploads/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..ac89f06
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": "."
+ }
+}