NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

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

Commit

First commit v2

4527dbd
Alex Nord <[email protected]> 3 months ago
.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": "."
+  }
+}