···1818import { OAUTH_SCOPE } from "../lib/constants.ts";
1919import { getDb } from "../server/db/index.ts";
2020import type { AtprotoEnv } from "./env.ts";
2121-import { getDevPlcUrl } from "./env.ts";
22212322class SqliteSessionStore implements Store<string, StoredSession> {
2423 get(did: string): StoredSession | undefined {
···9190}
92919392function buildActorResolver(): LocalActorResolver {
9494- const plcUrl = getDevPlcUrl();
9593 return new LocalActorResolver({
9694 handleResolver: new CompositeHandleResolver({
9795 methods: {
···10199 }),
102100 didDocumentResolver: new CompositeDidDocumentResolver({
103101 methods: {
104104- plc: new PlcDidDocumentResolver(
105105- plcUrl ? { apiUrl: plcUrl } : undefined,
106106- ),
102102+ plc: new PlcDidDocumentResolver(),
107103 web: new WebDidDocumentResolver(),
108104 },
109105 }),
+20-11
src/atproto/env.ts
···2828 return process.env["HANDLE_RESOLVER_URL"] ?? "https://bsky.social";
2929}
30303131-export function getDevPdsUrl(): string | null {
3232- return process.env["DEV_PDS_URL"] ?? null;
3333-}
3434-3535-export function getDevPlcUrl(): string | null {
3636- return process.env["DEV_PLC_URL"] ?? null;
3737-}
3838-3931interface DevAccount {
4032 did: string;
4133 handle: string;
4242- password: string;
4334}
44353636+const DEFAULT_DEV_ACCOUNTS: Record<string, DevAccount> = {
3737+ alice: {
3838+ did: "did:plc:devalice00000000000000000",
3939+ handle: "alice.test",
4040+ },
4141+ bob: {
4242+ did: "did:plc:devbob0000000000000000000",
4343+ handle: "bob.test",
4444+ },
4545+};
4646+4747+/**
4848+ * Dev-mode accounts. When OAuth is not configured, the app falls back to
4949+ * cookie-only impersonation against this list. Returns null when OAuth is
5050+ * configured (production); otherwise reads DEV_ACCOUNTS env or hands back
5151+ * the baked-in alice/bob defaults.
5252+ */
4553export function getDevAccounts(): Record<string, DevAccount> | null {
5454+ if (getAtprotoEnv() !== null) return null;
4655 const raw = process.env["DEV_ACCOUNTS"];
4747- if (!raw) return null;
5656+ if (!raw) return DEFAULT_DEV_ACCOUNTS;
4857 try {
4958 return JSON.parse(raw) as Record<string, DevAccount>;
5059 } catch {
5151- return null;
6060+ return DEFAULT_DEV_ACCOUNTS;
5261 }
5362}
+6-11
src/atproto/routes.ts
···66import { getClientIp, rateLimit } from "../lib/rate-limit.ts";
77import { htmlResponse } from "../lib/response.ts";
88import { loginPage } from "../views/login.ts";
99-import { getDevAccounts, getDevPdsUrl } from "./env.ts";
99+import { getDevAccounts } from "./env.ts";
1010import { getClient } from "./session.ts";
11111212function getSafeReturnTo(cookieHeader: string | null): string {
···147147 });
148148 });
149149150150- // Dev-login bypass: only available when DEV_PDS_URL is set
151151- const devPdsUrl = getDevPdsUrl();
152152- if (devPdsUrl) {
150150+ // Dev-login bypass: only available when OAuth is not configured.
151151+ const devAccounts = getDevAccounts();
152152+ if (devAccounts) {
153153 app.get("/dev/login/:handle", ({ params, request }) => {
154154- const accounts = getDevAccounts();
155155- if (!accounts) {
156156- return new Response("DEV_ACCOUNTS not configured", { status: 503 });
157157- }
158158-159159- const account = Object.values(accounts).find(
154154+ const account = Object.values(devAccounts).find(
160155 (a) => a.handle === params.handle,
161156 );
162157 if (!account) {
···173168 ["Location", returnToUrl],
174169 [
175170 "Set-Cookie",
176176- `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${LIMITS.sessionMaxAgeSecs}`,
171171+ `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${LIMITS.sessionMaxAgeSecs}`,
177172 ],
178173 [
179174 "Set-Cookie",
+8-41
src/atproto/session.ts
···11import { Client } from "@atcute/client";
22import type { Did } from "@atcute/lexicons/syntax";
33import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client";
44-import { PasswordSession } from "@atcute/password-session";
54import { resolveProfile } from "../lib/profile.ts";
65import { createOAuthClient } from "./client.ts";
77-import { getAtprotoEnv, getDevAccounts, getDevPdsUrl } from "./env.ts";
66+import { getAtprotoEnv, getDevAccounts } from "./env.ts";
8798export interface Session {
109 did: string;
···3332}
34333534/**
3636- * Get a dev-mode session from the DID cookie when OAuth is not configured.
3737- * Returns a Session without oauthSession — use getAgent() to get an
3838- * authenticated client that uses an app-password session against the dev PDS.
3535+ * Cookie-only dev session. The DID cookie is trusted because OAuth is not
3636+ * configured; only valid in dev where DEV_ACCOUNTS lists the impersonatable users.
3937 */
4038function getDevSession(cookieHeader: string | undefined): Session | null {
4139 if (!cookieHeader) return null;
···5452}
55535654/**
5757- * Get an RPC client authenticated for the session.
5858- * In production: wraps the OAuth session.
5959- * In dev-full mode (no oauthSession): logs in with the configured app password.
5555+ * Get an RPC client authenticated for the session. Returns null in dev mode —
5656+ * dev has no PDS, so orchestrators skip PDS writes and go straight to DB.
6057 */
6161-export async function getAgent(session: Session): Promise<Client> {
5858+export function getAgent(session: Session): Client | null {
6259 if (session.oauthSession) {
6360 return new Client({ handler: session.oauthSession });
6461 }
6565- return loginDevClient(session);
6666-}
6767-6868-async function loginDevClient(session: Session): Promise<Client> {
6969- const devPdsUrl = getDevPdsUrl();
7070- if (!devPdsUrl) {
7171- throw new Error("DEV_PDS_URL not configured");
7272- }
7373-7474- const accounts = getDevAccounts();
7575- if (!accounts) {
7676- throw new Error("DEV_ACCOUNTS not configured");
7777- }
7878-7979- const account = Object.values(accounts).find((a) => a.did === session.did);
8080- if (!account) {
8181- throw new Error(`No dev account for DID: ${session.did}`);
8282- }
8383-8484- const passwordSession = await PasswordSession.login({
8585- service: devPdsUrl,
8686- identifier: account.handle,
8787- password: account.password,
8888- });
8989- return new Client({ handler: passwordSession });
6262+ return null;
9063}
91649265let oauthClient: OAuthClient | null = null;
···10679 if (client) {
10780 return getSession(client, request.headers.get("cookie") ?? undefined);
10881 }
109109-110110- // Fall back to dev-mode session when OAuth is not configured
111111- if (getDevPdsUrl()) {
112112- return getDevSession(request.headers.get("cookie") ?? undefined);
113113- }
114114-115115- return null;
8282+ return getDevSession(request.headers.get("cookie") ?? undefined);
11683}
+4-5
src/firehose/index.ts
···11import { JetstreamSubscription } from "@atcute/jetstream";
22-import { getDevPdsUrl, getJetstreamUrl } from "../atproto/env.ts";
22+import { getJetstreamUrl, isAuthEnabled } from "../atproto/env.ts";
33import { COLLECTIONS } from "../lib/constants.ts";
44import { getCursor, setCursor } from "../server/db/queries/index.ts";
55import { type FirehoseCommit, handleCommitEvent } from "./handlers.ts";
6677const jetstreamUrl = getJetstreamUrl();
88-// In dev mode the PDS is ephemeral -- each run starts fresh.
99-// Persisting the cursor across sessions causes the subscriber to ask jetstream
1010-// for events older than its retention window.
1111-const isDevMode = !!getDevPdsUrl();
88+// Dev mode has no real users on the network; persisting a cursor would only ask
99+// jetstream for events older than its retention window on the next run.
1010+const isDevMode = !isAuthEnabled();
1211const savedCursor = isDevMode ? null : getCursor();
13121413console.log(`Jetstream connecting to ${jetstreamUrl}`);
+1-5
src/lib/identity.ts
···99} from "@atcute/identity-resolver";
1010import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node";
1111import type { Did } from "@atcute/lexicons/syntax";
1212-import { getDevPlcUrl } from "../atproto/env.ts";
13121413let handleInstance: HandleResolver | null = null;
1514let didDocInstance: DidDocumentResolver | null = null;
···28272928export function getDidDocumentResolver(): DidDocumentResolver {
3029 if (!didDocInstance) {
3131- const plcUrl = getDevPlcUrl();
3230 didDocInstance = new CompositeDidDocumentResolver({
3331 methods: {
3434- plc: new PlcDidDocumentResolver(
3535- plcUrl ? { apiUrl: plcUrl } : undefined,
3636- ),
3232+ plc: new PlcDidDocumentResolver(),
3733 web: new WebDidDocumentResolver(),
3834 },
3935 });
···11import { Database } from "bun:sqlite";
22+import { isAuthEnabled } from "../../atproto/env.ts";
23import { initSchema } from "./schema.ts";
34import { seedIfEmpty } from "./seed.ts";
45···1011 if (!db) {
1112 db = new Database(DB_PATH);
1213 initSchema(db);
1313- if (process.env["SEED_DB"]) seedIfEmpty(db);
1414+ // Auto-seed dev databases. Tests use :memory: and manage their own state.
1515+ if (!isAuthEnabled() && DB_PATH !== ":memory:") seedIfEmpty(db);
1416 }
1517 return db;
1618}
+92-103
src/server/db/seed.ts
···22import * as TID from "@atcute/tid";
33import { getDevAccounts } from "../../atproto/env.ts";
4455+interface SeedNote {
66+ slug: string;
77+ title: string;
88+ content: string;
99+}
1010+1111+function seedWiki(
1212+ db: Database,
1313+ owner: { did: string; handle: string },
1414+ slug: string,
1515+ name: string,
1616+ description: string,
1717+ notes: SeedNote[],
1818+): void {
1919+ const wikiAtUri = `at://${owner.did}/wiki.lichen.wiki/${slug}`;
2020+ db.run(
2121+ "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)",
2222+ [slug, owner.did, name, "public", description, wikiAtUri],
2323+ );
2424+ db.run(
2525+ "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)",
2626+ [
2727+ slug,
2828+ owner.did,
2929+ "admin",
3030+ `at://${owner.did}/wiki.lichen.membership/${TID.now()}`,
3131+ ],
3232+ );
3333+3434+ for (const note of notes) {
3535+ const noteAtUri = `at://${owner.did}/wiki.lichen.note/${TID.now()}`;
3636+ db.run(
3737+ "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
3838+ [note.slug, slug, note.title, owner.did, noteAtUri],
3939+ );
4040+ db.run(
4141+ `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at)
4242+ VALUES (?, ?, ?, datetime('now'))`,
4343+ [noteAtUri, note.content, `${noteAtUri}/rev/1`],
4444+ );
4545+ }
4646+}
4747+548export function seedIfEmpty(db: Database): void {
649 const count = db.query("SELECT COUNT(*) as n FROM wikis").get() as {
750 n: number;
851 };
952 if (count.n > 0) return;
10531111- console.log("Seeding database with sample data...");
1212-1313- const homeTid = TID.now();
1414- const helloTid = TID.now();
1515- const gettingStartedTid = TID.now();
1654 const accounts = getDevAccounts();
1717- const mockDid = accounts
1818- ? (Object.values(accounts)[0]?.did ?? "did:plc:seed")
1919- : "did:plc:seed";
5555+ if (!accounts) return;
5656+ const alice = accounts["alice"];
5757+ const bob = accounts["bob"];
5858+ if (!alice || !bob) return;
20592121- const noteAtUris = {
2222- home: `at://${mockDid}/wiki.lichen.note/${homeTid}`,
2323- hello: `at://${mockDid}/wiki.lichen.note/${helloTid}`,
2424- gettingStarted: `at://${mockDid}/wiki.lichen.note/${gettingStartedTid}`,
2525- };
6060+ console.log("Seeding database with sample data...");
26612727- const noteContents: Record<string, string> = {
2828- [noteAtUris.home]: `# Welcome to the Test Wiki
2929-3030-This is the **home page**. It appears when you visit the wiki root.
3131-3232-## Quick Links
6262+ db.run("BEGIN TRANSACTION");
6363+ try {
6464+ seedWiki(
6565+ db,
6666+ alice,
6767+ "alices-garden",
6868+ "Alice's Garden",
6969+ "A sample public wiki owned by Alice for exploring Lichen.",
7070+ [
7171+ {
7272+ slug: "home",
7373+ title: "Home",
7474+ content: `# Welcome to Alice's Garden
33753434-- [[hello|Hello World]] — a sample note
3535-- [[getting-started|Getting Started]] — how to use Lichen
3636-3737-## About
7676+This is the **home page**. Try editing it, then check the history.
38773939-This wiki is powered by ATProto. Every edit creates a diff stored permanently on the protocol.
7878+- [[hello|Hello]] — a sample note
7979+- [[bobs-notebook/home|Bob's Notebook]] — Alice contributes there too
4080`,
4141- [noteAtUris.hello]: `# Hello World
4242-4343-Welcome to the **Test Wiki**. This is a sample note rendered with markdown-it.
8181+ },
8282+ {
8383+ slug: "hello",
8484+ title: "Hello",
8585+ content: `# Hello
44864545-## Features
4646-4747-- Supports **bold**, *italic*, and ~~strikethrough~~
4848-- [[getting-started|Check out the Getting Started guide]]
4949-- Code blocks work too:
5050-5151-\`\`\`js
5252-console.log("hello from Lichen");
5353-\`\`\`
8787+A short note. Edit me to see how diffs work.
5488`,
5555- [noteAtUris.gettingStarted]: `# Getting Started
8989+ },
9090+ ],
9191+ );
56925757-This wiki runs on [[hello|ATProto]]. Every edit is a diff stored permanently.
9393+ seedWiki(
9494+ db,
9595+ bob,
9696+ "bobs-notebook",
9797+ "Bob's Notebook",
9898+ "A sample public wiki owned by Bob. Alice has contributor access.",
9999+ [
100100+ {
101101+ slug: "home",
102102+ title: "Home",
103103+ content: `# Bob's Notebook
581045959-1. Sign in with your Bluesky account
6060-2. Create or join a wiki
6161-3. Start writing
105105+Bob owns this wiki. Alice can edit notes here as a contributor.
62106`,
6363- };
6464-6565- db.run("BEGIN TRANSACTION");
6666- try {
6767- db.run(
6868- "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)",
6969- [
7070- "test",
7171- mockDid,
7272- "Test Wiki",
7373- "public",
7474- "A sample wiki for exploring Lichen features.",
7575- `at://${mockDid}/wiki.lichen.wiki/test`,
107107+ },
76108 ],
77109 );
781107979- db.run(
8080- "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
8181- ["home", "test", "Home", mockDid, noteAtUris.home],
8282- );
111111+ // Alice is a contributor on Bob's wiki — log in as alice and edit it.
83112 db.run(
8484- "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
8585- ["hello", "test", "Hello World", mockDid, noteAtUris.hello],
8686- );
8787- db.run(
8888- "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
113113+ "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)",
89114 [
9090- "getting-started",
9191- "test",
9292- "Getting Started",
9393- mockDid,
9494- noteAtUris.gettingStarted,
115115+ "bobs-notebook",
116116+ alice.did,
117117+ "contributor",
118118+ `at://${bob.did}/wiki.lichen.membership/${TID.now()}`,
95119 ],
96120 );
9797- for (const [atUri, content] of Object.entries(noteContents)) {
9898- db.run(
9999- `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at)
100100- VALUES (?, ?, ?, datetime('now'))`,
101101- [atUri, content, `${atUri}/rev/1`],
102102- );
103103- }
104104-105105- // Bulk filler notes so the sidebar overflows and the main pane scrolls.
106106- // Useful for visually testing the app-shell layout locally.
107107- const longBody = Array.from(
108108- { length: 60 },
109109- (_, i) =>
110110- `## Section ${i + 1}\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n`,
111111- ).join("\n");
112112-113113- for (let i = 1; i <= 40; i++) {
114114- const slug = `filler-note-${String(i).padStart(2, "0")}`;
115115- const tid = TID.now();
116116- const atUri = `at://${mockDid}/wiki.lichen.note/${tid}`;
117117- db.run(
118118- "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)",
119119- [slug, "test", `Filler note ${i}`, mockDid, atUri],
120120- );
121121- // Make a few notes very long so the main pane scrolls.
122122- const content =
123123- i % 5 === 0
124124- ? `# Filler note ${i} (long)\n\n${longBody}`
125125- : `# Filler note ${i}\n\nShort placeholder content for sidebar overflow testing.\n`;
126126- db.run(
127127- `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at)
128128- VALUES (?, ?, ?, datetime('now'))`,
129129- [atUri, content, `${atUri}/rev/1`],
130130- );
131131- }
132121133122 db.run("COMMIT");
134123 } catch (err) {
+11-7
src/server/index.ts
···11import { staticPlugin } from "@elysiajs/static";
22import { Elysia } from "elysia";
33-import { getAtprotoEnv, getDevPdsUrl, isAuthEnabled } from "../atproto/env.ts";
33+import { getAtprotoEnv, isAuthEnabled } from "../atproto/env.ts";
44import { atprotoRoutes } from "../atproto/routes.ts";
55import { AppError } from "../lib/errors.ts";
66import { getDb } from "./db/index.ts";
···1515import { searchRoutes } from "./routes/search.ts";
1616import { wikiRoutes } from "./routes/wiki.ts";
17171818-// Dev and prod OAuth configurations must not coexist: the dev-session fallback
1919-// trusts a `did=` cookie without a password check, so a prod instance with
2020-// DEV_PDS_URL set would allow impersonation.
2121-if (getAtprotoEnv() !== null && getDevPdsUrl() !== null) {
1818+// Dev and prod auth must not coexist: the dev-session fallback trusts a `did=`
1919+// cookie without a password check. A prod instance that also sets DEV_ACCOUNTS
2020+// would still ignore it (getDevAccounts returns null when OAuth env is set),
2121+// but bail loudly to flag misconfiguration rather than silently dropping it.
2222+if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) {
2223 throw new Error(
2323- "Dev and prod OAuth configurations are mutually exclusive — unset DEV_PDS_URL or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).",
2424+ "Dev and prod auth are mutually exclusive — unset DEV_ACCOUNTS or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).",
2425 );
2526}
2627···3435}
35363637const app = new Elysia()
3737- .onError(({ error }) => {
3838+ .onError(({ error, code }) => {
3839 if (error instanceof AppError) {
3940 return new Response(error.message, { status: error.statusCode });
4141+ }
4242+ if (code === "NOT_FOUND") {
4343+ return new Response("Not found", { status: 404 });
4044 }
4145 console.error("Unhandled error:", error);
4246 return new Response("Internal server error", { status: 500 });