public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
Add first-run setup and gate demo seed behind config
462c463
.env.example | 1 +
README.md | 29 +++++-
apps/api/src/app.ts | 2 +
apps/api/src/config/env.ts | 5 +-
apps/api/src/db/seed.ts | 199 ++++++++++++++++++++------------------
apps/api/src/http/routes/auth.ts | 5 +
apps/api/src/http/routes/setup.ts | 34 +++++++
apps/api/src/services/setup.ts | 114 ++++++++++++++++++++++
apps/api/src/test/http.test.ts | 17 ++++
apps/web/src/App.tsx | 167 +++++++++++++++++++++++++++++++-
apps/web/src/styles.css | 10 +-
apps/worker/src/index.ts | 4 +-
docker-compose.yml | 53 +++++++---
infra/scripts/dev-bootstrap.sh | 12 +++
14 files changed, 534 insertions(+), 118 deletions(-)
create mode 100644 apps/api/src/http/routes/setup.ts
create mode 100644 apps/api/src/services/setup.ts
create mode 100644 infra/scripts/dev-bootstrap.sh
Diff
diff --git a/.env.example b/.env.example
index 0b1a019..9543fa2 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,7 @@ POSTGRES_PASSWORD=ledger
DATABASE_URL=postgresql://ledger:ledger@postgres:5432/ledger
REDIS_URL=redis://redis:6379
+LEDGER_ENABLE_DEMO_SEED=false
JWT_SECRET=change-me
SESSION_COOKIE_NAME=ledger_session
diff --git a/README.md b/README.md
index 028f0d0..4476c78 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,19 @@ This repository currently ships a real MVP foundation:
- Docker Compose for local development
- Basic tests for permission logic, markdown sanitization, auth helpers, search matching, webhook signing, and key HTTP flows
+## Fresh install behavior
+
+Ledger no longer assumes demo accounts on a normal install.
+
+- `docker compose up` now runs a single `bootstrap` container that installs dependencies once into a shared Docker volume
+- PostgreSQL and Redis stay internal to the Compose network by default
+- The first browser visit takes you to a setup screen where you create:
+ - the initial owner account
+ - the site name and brand color
+ - the footer text
+ - the initial public knowledge base toggle
+- Demo users and sample content are only created when `LEDGER_ENABLE_DEMO_SEED=true`
+
## Monorepo layout
- `apps/web`: React frontend
@@ -60,8 +73,10 @@ Services:
- Frontend: [http://localhost:5173](http://localhost:5173)
- API: [http://localhost:4000](http://localhost:4000)
-- Postgres: `localhost:5432`
-- Redis: `localhost:6379`
+- Postgres: internal-only in Docker Compose
+- Redis: internal-only in Docker Compose
+
+On first boot, open the frontend and complete the setup wizard instead of using a pre-seeded admin login.
## Without Docker
@@ -74,9 +89,15 @@ npm run dev:web
npm run dev:worker
```
-## Seeded accounts
+## Optional demo seed
+
+If you want the old demo-style experience with seeded users and pages:
+
+```bash
+LEDGER_ENABLE_DEMO_SEED=true
+```
-All seeded users use password `Password123!`.
+All demo users use password `Password123!`.
- `[email protected]`
- `[email protected]`
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index bb40f68..5e27bfe 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -16,6 +16,7 @@ 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 { setupRouter } from "./http/routes/setup.js";
import { settingsRouter } from "./http/routes/settings.js";
import { webhooksRouter } from "./http/routes/webhooks.js";
import { integrationsRouter } from "./http/routes/integrations.js";
@@ -66,6 +67,7 @@ export function createApp() {
app.use("/api/roles", rolesRouter);
app.use("/api/feedback", feedbackRouter);
app.use("/api/settings", settingsRouter);
+ app.use("/api/setup", setupRouter);
app.use("/api/admin", adminRouter);
app.use("/api/webhooks", webhooksRouter);
app.use("/api/integrations", integrationsRouter);
diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts
index 5c44dc0..908fedf 100644
--- a/apps/api/src/config/env.ts
+++ b/apps/api/src/config/env.ts
@@ -7,6 +7,10 @@ const envSchema = z.object({
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"),
+ LEDGER_ENABLE_DEMO_SEED: z
+ .enum(["true", "false"])
+ .default("false")
+ .transform((value) => value === "true"),
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"),
@@ -36,4 +40,3 @@ const envSchema = z.object({
export const env = envSchema.parse(process.env);
export const isProduction = env.NODE_ENV === "production";
-
diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts
index f891e9a..ddce7c7 100644
--- a/apps/api/src/db/seed.ts
+++ b/apps/api/src/db/seed.ts
@@ -1,3 +1,4 @@
+import { env } from "../config/env.js";
import { hashPassword } from "../services/auth.js";
import { pool } from "./pool.js";
@@ -22,100 +23,6 @@ async function main() {
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)
@@ -140,7 +47,109 @@ async function main() {
`
);
- console.log("Seeded Ledger demo data");
+ if (env.LEDGER_ENABLE_DEMO_SEED) {
+ 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
+ ]);
+ }
+ }
+
+ console.log(env.LEDGER_ENABLE_DEMO_SEED ? "Seeded Ledger demo data" : "Seeded Ledger base data");
}
main()
diff --git a/apps/api/src/http/routes/auth.ts b/apps/api/src/http/routes/auth.ts
index d3bccc2..71a2f27 100644
--- a/apps/api/src/http/routes/auth.ts
+++ b/apps/api/src/http/routes/auth.ts
@@ -19,6 +19,11 @@ const registerSchema = z.object({
export const authRouter = Router();
authRouter.post("/register", async (req, res) => {
+ const userCount = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+ if (userCount.rows[0].count === 0) {
+ return res.status(403).json({ error: "Complete initial Ledger setup first" });
+ }
+
const input = registerSchema.parse(req.body);
const existing = await pool.query(`SELECT id FROM users WHERE email = $1`, [input.email]);
diff --git a/apps/api/src/http/routes/setup.ts b/apps/api/src/http/routes/setup.ts
new file mode 100644
index 0000000..bbac773
--- /dev/null
+++ b/apps/api/src/http/routes/setup.ts
@@ -0,0 +1,34 @@
+import { Router } from "express";
+import { z } from "zod";
+import { env, isProduction } from "../../config/env.js";
+import { initializeLedger, getSetupStatus } from "../../services/setup.js";
+
+const setupSchema = z.object({
+ siteName: z.string().min(2),
+ brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
+ footerText: z.string().max(200).nullable(),
+ publicKnowledgeBaseEnabled: z.boolean(),
+ ownerEmail: z.string().email(),
+ ownerDisplayName: z.string().min(2),
+ password: z.string().min(8)
+});
+
+export const setupRouter = Router();
+
+setupRouter.get("/status", async (_req, res) => {
+ const status = await getSetupStatus();
+ return res.json(status);
+});
+
+setupRouter.post("/initialize", async (req, res) => {
+ const input = setupSchema.parse(req.body);
+ const result = await initializeLedger(input);
+
+ res.cookie(env.SESSION_COOKIE_NAME, result.token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: isProduction
+ });
+
+ return res.status(201).json({ user: result.user });
+});
diff --git a/apps/api/src/services/setup.ts b/apps/api/src/services/setup.ts
new file mode 100644
index 0000000..70c9604
--- /dev/null
+++ b/apps/api/src/services/setup.ts
@@ -0,0 +1,114 @@
+import { hashPassword, createSessionToken, getUserForSession } from "./auth.js";
+import { logAudit } from "./audit.js";
+import { pool } from "../db/pool.js";
+
+interface SetupInput {
+ siteName: string;
+ brandColor: string;
+ footerText: string | null;
+ publicKnowledgeBaseEnabled: boolean;
+ ownerEmail: string;
+ ownerDisplayName: string;
+ password: string;
+}
+
+export async function getSetupStatus() {
+ const users = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+ const branding = await pool.query(`SELECT site_name, brand_color FROM branding_settings ORDER BY created_at ASC LIMIT 1`);
+
+ return {
+ isInitialized: users.rows[0].count > 0,
+ branding: branding.rows[0] ?? null
+ };
+}
+
+export async function initializeLedger(input: SetupInput) {
+ const existingUsers = await pool.query(`SELECT COUNT(*)::int AS count FROM users`);
+ if (existingUsers.rows[0].count > 0) {
+ throw new Error("Ledger has already been initialized");
+ }
+
+ await pool.query("BEGIN");
+ try {
+ const ownerRole = await pool.query(`SELECT id FROM roles WHERE key = 'owner'`);
+
+ const user = await pool.query(
+ `
+ INSERT INTO users (email, password_hash, display_name, primary_role_id)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id
+ `,
+ [input.ownerEmail, await hashPassword(input.password), input.ownerDisplayName, ownerRole.rows[0].id]
+ );
+
+ const ownerUserId = user.rows[0].id;
+
+ const branding = await pool.query(
+ `
+ UPDATE branding_settings
+ SET site_name = $1, brand_color = $2, footer_text = $3,
+ public_knowledge_base_enabled = $4, updated_at = now()
+ WHERE id = (SELECT id FROM branding_settings ORDER BY created_at ASC LIMIT 1)
+ RETURNING id
+ `,
+ [input.siteName, input.brandColor, input.footerText, input.publicKnowledgeBaseEnabled]
+ );
+
+ await pool.query(
+ `
+ INSERT INTO spaces (name, key, description, visibility, created_by_user_id)
+ VALUES
+ ('Docs', 'docs', 'Public documentation for your organization', 'public', $1),
+ ('Team', 'team', 'Internal knowledge for your organization', 'internal', $1)
+ ON CONFLICT (key) DO NOTHING
+ `,
+ [ownerUserId]
+ );
+
+ const docsSpace = await pool.query(`SELECT id FROM spaces WHERE key = 'docs'`);
+ const welcomePage = 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', 'Start customizing your new knowledge base.', 'public', 'published', true, $2)
+ RETURNING id
+ `,
+ [docsSpace.rows[0].id, ownerUserId]
+ );
+
+ const welcomeRevision = 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',
+ 'Start customizing your new knowledge base.',
+ 'public',
+ 'published',
+ '# Welcome to Ledger\n\nYour knowledge base is ready.\n\n## Next steps\n\n- Customize branding\n- Invite your team\n- Publish your first public and internal pages',
+ $2
+ )
+ RETURNING id
+ `,
+ [welcomePage.rows[0].id, ownerUserId]
+ );
+
+ await pool.query(`UPDATE pages SET current_revision_id = $2 WHERE id = $1`, [
+ welcomePage.rows[0].id,
+ welcomeRevision.rows[0].id
+ ]);
+
+ await pool.query("COMMIT");
+
+ const sessionUser = await getUserForSession(ownerUserId);
+ const token = createSessionToken(sessionUser!);
+ await logAudit(ownerUserId, "setup.initialize", "ledger", "instance", {
+ brandingSettingsId: branding.rows[0].id
+ });
+
+ return { token, user: sessionUser };
+ } catch (error) {
+ await pool.query("ROLLBACK");
+ throw error;
+ }
+}
diff --git a/apps/api/src/test/http.test.ts b/apps/api/src/test/http.test.ts
index 935c8e4..fd941fe 100644
--- a/apps/api/src/test/http.test.ts
+++ b/apps/api/src/test/http.test.ts
@@ -61,6 +61,23 @@ describe("http flows", () => {
expect(response.headers["set-cookie"]).toBeTruthy();
});
+ it("blocks register before initial setup is completed", async () => {
+ queryMock.mockResolvedValueOnce({
+ rowCount: 1,
+ rows: [{ count: 0 }]
+ });
+
+ const { createApp } = await import("../app.js");
+ const response = await request(createApp()).post("/api/auth/register").send({
+ email: "[email protected]",
+ password: "Password123!",
+ displayName: "First User"
+ });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toContain("initial Ledger setup");
+ });
+
it("creates feedback records", async () => {
queryMock
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: "feedback-1" }] })
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index ea6b07e..a937641 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -10,12 +10,21 @@ import { api } from "./lib/api";
type BrandingResponse = {
branding: {
siteName: string;
+ logoUrl?: string | null;
brandColor: string;
footerText: string | null;
publicKnowledgeBaseEnabled: boolean;
};
};
+type SetupStatus = {
+ isInitialized: boolean;
+ branding: {
+ site_name: string;
+ brand_color: string;
+ } | null;
+};
+
function useSession() {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
@@ -192,8 +201,8 @@ function PageView() {
function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
const navigate = useNavigate();
- const [email, setEmail] = useState("[email protected]");
- const [password, setPassword] = useState("Password123!");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
@@ -227,6 +236,128 @@ function LoginPage({ onLogin }: { onLogin: (user: SessionUser) => void }) {
);
}
+function SetupPage({
+ onInitialized,
+ initialBranding
+}: {
+ onInitialized: (user: SessionUser, branding: BrandingResponse["branding"]) => void;
+ initialBranding: SetupStatus["branding"];
+}) {
+ const navigate = useNavigate();
+ const [form, setForm] = useState({
+ siteName: initialBranding?.site_name ?? "Ledger",
+ brandColor: initialBranding?.brand_color ?? "#245cff",
+ footerText: "Built for fast, trusted answers.",
+ publicKnowledgeBaseEnabled: true,
+ ownerEmail: "",
+ ownerDisplayName: "",
+ password: ""
+ });
+ const [status, setStatus] = useState<string | null>(null);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ try {
+ const response = await api.post<{ user: SessionUser }>("/api/setup/initialize", {
+ ...form,
+ footerText: form.footerText || null
+ });
+
+ onInitialized(response.user, {
+ siteName: form.siteName,
+ logoUrl: null,
+ brandColor: form.brandColor,
+ footerText: form.footerText || null,
+ publicKnowledgeBaseEnabled: form.publicKnowledgeBaseEnabled
+ });
+ navigate("/dashboard");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Could not initialize Ledger");
+ }
+ }
+
+ return (
+ <section className="setup-layout">
+ <div className="hero-card">
+ <p className="eyebrow">First-time setup</p>
+ <h1>Create your Ledger base</h1>
+ <p className="lede">
+ Set your site identity, choose whether public docs are enabled, and create the first
+ owner account. This happens once on a fresh install.
+ </p>
+ </div>
+ <form className="card stack" onSubmit={submit}>
+ <div className="split">
+ <label>
+ Site name
+ <input
+ value={form.siteName}
+ onChange={(event) => setForm((current) => ({ ...current, siteName: event.target.value }))}
+ />
+ </label>
+ <label>
+ Brand color
+ <input
+ value={form.brandColor}
+ onChange={(event) =>
+ setForm((current) => ({ ...current, brandColor: event.target.value }))
+ }
+ />
+ </label>
+ </div>
+ <label>
+ Footer text
+ <input
+ value={form.footerText}
+ onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
+ />
+ </label>
+ <label className="checkbox-row">
+ <input
+ type="checkbox"
+ checked={form.publicKnowledgeBaseEnabled}
+ onChange={(event) =>
+ setForm((current) => ({
+ ...current,
+ publicKnowledgeBaseEnabled: event.target.checked
+ }))
+ }
+ />
+ Enable the public knowledge base immediately
+ </label>
+ <div className="split">
+ <label>
+ Owner name
+ <input
+ value={form.ownerDisplayName}
+ onChange={(event) =>
+ setForm((current) => ({ ...current, ownerDisplayName: event.target.value }))
+ }
+ />
+ </label>
+ <label>
+ Owner email
+ <input
+ value={form.ownerEmail}
+ onChange={(event) => setForm((current) => ({ ...current, ownerEmail: event.target.value }))}
+ />
+ </label>
+ </div>
+ <label>
+ Password
+ <input
+ type="password"
+ value={form.password}
+ onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
+ />
+ </label>
+ <button type="submit">Initialize Ledger</button>
+ {status ? <p className="muted">{status}</p> : null}
+ </form>
+ </section>
+ );
+}
+
function Dashboard({ user }: { user: SessionUser }) {
const [spaces, setSpaces] = useState<Array<{ id: string; name: string; key: string }>>([]);
const [analytics, setAnalytics] = useState<{
@@ -387,9 +518,11 @@ function Dashboard({ user }: { user: SessionUser }) {
export function App() {
const { user, setUser, loading } = useSession();
const [branding, setBranding] = useState<BrandingResponse["branding"] | null>(null);
+ const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
useEffect(() => {
api.get<BrandingResponse>("/api/settings/public").then((response) => setBranding(response.branding));
+ api.get<SetupStatus>("/api/setup/status").then(setSetupStatus);
}, []);
async function logout() {
@@ -397,10 +530,38 @@ export function App() {
setUser(null);
}
- if (loading) {
+ if (loading || !setupStatus) {
return <div className="loading-screen">Loading Ledger...</div>;
}
+ if (!setupStatus.isInitialized) {
+ return (
+ <Shell branding={branding} user={null} onLogout={logout}>
+ <Routes>
+ <Route
+ path="*"
+ element={
+ <SetupPage
+ initialBranding={setupStatus.branding}
+ onInitialized={(initializedUser, initializedBranding) => {
+ setUser(initializedUser);
+ setBranding(initializedBranding);
+ setSetupStatus({
+ isInitialized: true,
+ branding: {
+ site_name: initializedBranding.siteName,
+ brand_color: initializedBranding.brandColor
+ }
+ });
+ }}
+ />
+ }
+ />
+ </Routes>
+ </Shell>
+ );
+ }
+
return (
<Shell branding={branding} user={user} onLogout={logout}>
<Routes>
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 7e37c95..d2b1263 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -105,7 +105,8 @@ main {
}
.hero-layout,
-.dashboard-grid {
+.dashboard-grid,
+.setup-layout {
display: grid;
gap: 1.25rem;
}
@@ -114,6 +115,10 @@ main {
grid-template-columns: 1.2fr 1fr;
}
+.setup-layout {
+ grid-template-columns: 1fr 1.1fr;
+}
+
.grid,
.content-layout {
display: grid;
@@ -285,7 +290,8 @@ main {
@media (max-width: 900px) {
.hero-layout,
.grid,
- .content-layout {
+ .content-layout,
+ .setup-layout {
grid-template-columns: 1fr;
}
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index ca56ae1..69f2c5d 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -2,7 +2,9 @@ import "dotenv/config";
import { Worker } from "bullmq";
import Redis from "ioredis";
-const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
+const connection = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
+ maxRetriesPerRequest: null
+});
const worker = new Worker(
"ledger-jobs",
diff --git a/docker-compose.yml b/docker-compose.yml
index bdd9441..7f366ac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,27 +1,41 @@
services:
+ bootstrap:
+ image: node:20-bookworm
+ working_dir: /app
+ command: sh /app/infra/scripts/dev-bootstrap.sh
+ volumes:
+ - .:/app
+ - node_modules:/app/node_modules
+
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
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ledger} -d ${POSTGRES_DB:-ledger}"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
redis:
image: redis:7-alpine
- ports:
- - "6379:6379"
volumes:
- redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
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"
+ command: sh -c "npm run db:migrate && npm run db:seed && npm --workspace @ledger/api run dev"
env_file:
- .env
ports:
@@ -29,35 +43,50 @@ services:
volumes:
- .:/app
- ./storage/uploads:/app/storage/uploads
+ - node_modules:/app/node_modules
depends_on:
- - postgres
- - redis
+ bootstrap:
+ condition: service_completed_successfully
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
web:
image: node:20-bookworm
working_dir: /app
- command: sh -c "npm install && npm run dev:web -- --host 0.0.0.0"
+ command: npm --workspace @ledger/web run dev -- --host 0.0.0.0
env_file:
- .env
ports:
- "5173:5173"
volumes:
- .:/app
+ - node_modules:/app/node_modules
depends_on:
- - api
+ bootstrap:
+ condition: service_completed_successfully
+ api:
+ condition: service_started
worker:
image: node:20-bookworm
working_dir: /app
- command: sh -c "npm install && npm run dev:worker"
+ command: npm --workspace @ledger/worker run dev
env_file:
- .env
volumes:
- .:/app
+ - node_modules:/app/node_modules
depends_on:
- - api
- - redis
+ bootstrap:
+ condition: service_completed_successfully
+ api:
+ condition: service_started
+ redis:
+ condition: service_healthy
volumes:
postgres_data:
redis_data:
+ node_modules:
diff --git a/infra/scripts/dev-bootstrap.sh b/infra/scripts/dev-bootstrap.sh
new file mode 100644
index 0000000..ee6d7e9
--- /dev/null
+++ b/infra/scripts/dev-bootstrap.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+cd /app
+
+mkdir -p /app/node_modules
+
+if [ ! -f /app/node_modules/.ledger-ready ]; then
+ npm install
+ npm run build --workspace @ledger/shared
+ touch /app/node_modules/.ledger-ready
+fi