···11## Getting started
2233-**Prerequisites:** [Bun](https://bun.sh) and [Node.js](https://nodejs.org) **v22**. Bun runs the appview and unit tests; Node is needed for the local ATProto PDS (`@atproto/pds` requires the `better-sqlite3` native addon which is incompatible with Bun's runtime, and only builds against Node 22).
44-55-Use a version manager to pin Node 22 for this project — [mise](https://mise.jdx.dev) is recommended:
66-77-```bash
88-mise use node@22 # creates .mise.toml, adds node=22
99-```
33+Prerequisites: [Bun](https://bun.sh) and [Node.js](https://nodejs.org) v22. Bun runs the appview and tests. Node is needed for the local ATProto PDS (`@atproto/pds` requires `better-sqlite3` which only builds against Node 22).
1041111-Or with nvm: `echo "22" > .nvmrc && nvm use`.
55+Use [mise](https://mise.jdx.dev) to pin Node 22: `mise use node@22`. Or nvm: `nvm use 22`.
126137```bash
148bun install
1515-bun run dev # appview server on :3000 (watch mode)
99+bun run dev # appview on :3000 (watch mode)
1610bun test # run tests
1711bun run lint # check with Biome
1812bun run typecheck # tsc --noEmit
···20142115The database auto-seeds with sample data on first run. To reset, delete `lichen.db` and restart.
22162323-## Development commands
1717+## Full local stack
1818+1919+`bun run dev:full` starts a test PDS (Node), the appview, and the firehose subscriber. Two test accounts are created automatically. Log in via:
2020+- `http://localhost:3000/dev/login/alice.test`
2121+- `http://localhost:3000/dev/login/bob.test`
2222+2323+## All commands
24242525```bash
2626bun run dev # appview server (watch mode)
2727-bun run dev:full # full local stack: PDS (Node) + appview + firehose
2828-bun run dev:firehose # firehose subscriber (separate process)
2727+bun run dev:full # full local stack: PDS + appview + firehose
2828+bun run dev:firehose # firehose subscriber only
2929bun run dev:viz # watch-mode viz bundle
3030bun run dev:editor # watch-mode editor bundle
3131-bun test # run all tests (unit + integration)
3131+bun test # all tests
3232bun run test:unit # unit tests only
3333-bun run test:integration # integration tests only (requires Node for PDS)
3434-bun run lint # check linting/formatting
3535-bun run lint:fix # auto-fix lint/format issues
3636-bun run typecheck # type check (tsc --noEmit)
3333+bun run test:integration # integration tests (requires Node for PDS)
3434+bun run lint:fix # auto-fix lint/format
3535+bun run typecheck # type check
3736bun run build:css # build Tailwind CSS
3838-bun run build:viz # bundle viz renderers → public/viz/dist.js
3939-bun run build:editor # bundle editor → public/editor/dist.js
3737+bun run build:viz # bundle viz renderers
3838+bun run build:editor # bundle editor
4039```
41404242-### Full local stack (`dev:full`)
4343-4444-`bun run dev:full` starts a complete local environment:
4545-4646-1. A test PDS running under Node (via `@atproto/dev-env`)
4747-2. The appview server on port 3000 (Bun, watch mode)
4848-3. The firehose subscriber (Bun, watch mode)
4949-5050-Two test accounts are created automatically (alice.test, bob.test). Log in via:
5151-- `http://localhost:3000/dev/login/alice.test`
5252-- `http://localhost:3000/dev/login/bob.test`
5353-5454-This sets a `did` cookie directly — no OAuth flow needed.
5555-5641## Project layout
57425843```
5944src/
6045 server/ # Elysia routes, database (schema, queries/, seed)
6161- atproto/ # ATProto OAuth (client, routes, session, env) + PDS write functions
4646+ atproto/ # OAuth, PDS write functions
6247 firehose/ # standalone firehose subscriber
6363- lib/ # shared logic (at-uri, diff, markdown, slugs, viz, image, blob refs, ws-polyfill, i18n, urls, response)
6464- shared/ # shared type definitions (viz-types)
4848+ lib/ # shared logic (diff, markdown, slugs, viz, image, blob, i18n, urls...)
4949+ shared/ # shared type definitions
6550 views/ # HTML templates
6651lexicons/ # ATProto lexicon schemas
6767-public/ # static assets (editor, viz renderers, CSS)
6868-scripts/ # dev tooling (start-pds.mjs, dev-full.ts)
5252+public/ # static assets (editor, viz, CSS)
5353+scripts/ # dev tooling
6954tests/ # mirrors src/ structure
7070- integration/ # end-to-end tests against a real PDS (Node subprocess)
7155```
72567357## Code style
74587559- TypeScript, strict mode
7676-- Biome for formatting (tabs, double quotes) — run `bun run lint:fix` before committing
6060+- Biome for formatting (tabs, double quotes), run `bun run lint:fix` before committing
7761- Named exports, no default exports
7862- Functions over classes unless state is needed
7979-- Prefer `const` and immutable patterns
8080-- Error handling: throw typed error classes (`WikiNotFoundError`, `AccessDeniedError`, `PdsWriteError`), caught by a single Elysia `onError` hook — route handlers never catch errors themselves
8181-- Keep route handlers thin: orchestrators in `src/lib/orchestrators/` own the PDS→DB lifecycle per domain; routes parse input, call the orchestrator, and redirect
8282-- PDS is canonical: PDS write must succeed before DB write. If PDS fails, the request fails (no partial state)
8383-- No ORMs — raw SQL with prepared statements
8484-- Route param names must be consistent across all routes sharing a path segment
8585-- UI strings must use the i18n system (`t(locale).section.key`) — no hardcoded English strings in views
8686-- Tailwind class names must come from `src/views/theme.ts` (`THEME.*`) — no hardcoded class strings in views. Client-side hex colors come from `THEME_HEX`
6363+- Throw typed error classes (`WikiNotFoundError`, `AccessDeniedError`, `PdsWriteError`), caught by a single `onError` hook. Route handlers never catch errors themselves.
6464+- Route handlers stay thin: orchestrators in `src/lib/orchestrators/` own the PDS-then-DB lifecycle. Routes parse input, call the orchestrator, redirect.
6565+- PDS write must succeed before DB write. If PDS fails, the request fails.
6666+- No ORMs, raw SQL with prepared statements
6767+- UI strings go through `src/lib/i18n/`, no hardcoded English in views
6868+- Tailwind classes come from `src/views/theme.ts` (`THEME.*`), no hardcoded class strings in views
87698870## Database
89719090-SQLite via `bun:sqlite`. Schema defined in `src/server/db/schema.ts`.
9191-9292-Tables: `wikis`, `notes`, `revisions`, `snapshots`, `memberships`, `requests`, `current_note`, `backlinks`, `blobs`, `firehose_cursor`, `oauth_sessions`, `oauth_state`
9393-9494-- Schema created via `CREATE TABLE IF NOT EXISTS` on startup
9595-- All queries use prepared statements (`src/server/db/queries/`)
9696-- Write operations wrap related changes in transactions
9797-- Auto-seeds with sample data if tables are empty (`src/server/db/seed.ts`)
7272+SQLite via `bun:sqlite`. Schema in `src/server/db/schema.ts`, queries in `src/server/db/queries/`. All queries use prepared statements, writes are wrapped in transactions.
98739974## ATProto
10075101101-**Lexicon namespace:** `wiki.lichen` (domain: `lichen.wiki`). Lexicon JSON files live in `lexicons/`.
7676+Lexicon namespace: `wiki.lichen` (domain: `lichen.wiki`). Schemas in `lexicons/`.
1027710378Record types: `wiki.lichen.wiki`, `wiki.lichen.note`, `wiki.lichen.noteRevision`, `wiki.lichen.memberRequest`, `wiki.lichen.membership`, `wiki.lichen.bookmark`
10479105105-In dev mode (no OAuth configured), PDS writes are skipped and a mock DID (`did:plc:mock123`) is used. All data lives in SQLite only.
8080+In dev mode (no OAuth configured), PDS writes are skipped and a mock DID is used.
1068110782## Tests
10883109109-Tests mirror `src/` under `tests/`. Run with `bun test`.
8484+Tests mirror `src/` under `tests/`. All test files share one in-memory DB singleton (`tests/preload.ts` sets `DB_PATH=:memory:`), so tests that create data must clean up in `afterAll`.
11085111111-- Add tests for new code; update existing tests when signatures change
112112-- Test edge cases, not just the happy path
113113-- `tests/preload.ts` sets `DB_PATH=:memory:` — all test files share one in-memory DB singleton, so tests that create data must clean up in `afterAll`
114114-- Wikilink tests should pass a `wikiSlug` parameter to `renderMarkdown()` for correct path resolution
115115-116116-### Integration tests
117117-118118-`tests/integration/` contains end-to-end tests that write real ATProto records to a PDS and verify they flow through the firehose into the appview DB.
119119-120120-Architecture:
121121-- A test PDS runs as a **Node subprocess** (via `scripts/start-pds.mjs`) because `@atproto/pds` depends on the `better-sqlite3` native addon, which is incompatible with Bun
122122-- The Bun test process connects to the PDS via `@atproto/sync` Firehose (using a `ws.createWebSocketStream` polyfill in `src/lib/ws-polyfill.ts`)
123123-- Records are written via `AtpAgent`, ingested by the in-process firehose handler, and assertions check the `bun:sqlite` appview DB
124124-125125-Run integration tests only: `bun run test:integration`
126126-8686+Integration tests in `tests/integration/` write real ATProto records to a PDS subprocess and verify they flow through the firehose into the appview DB. Run them with `bun run test:integration`.
+14-44
README.md
···11-# Lichen
11+A collaborative wiki built on [ATProto](https://atproto.com). Knowledge grows together.
2233-A wiki built on [ATProto](https://atproto.com). Sign in with your Bluesky account, create public or private wikis, and write in plain markdown with `[[wikilinks]]`.
33+<!-- TODO: screenshot or demo gif -->
4455-Your data lives on your own PDS: records persist independently of this app, and your Bluesky identity works everywhere on ATProto.
55+## What is Lichen?
6677-**Features:** wikilinks · backlinks · revision history · access control (public/private wikis, roles) · D3 visualizations in fenced code blocks · image upload · EN/FR UI
77+Sign in with your Bluesky account, create wikis, and write in plain markdown with `[[wikilinks]]`. Your data lives on your own PDS: it persists independently of this app, and your Bluesky identity works everywhere on ATProto.
8899-**Lexicon namespace:** `wiki.lichen` — domain: `lichen.wiki`
99+## Features
10101111-## Quick start
1212-```bash
1313-bun install
1414-bun run dev
1515-```
1616-1717-Opens at `http://localhost:3000` with a seeded sample wiki on first run.
1818-1919-### Full local stack (with PDS)
2020-2121-```bash
2222-bun run dev:full
2323-```
2424-2525-Starts a local ATProto PDS (via Node), the appview, and the firehose subscriber — all wired together with test accounts. Visit `http://localhost:3000/dev/login/alice.test` to log in.
1111+- Wikilinks and backlinks: link notes with `[[double brackets]]`, backlinks are tracked automatically
1212+- Revision history: every edit is a diff, full history is always available
1313+- Access control: public or private wikis, with admin/contributor/viewer roles
1414+- Real-time sync via the ATProto firehose
1515+- Data portability: your wikis and notes are ATProto records on your PDS, not locked in
1616+- Markdown editor with split-pane live preview, image upload via drag-drop/paste
1717+- Multilingual UI (English and French)
1818+- Self-hostable: deploy your own instance, fully independent
26192720## How it works
28212929-Contributors write revision records directly to their own PDS via OAuth.
3030-An AppView server ingests those records from the firehose, materializes wiki state, and serves reads. Private wikis use a hosted app PDS; public wiki data is fully portable.
3131-3232-## Tech stack
3333-3434-| Layer | Tech |
3535-|---|---|
3636-| Runtime | [Bun](https://bun.sh) |
3737-| Framework | [Elysia](https://elysiajs.com) |
3838-| Database | SQLite via `bun:sqlite` |
3939-| UI | Server-rendered HTML + [HTMX](https://htmx.org) + [CodeMirror 6](https://codemirror.net) |
4040-| Auth | ATProto OAuth |
4141-4242-Full stack details in [CONTRIBUTING.md](CONTRIBUTING.md).
4343-4444-## Self-hosting
4545-4646-Clone the repo, set two env vars, deploy:
4747-4848-- `PUBLIC_URL` — your instance's public HTTPS URL
4949-- `OAUTH_PRIVATE_KEY_PATH` — ES256 PEM key for ATProto OAuth
5050-5151-Requires Bun, Node.js, and SQLite. Wildcard DNS optional (path-based URLs work without it).
5252-Blob storage defaults to proxying from PDS; R2/S3 can be added as a cache layer.
2222+Contributors write directly to their own [Personal Data Server](https://atproto.com/guides/glossary#pds) via OAuth. The Lichen appview ingests those records from the network firehose, assembles wiki state, and serves reads. Your data is never locked in: it follows you across the ATProto ecosystem.
53235424## Contributing
5525