🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Drop PDS dev testing for simplicity

juprodh b4980520 74908f38

+211 -275
-3
bun.lock
··· 13 13 "@atcute/jetstream": "^1.1.2", 14 14 "@atcute/lexicons": "^1.3.0", 15 15 "@atcute/oauth-node-client": "^1.1.0", 16 - "@atcute/password-session": "^0.1.0", 17 16 "@atcute/tid": "^1.1.2", 18 17 "@codemirror/lang-markdown": "^6.5.0", 19 18 "@codemirror/state": "^6.0.0", ··· 70 69 "@atcute/oauth-node-client": ["@atcute/oauth-node-client@1.1.0", "", { "dependencies": { "@atcute/client": "^4.2.1", "@atcute/identity": "^1.1.3", "@atcute/identity-resolver": "^1.2.2", "@atcute/lexicons": "^1.2.7", "@atcute/oauth-crypto": "^0.1.0", "@atcute/oauth-keyset": "^0.1.0", "@atcute/oauth-types": "^0.1.1", "@atcute/util-fetch": "^1.0.5", "@badrap/valita": "^0.4.6", "nanoid": "^5.1.6" } }, "sha512-xCp/VfjtvTeKscKR/oI2hdMTp1/DaF/7ll8b6yZOCgbKlVDDfhCn5mmKNVARGTNaoywxrXG3XffbWCIx3/E87w=="], 71 70 72 71 "@atcute/oauth-types": ["@atcute/oauth-types@0.1.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.7", "@atcute/oauth-keyset": "^0.1.0", "@badrap/valita": "^0.4.6" } }, "sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg=="], 73 - 74 - "@atcute/password-session": ["@atcute/password-session@0.1.0", "", { "dependencies": { "@atcute/client": "^4.2.1", "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.9" } }, "sha512-r4iUNT7aQ1J6XXGO+pu39037hFQd0GYEhOuw/aykoNI3HHFLX2t5YyrxWTu5uKMGECk3s7zEgc8B8ol9JsMjRA=="], 75 72 76 73 "@atcute/tid": ["@atcute/tid@1.1.2", "", { "dependencies": { "@atcute/time-ms": "^1.2.2" } }, "sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w=="], 77 74
-1
package.json
··· 44 44 "@atcute/jetstream": "^1.1.2", 45 45 "@atcute/lexicons": "^1.3.0", 46 46 "@atcute/oauth-node-client": "^1.1.0", 47 - "@atcute/password-session": "^0.1.0", 48 47 "@atcute/tid": "^1.1.2", 49 48 "@codemirror/lang-markdown": "^6.5.0", 50 49 "@codemirror/state": "^6.0.0",
+1 -5
src/atproto/client.ts
··· 18 18 import { OAUTH_SCOPE } from "../lib/constants.ts"; 19 19 import { getDb } from "../server/db/index.ts"; 20 20 import type { AtprotoEnv } from "./env.ts"; 21 - import { getDevPlcUrl } from "./env.ts"; 22 21 23 22 class SqliteSessionStore implements Store<string, StoredSession> { 24 23 get(did: string): StoredSession | undefined { ··· 91 90 } 92 91 93 92 function buildActorResolver(): LocalActorResolver { 94 - const plcUrl = getDevPlcUrl(); 95 93 return new LocalActorResolver({ 96 94 handleResolver: new CompositeHandleResolver({ 97 95 methods: { ··· 101 99 }), 102 100 didDocumentResolver: new CompositeDidDocumentResolver({ 103 101 methods: { 104 - plc: new PlcDidDocumentResolver( 105 - plcUrl ? { apiUrl: plcUrl } : undefined, 106 - ), 102 + plc: new PlcDidDocumentResolver(), 107 103 web: new WebDidDocumentResolver(), 108 104 }, 109 105 }),
+20 -11
src/atproto/env.ts
··· 28 28 return process.env["HANDLE_RESOLVER_URL"] ?? "https://bsky.social"; 29 29 } 30 30 31 - export function getDevPdsUrl(): string | null { 32 - return process.env["DEV_PDS_URL"] ?? null; 33 - } 34 - 35 - export function getDevPlcUrl(): string | null { 36 - return process.env["DEV_PLC_URL"] ?? null; 37 - } 38 - 39 31 interface DevAccount { 40 32 did: string; 41 33 handle: string; 42 - password: string; 43 34 } 44 35 36 + const DEFAULT_DEV_ACCOUNTS: Record<string, DevAccount> = { 37 + alice: { 38 + did: "did:plc:devalice00000000000000000", 39 + handle: "alice.test", 40 + }, 41 + bob: { 42 + did: "did:plc:devbob0000000000000000000", 43 + handle: "bob.test", 44 + }, 45 + }; 46 + 47 + /** 48 + * Dev-mode accounts. When OAuth is not configured, the app falls back to 49 + * cookie-only impersonation against this list. Returns null when OAuth is 50 + * configured (production); otherwise reads DEV_ACCOUNTS env or hands back 51 + * the baked-in alice/bob defaults. 52 + */ 45 53 export function getDevAccounts(): Record<string, DevAccount> | null { 54 + if (getAtprotoEnv() !== null) return null; 46 55 const raw = process.env["DEV_ACCOUNTS"]; 47 - if (!raw) return null; 56 + if (!raw) return DEFAULT_DEV_ACCOUNTS; 48 57 try { 49 58 return JSON.parse(raw) as Record<string, DevAccount>; 50 59 } catch { 51 - return null; 60 + return DEFAULT_DEV_ACCOUNTS; 52 61 } 53 62 }
+6 -11
src/atproto/routes.ts
··· 6 6 import { getClientIp, rateLimit } from "../lib/rate-limit.ts"; 7 7 import { htmlResponse } from "../lib/response.ts"; 8 8 import { loginPage } from "../views/login.ts"; 9 - import { getDevAccounts, getDevPdsUrl } from "./env.ts"; 9 + import { getDevAccounts } from "./env.ts"; 10 10 import { getClient } from "./session.ts"; 11 11 12 12 function getSafeReturnTo(cookieHeader: string | null): string { ··· 147 147 }); 148 148 }); 149 149 150 - // Dev-login bypass: only available when DEV_PDS_URL is set 151 - const devPdsUrl = getDevPdsUrl(); 152 - if (devPdsUrl) { 150 + // Dev-login bypass: only available when OAuth is not configured. 151 + const devAccounts = getDevAccounts(); 152 + if (devAccounts) { 153 153 app.get("/dev/login/:handle", ({ params, request }) => { 154 - const accounts = getDevAccounts(); 155 - if (!accounts) { 156 - return new Response("DEV_ACCOUNTS not configured", { status: 503 }); 157 - } 158 - 159 - const account = Object.values(accounts).find( 154 + const account = Object.values(devAccounts).find( 160 155 (a) => a.handle === params.handle, 161 156 ); 162 157 if (!account) { ··· 173 168 ["Location", returnToUrl], 174 169 [ 175 170 "Set-Cookie", 176 - `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${LIMITS.sessionMaxAgeSecs}`, 171 + `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${LIMITS.sessionMaxAgeSecs}`, 177 172 ], 178 173 [ 179 174 "Set-Cookie",
+8 -41
src/atproto/session.ts
··· 1 1 import { Client } from "@atcute/client"; 2 2 import type { Did } from "@atcute/lexicons/syntax"; 3 3 import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; 4 - import { PasswordSession } from "@atcute/password-session"; 5 4 import { resolveProfile } from "../lib/profile.ts"; 6 5 import { createOAuthClient } from "./client.ts"; 7 - import { getAtprotoEnv, getDevAccounts, getDevPdsUrl } from "./env.ts"; 6 + import { getAtprotoEnv, getDevAccounts } from "./env.ts"; 8 7 9 8 export interface Session { 10 9 did: string; ··· 33 32 } 34 33 35 34 /** 36 - * Get a dev-mode session from the DID cookie when OAuth is not configured. 37 - * Returns a Session without oauthSession — use getAgent() to get an 38 - * authenticated client that uses an app-password session against the dev PDS. 35 + * Cookie-only dev session. The DID cookie is trusted because OAuth is not 36 + * configured; only valid in dev where DEV_ACCOUNTS lists the impersonatable users. 39 37 */ 40 38 function getDevSession(cookieHeader: string | undefined): Session | null { 41 39 if (!cookieHeader) return null; ··· 54 52 } 55 53 56 54 /** 57 - * Get an RPC client authenticated for the session. 58 - * In production: wraps the OAuth session. 59 - * In dev-full mode (no oauthSession): logs in with the configured app password. 55 + * Get an RPC client authenticated for the session. Returns null in dev mode — 56 + * dev has no PDS, so orchestrators skip PDS writes and go straight to DB. 60 57 */ 61 - export async function getAgent(session: Session): Promise<Client> { 58 + export function getAgent(session: Session): Client | null { 62 59 if (session.oauthSession) { 63 60 return new Client({ handler: session.oauthSession }); 64 61 } 65 - return loginDevClient(session); 66 - } 67 - 68 - async function loginDevClient(session: Session): Promise<Client> { 69 - const devPdsUrl = getDevPdsUrl(); 70 - if (!devPdsUrl) { 71 - throw new Error("DEV_PDS_URL not configured"); 72 - } 73 - 74 - const accounts = getDevAccounts(); 75 - if (!accounts) { 76 - throw new Error("DEV_ACCOUNTS not configured"); 77 - } 78 - 79 - const account = Object.values(accounts).find((a) => a.did === session.did); 80 - if (!account) { 81 - throw new Error(`No dev account for DID: ${session.did}`); 82 - } 83 - 84 - const passwordSession = await PasswordSession.login({ 85 - service: devPdsUrl, 86 - identifier: account.handle, 87 - password: account.password, 88 - }); 89 - return new Client({ handler: passwordSession }); 62 + return null; 90 63 } 91 64 92 65 let oauthClient: OAuthClient | null = null; ··· 106 79 if (client) { 107 80 return getSession(client, request.headers.get("cookie") ?? undefined); 108 81 } 109 - 110 - // Fall back to dev-mode session when OAuth is not configured 111 - if (getDevPdsUrl()) { 112 - return getDevSession(request.headers.get("cookie") ?? undefined); 113 - } 114 - 115 - return null; 82 + return getDevSession(request.headers.get("cookie") ?? undefined); 116 83 }
+4 -5
src/firehose/index.ts
··· 1 1 import { JetstreamSubscription } from "@atcute/jetstream"; 2 - import { getDevPdsUrl, getJetstreamUrl } from "../atproto/env.ts"; 2 + import { getJetstreamUrl, isAuthEnabled } from "../atproto/env.ts"; 3 3 import { COLLECTIONS } from "../lib/constants.ts"; 4 4 import { getCursor, setCursor } from "../server/db/queries/index.ts"; 5 5 import { type FirehoseCommit, handleCommitEvent } from "./handlers.ts"; 6 6 7 7 const jetstreamUrl = getJetstreamUrl(); 8 - // In dev mode the PDS is ephemeral -- each run starts fresh. 9 - // Persisting the cursor across sessions causes the subscriber to ask jetstream 10 - // for events older than its retention window. 11 - const isDevMode = !!getDevPdsUrl(); 8 + // Dev mode has no real users on the network; persisting a cursor would only ask 9 + // jetstream for events older than its retention window on the next run. 10 + const isDevMode = !isAuthEnabled(); 12 11 const savedCursor = isDevMode ? null : getCursor(); 13 12 14 13 console.log(`Jetstream connecting to ${jetstreamUrl}`);
+1 -5
src/lib/identity.ts
··· 9 9 } from "@atcute/identity-resolver"; 10 10 import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; 11 11 import type { Did } from "@atcute/lexicons/syntax"; 12 - import { getDevPlcUrl } from "../atproto/env.ts"; 13 12 14 13 let handleInstance: HandleResolver | null = null; 15 14 let didDocInstance: DidDocumentResolver | null = null; ··· 28 27 29 28 export function getDidDocumentResolver(): DidDocumentResolver { 30 29 if (!didDocInstance) { 31 - const plcUrl = getDevPlcUrl(); 32 30 didDocInstance = new CompositeDidDocumentResolver({ 33 31 methods: { 34 - plc: new PlcDidDocumentResolver( 35 - plcUrl ? { apiUrl: plcUrl } : undefined, 36 - ), 32 + plc: new PlcDidDocumentResolver(), 37 33 web: new WebDidDocumentResolver(), 38 34 }, 39 35 });
+1 -1
src/lib/import-export/import.ts
··· 103 103 } 104 104 105 105 async function uploadImages( 106 - agent: Awaited<ReturnType<typeof getAgent>>, 106 + agent: NonNullable<ReturnType<typeof getAgent>>, 107 107 did: string, 108 108 images: ImportedImage[], 109 109 imageMap: Map<string, string>,
+4 -4
src/lib/orchestrators/bookmark.ts
··· 19 19 const tid = TID.now(); 20 20 const atUri = `at://${did}/${COLLECTIONS.bookmark}/${tid}`; 21 21 22 - if (session) { 22 + const agent = session ? getAgent(session) : null; 23 + if (agent && session) { 23 24 await withPdsError("add bookmark", async () => { 24 - const agent = await getAgent(session); 25 25 await writeBookmarkRecord(agent, session.did, tid, wikiAtUri, now); 26 26 }); 27 27 } ··· 37 37 const bookmarkAtUri = getBookmarkAtUri(did, wikiAtUri); 38 38 if (!bookmarkAtUri) return; 39 39 40 - if (session) { 40 + const agent = session ? getAgent(session) : null; 41 + if (agent) { 41 42 const parsed = parseAtUri(bookmarkAtUri); 42 43 if (parsed) { 43 44 await withPdsError("remove bookmark", async () => { 44 - const agent = await getAgent(session); 45 45 await deleteRecord( 46 46 agent, 47 47 parsed.did,
+24 -23
src/lib/orchestrators/membership.ts
··· 37 37 const tid = TID.now(); 38 38 const atUri = `at://${session.did}/wiki.lichen.memberRequest/${tid}`; 39 39 40 - await withPdsError("request access", async () => { 41 - const agent = await getAgent(session); 42 - await writeMemberRequestRecord( 43 - agent, 44 - session.did, 45 - tid, 46 - ctx.wiki.at_uri, 47 - now, 48 - ); 49 - }); 40 + const agent = getAgent(session); 41 + if (agent) { 42 + await withPdsError("request access", async () => { 43 + await writeMemberRequestRecord( 44 + agent, 45 + session.did, 46 + tid, 47 + ctx.wiki.at_uri, 48 + now, 49 + ); 50 + }); 51 + } 50 52 51 53 upsertRequest(ctx.wiki.slug, session.did, atUri, now); 52 54 } ··· 67 69 const membershipTid = TID.now(); 68 70 const membershipAtUri = `at://${did}/wiki.lichen.membership/${membershipTid}`; 69 71 70 - if (ctx.session) { 71 - const session = ctx.session; 72 + const session = ctx.session; 73 + const agent = session ? getAgent(session) : null; 74 + if (agent && session) { 72 75 await withPdsError("approve membership", async () => { 73 - const agent = await getAgent(session); 74 76 await writeMembershipRecord( 75 77 agent, 76 78 session.did, ··· 114 116 const newTid = TID.now(); 115 117 const newAtUri = `at://${did}/wiki.lichen.membership/${newTid}`; 116 118 117 - if (ctx.session) { 118 - const session = ctx.session; 119 - const agent = await getAgent(session); 120 - 119 + const session = ctx.session; 120 + const agent = session ? getAgent(session) : null; 121 + if (agent && session) { 121 122 // Best-effort: delete old PDS record if we own it 122 123 const parsed = parseAtUri(existing.at_uri); 123 124 if (parsed && parsed.did === session.did) { ··· 172 173 const tid = TID.now(); 173 174 const atUri = `at://${did}/wiki.lichen.membership/${tid}`; 174 175 175 - if (ctx.session) { 176 - const session = ctx.session; 176 + const session = ctx.session; 177 + const agent = session ? getAgent(session) : null; 178 + if (agent && session) { 177 179 await withPdsError("add member", async () => { 178 - const agent = await getAgent(session); 179 180 await writeMembershipRecord( 180 181 agent, 181 182 session.did, ··· 211 212 throw new NotFoundError("Membership not found"); 212 213 } 213 214 214 - if (ctx.session) { 215 - const session = ctx.session; 215 + const session = ctx.session; 216 + const agent = session ? getAgent(session) : null; 217 + if (agent) { 216 218 const parsed = parseAtUri(atUri); 217 219 if (parsed) { 218 220 await withPdsError("remove member", async () => { 219 - const agent = await getAgent(session); 220 221 await deleteRecord( 221 222 agent, 222 223 parsed.did,
+7 -7
src/lib/orchestrators/note.ts
··· 72 72 * Write a revision record to the PDS. Shared between create and edit. 73 73 */ 74 74 async function writePdsRevision( 75 - agent: Awaited<ReturnType<typeof getAgent>>, 75 + agent: NonNullable<ReturnType<typeof getAgent>>, 76 76 did: string, 77 77 revisionTid: string, 78 78 noteAtUri: string, ··· 126 126 const noteAtUri = `at://${did}/wiki.lichen.note/${noteTid}`; 127 127 const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; 128 128 129 - if (ctx.session) { 130 - const agent = await getAgent(ctx.session); 129 + const agent = ctx.session ? getAgent(ctx.session) : null; 130 + if (agent) { 131 131 const now = new Date().toISOString(); 132 132 await withPdsError("create note", async () => { 133 133 await writeNoteRecord( ··· 206 206 const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; 207 207 const currentContent = currentNote?.content ?? ""; 208 208 209 - if (ctx.session) { 210 - const agent = await getAgent(ctx.session); 209 + const agent = ctx.session ? getAgent(ctx.session) : null; 210 + if (agent) { 211 211 await withPdsError("edit note", async () => { 212 212 await writePdsRevision( 213 213 agent, ··· 264 264 throw new NotFoundError("Note not found"); 265 265 } 266 266 267 - if (ctx.session) { 268 - const agent = await getAgent(ctx.session); 267 + const agent = ctx.session ? getAgent(ctx.session) : null; 268 + if (agent) { 269 269 const parsed = parseAtUri(note.at_uri); 270 270 if (parsed) { 271 271 try {
+6 -7
src/lib/orchestrators/wiki.ts
··· 35 35 interface WikiCoreResult { 36 36 wikiSlug: string; 37 37 wikiAtUri: string; 38 - agent: Awaited<ReturnType<typeof getAgent>> | null; 38 + agent: ReturnType<typeof getAgent>; 39 39 did: string; 40 40 now: string; 41 41 } ··· 83 83 if (!did) throw new ForbiddenError(); 84 84 85 85 let atUri = `at://${did}/wiki.lichen.wiki/${slug}`; 86 - const agent = ctx.session ? await getAgent(ctx.session) : null; 86 + const agent = ctx.session ? getAgent(ctx.session) : null; 87 87 88 88 const description = fields.description 89 89 .trim() ··· 224 224 .trim() 225 225 .slice(0, LIMITS.wiki.description); 226 226 227 - if (ctx.session) { 228 - const agent = await getAgent(ctx.session); 227 + const agent = ctx.session ? getAgent(ctx.session) : null; 228 + if (agent) { 229 229 await withPdsError("edit wiki", async () => { 230 230 await writeWikiRecord( 231 231 agent, ··· 266 266 throw new ForbiddenError(msg.settings.deleteWikiOwnerOnly); 267 267 } 268 268 269 - if (ctx.session) { 270 - const agent = await getAgent(ctx.session); 271 - 269 + const agent = ctx.session ? getAgent(ctx.session) : null; 270 + if (agent) { 272 271 const wikiParsed = parseAtUri(ctx.wiki.at_uri); 273 272 if (wikiParsed) { 274 273 try {
+3 -1
src/server/db/index.ts
··· 1 1 import { Database } from "bun:sqlite"; 2 + import { isAuthEnabled } from "../../atproto/env.ts"; 2 3 import { initSchema } from "./schema.ts"; 3 4 import { seedIfEmpty } from "./seed.ts"; 4 5 ··· 10 11 if (!db) { 11 12 db = new Database(DB_PATH); 12 13 initSchema(db); 13 - if (process.env["SEED_DB"]) seedIfEmpty(db); 14 + // Auto-seed dev databases. Tests use :memory: and manage their own state. 15 + if (!isAuthEnabled() && DB_PATH !== ":memory:") seedIfEmpty(db); 14 16 } 15 17 return db; 16 18 }
+92 -103
src/server/db/seed.ts
··· 2 2 import * as TID from "@atcute/tid"; 3 3 import { getDevAccounts } from "../../atproto/env.ts"; 4 4 5 + interface SeedNote { 6 + slug: string; 7 + title: string; 8 + content: string; 9 + } 10 + 11 + function seedWiki( 12 + db: Database, 13 + owner: { did: string; handle: string }, 14 + slug: string, 15 + name: string, 16 + description: string, 17 + notes: SeedNote[], 18 + ): void { 19 + const wikiAtUri = `at://${owner.did}/wiki.lichen.wiki/${slug}`; 20 + db.run( 21 + "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", 22 + [slug, owner.did, name, "public", description, wikiAtUri], 23 + ); 24 + db.run( 25 + "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", 26 + [ 27 + slug, 28 + owner.did, 29 + "admin", 30 + `at://${owner.did}/wiki.lichen.membership/${TID.now()}`, 31 + ], 32 + ); 33 + 34 + for (const note of notes) { 35 + const noteAtUri = `at://${owner.did}/wiki.lichen.note/${TID.now()}`; 36 + db.run( 37 + "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 38 + [note.slug, slug, note.title, owner.did, noteAtUri], 39 + ); 40 + db.run( 41 + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 42 + VALUES (?, ?, ?, datetime('now'))`, 43 + [noteAtUri, note.content, `${noteAtUri}/rev/1`], 44 + ); 45 + } 46 + } 47 + 5 48 export function seedIfEmpty(db: Database): void { 6 49 const count = db.query("SELECT COUNT(*) as n FROM wikis").get() as { 7 50 n: number; 8 51 }; 9 52 if (count.n > 0) return; 10 53 11 - console.log("Seeding database with sample data..."); 12 - 13 - const homeTid = TID.now(); 14 - const helloTid = TID.now(); 15 - const gettingStartedTid = TID.now(); 16 54 const accounts = getDevAccounts(); 17 - const mockDid = accounts 18 - ? (Object.values(accounts)[0]?.did ?? "did:plc:seed") 19 - : "did:plc:seed"; 55 + if (!accounts) return; 56 + const alice = accounts["alice"]; 57 + const bob = accounts["bob"]; 58 + if (!alice || !bob) return; 20 59 21 - const noteAtUris = { 22 - home: `at://${mockDid}/wiki.lichen.note/${homeTid}`, 23 - hello: `at://${mockDid}/wiki.lichen.note/${helloTid}`, 24 - gettingStarted: `at://${mockDid}/wiki.lichen.note/${gettingStartedTid}`, 25 - }; 60 + console.log("Seeding database with sample data..."); 26 61 27 - const noteContents: Record<string, string> = { 28 - [noteAtUris.home]: `# Welcome to the Test Wiki 29 - 30 - This is the **home page**. It appears when you visit the wiki root. 31 - 32 - ## Quick Links 62 + db.run("BEGIN TRANSACTION"); 63 + try { 64 + seedWiki( 65 + db, 66 + alice, 67 + "alices-garden", 68 + "Alice's Garden", 69 + "A sample public wiki owned by Alice for exploring Lichen.", 70 + [ 71 + { 72 + slug: "home", 73 + title: "Home", 74 + content: `# Welcome to Alice's Garden 33 75 34 - - [[hello|Hello World]] — a sample note 35 - - [[getting-started|Getting Started]] — how to use Lichen 36 - 37 - ## About 76 + This is the **home page**. Try editing it, then check the history. 38 77 39 - This wiki is powered by ATProto. Every edit creates a diff stored permanently on the protocol. 78 + - [[hello|Hello]] — a sample note 79 + - [[bobs-notebook/home|Bob's Notebook]] — Alice contributes there too 40 80 `, 41 - [noteAtUris.hello]: `# Hello World 42 - 43 - Welcome to the **Test Wiki**. This is a sample note rendered with markdown-it. 81 + }, 82 + { 83 + slug: "hello", 84 + title: "Hello", 85 + content: `# Hello 44 86 45 - ## Features 46 - 47 - - Supports **bold**, *italic*, and ~~strikethrough~~ 48 - - [[getting-started|Check out the Getting Started guide]] 49 - - Code blocks work too: 50 - 51 - \`\`\`js 52 - console.log("hello from Lichen"); 53 - \`\`\` 87 + A short note. Edit me to see how diffs work. 54 88 `, 55 - [noteAtUris.gettingStarted]: `# Getting Started 89 + }, 90 + ], 91 + ); 56 92 57 - This wiki runs on [[hello|ATProto]]. Every edit is a diff stored permanently. 93 + seedWiki( 94 + db, 95 + bob, 96 + "bobs-notebook", 97 + "Bob's Notebook", 98 + "A sample public wiki owned by Bob. Alice has contributor access.", 99 + [ 100 + { 101 + slug: "home", 102 + title: "Home", 103 + content: `# Bob's Notebook 58 104 59 - 1. Sign in with your Bluesky account 60 - 2. Create or join a wiki 61 - 3. Start writing 105 + Bob owns this wiki. Alice can edit notes here as a contributor. 62 106 `, 63 - }; 64 - 65 - db.run("BEGIN TRANSACTION"); 66 - try { 67 - db.run( 68 - "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", 69 - [ 70 - "test", 71 - mockDid, 72 - "Test Wiki", 73 - "public", 74 - "A sample wiki for exploring Lichen features.", 75 - `at://${mockDid}/wiki.lichen.wiki/test`, 107 + }, 76 108 ], 77 109 ); 78 110 79 - db.run( 80 - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 81 - ["home", "test", "Home", mockDid, noteAtUris.home], 82 - ); 111 + // Alice is a contributor on Bob's wiki — log in as alice and edit it. 83 112 db.run( 84 - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 85 - ["hello", "test", "Hello World", mockDid, noteAtUris.hello], 86 - ); 87 - db.run( 88 - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 113 + "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", 89 114 [ 90 - "getting-started", 91 - "test", 92 - "Getting Started", 93 - mockDid, 94 - noteAtUris.gettingStarted, 115 + "bobs-notebook", 116 + alice.did, 117 + "contributor", 118 + `at://${bob.did}/wiki.lichen.membership/${TID.now()}`, 95 119 ], 96 120 ); 97 - for (const [atUri, content] of Object.entries(noteContents)) { 98 - db.run( 99 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 100 - VALUES (?, ?, ?, datetime('now'))`, 101 - [atUri, content, `${atUri}/rev/1`], 102 - ); 103 - } 104 - 105 - // Bulk filler notes so the sidebar overflows and the main pane scrolls. 106 - // Useful for visually testing the app-shell layout locally. 107 - const longBody = Array.from( 108 - { length: 60 }, 109 - (_, i) => 110 - `## 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`, 111 - ).join("\n"); 112 - 113 - for (let i = 1; i <= 40; i++) { 114 - const slug = `filler-note-${String(i).padStart(2, "0")}`; 115 - const tid = TID.now(); 116 - const atUri = `at://${mockDid}/wiki.lichen.note/${tid}`; 117 - db.run( 118 - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", 119 - [slug, "test", `Filler note ${i}`, mockDid, atUri], 120 - ); 121 - // Make a few notes very long so the main pane scrolls. 122 - const content = 123 - i % 5 === 0 124 - ? `# Filler note ${i} (long)\n\n${longBody}` 125 - : `# Filler note ${i}\n\nShort placeholder content for sidebar overflow testing.\n`; 126 - db.run( 127 - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) 128 - VALUES (?, ?, ?, datetime('now'))`, 129 - [atUri, content, `${atUri}/rev/1`], 130 - ); 131 - } 132 121 133 122 db.run("COMMIT"); 134 123 } catch (err) {
+11 -7
src/server/index.ts
··· 1 1 import { staticPlugin } from "@elysiajs/static"; 2 2 import { Elysia } from "elysia"; 3 - import { getAtprotoEnv, getDevPdsUrl, isAuthEnabled } from "../atproto/env.ts"; 3 + import { getAtprotoEnv, isAuthEnabled } from "../atproto/env.ts"; 4 4 import { atprotoRoutes } from "../atproto/routes.ts"; 5 5 import { AppError } from "../lib/errors.ts"; 6 6 import { getDb } from "./db/index.ts"; ··· 15 15 import { searchRoutes } from "./routes/search.ts"; 16 16 import { wikiRoutes } from "./routes/wiki.ts"; 17 17 18 - // Dev and prod OAuth configurations must not coexist: the dev-session fallback 19 - // trusts a `did=` cookie without a password check, so a prod instance with 20 - // DEV_PDS_URL set would allow impersonation. 21 - if (getAtprotoEnv() !== null && getDevPdsUrl() !== null) { 18 + // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` 19 + // cookie without a password check. A prod instance that also sets DEV_ACCOUNTS 20 + // would still ignore it (getDevAccounts returns null when OAuth env is set), 21 + // but bail loudly to flag misconfiguration rather than silently dropping it. 22 + if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) { 22 23 throw new Error( 23 - "Dev and prod OAuth configurations are mutually exclusive — unset DEV_PDS_URL or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", 24 + "Dev and prod auth are mutually exclusive — unset DEV_ACCOUNTS or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", 24 25 ); 25 26 } 26 27 ··· 34 35 } 35 36 36 37 const app = new Elysia() 37 - .onError(({ error }) => { 38 + .onError(({ error, code }) => { 38 39 if (error instanceof AppError) { 39 40 return new Response(error.message, { status: error.statusCode }); 41 + } 42 + if (code === "NOT_FOUND") { 43 + return new Response("Not found", { status: 404 }); 40 44 } 41 45 console.error("Unhandled error:", error); 42 46 return new Response("Internal server error", { status: 500 });
+2 -2
src/server/routes/blob.ts
··· 65 65 ); 66 66 } 67 67 68 - if (session) { 68 + const rpc = session ? getAgent(session) : null; 69 + if (rpc && session) { 69 70 // ATProto mode: upload to PDS 70 - const rpc = await getAgent(session); 71 71 const result = await ok( 72 72 rpc.post("com.atproto.repo.uploadBlob", { 73 73 input: processed.data,
+21 -38
tests/atproto/env.test.ts
··· 2 2 import { 3 3 getAtprotoEnv, 4 4 getDevAccounts, 5 - getDevPdsUrl, 6 - getDevPlcUrl, 7 5 getHandleResolverUrl, 8 6 getJetstreamUrl, 9 7 isAuthEnabled, ··· 16 14 "OAUTH_PRIVATE_KEY_PATH", 17 15 "JETSTREAM_URL", 18 16 "HANDLE_RESOLVER_URL", 19 - "DEV_PDS_URL", 20 - "DEV_PLC_URL", 21 17 "DEV_ACCOUNTS", 22 18 ]; 23 19 ··· 99 95 }); 100 96 }); 101 97 102 - describe("getDevPdsUrl", () => { 103 - test("returns null when not set", () => { 104 - delete process.env["DEV_PDS_URL"]; 105 - expect(getDevPdsUrl()).toBeNull(); 106 - }); 107 - 108 - test("reads from env", () => { 109 - process.env["DEV_PDS_URL"] = "http://localhost:2583"; 110 - expect(getDevPdsUrl()).toBe("http://localhost:2583"); 111 - }); 112 - }); 113 - 114 - describe("getDevPlcUrl", () => { 115 - test("returns null when not set", () => { 116 - delete process.env["DEV_PLC_URL"]; 117 - expect(getDevPlcUrl()).toBeNull(); 98 + describe("getDevAccounts", () => { 99 + test("returns null when OAuth is configured", () => { 100 + process.env["PUBLIC_URL"] = "https://lichen.wiki"; 101 + process.env["OAUTH_PRIVATE_KEY_PATH"] = "/path/to/key.pem"; 102 + expect(getDevAccounts()).toBeNull(); 118 103 }); 119 104 120 - test("reads from env", () => { 121 - process.env["DEV_PLC_URL"] = "http://localhost:2582"; 122 - expect(getDevPlcUrl()).toBe("http://localhost:2582"); 123 - }); 124 - }); 125 - 126 - describe("getDevAccounts", () => { 127 - test("returns null when not set", () => { 105 + test("returns baked-in alice + bob defaults when OAuth not set", () => { 106 + delete process.env["PUBLIC_URL"]; 107 + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; 128 108 delete process.env["DEV_ACCOUNTS"]; 129 - expect(getDevAccounts()).toBeNull(); 109 + const accounts = getDevAccounts(); 110 + expect(accounts?.["alice"]?.handle).toBe("alice.test"); 111 + expect(accounts?.["bob"]?.handle).toBe("bob.test"); 130 112 }); 131 113 132 - test("parses valid JSON", () => { 114 + test("parses valid DEV_ACCOUNTS JSON", () => { 115 + delete process.env["PUBLIC_URL"]; 116 + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; 133 117 process.env["DEV_ACCOUNTS"] = JSON.stringify({ 134 - alice: { 135 - did: "did:plc:alice", 136 - handle: "alice.test", 137 - password: "pw", 138 - }, 118 + carol: { did: "did:plc:carol", handle: "carol.test" }, 139 119 }); 140 120 const accounts = getDevAccounts(); 141 - expect(accounts?.["alice"]?.did).toBe("did:plc:alice"); 121 + expect(accounts?.["carol"]?.did).toBe("did:plc:carol"); 142 122 }); 143 123 144 - test("returns null for malformed JSON", () => { 124 + test("falls back to defaults on malformed JSON", () => { 125 + delete process.env["PUBLIC_URL"]; 126 + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; 145 127 process.env["DEV_ACCOUNTS"] = "{not json}"; 146 - expect(getDevAccounts()).toBeNull(); 128 + const accounts = getDevAccounts(); 129 + expect(accounts?.["alice"]?.handle).toBe("alice.test"); 147 130 }); 148 131 });