diff --git a/bun.lock b/bun.lock index 524dc31..620537e 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,6 @@ "@atcute/jetstream": "^1.1.2", "@atcute/lexicons": "^1.3.0", "@atcute/oauth-node-client": "^1.1.0", - "@atcute/password-session": "^0.1.0", "@atcute/tid": "^1.1.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.0.0", @@ -71,8 +70,6 @@ "@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=="], - "@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=="], - "@atcute/tid": ["@atcute/tid@1.1.2", "", { "dependencies": { "@atcute/time-ms": "^1.2.2" } }, "sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w=="], "@atcute/time-ms": ["@atcute/time-ms@1.3.2", "", {}, "sha512-F+qOyR9pO55g1d/QmN+Gr+fimoUQQLusdGSB6pjV0wW5KPILR4oQ4e2ZhWzqUbeHLAgWvgoTTMsMDdz62Xa2tg=="], diff --git a/package.json b/package.json index 12a4d9e..fb9c616 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@atcute/jetstream": "^1.1.2", "@atcute/lexicons": "^1.3.0", "@atcute/oauth-node-client": "^1.1.0", - "@atcute/password-session": "^0.1.0", "@atcute/tid": "^1.1.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.0.0", diff --git a/src/atproto/client.ts b/src/atproto/client.ts index 629e62c..f812fef 100644 --- a/src/atproto/client.ts +++ b/src/atproto/client.ts @@ -18,7 +18,6 @@ import { import { OAUTH_SCOPE } from "../lib/constants.ts"; import { getDb } from "../server/db/index.ts"; import type { AtprotoEnv } from "./env.ts"; -import { getDevPlcUrl } from "./env.ts"; class SqliteSessionStore implements Store { get(did: string): StoredSession | undefined { @@ -91,7 +90,6 @@ class SqliteStateStore implements Store { } function buildActorResolver(): LocalActorResolver { - const plcUrl = getDevPlcUrl(); return new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { @@ -101,9 +99,7 @@ function buildActorResolver(): LocalActorResolver { }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { - plc: new PlcDidDocumentResolver( - plcUrl ? { apiUrl: plcUrl } : undefined, - ), + plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), diff --git a/src/atproto/env.ts b/src/atproto/env.ts index 02ac03e..f4337fb 100644 --- a/src/atproto/env.ts +++ b/src/atproto/env.ts @@ -28,26 +28,35 @@ export function getHandleResolverUrl(): string { return process.env["HANDLE_RESOLVER_URL"] ?? "https://bsky.social"; } -export function getDevPdsUrl(): string | null { - return process.env["DEV_PDS_URL"] ?? null; -} - -export function getDevPlcUrl(): string | null { - return process.env["DEV_PLC_URL"] ?? null; -} - interface DevAccount { did: string; handle: string; - password: string; } +const DEFAULT_DEV_ACCOUNTS: Record = { + alice: { + did: "did:plc:devalice00000000000000000", + handle: "alice.test", + }, + bob: { + did: "did:plc:devbob0000000000000000000", + handle: "bob.test", + }, +}; + +/** + * Dev-mode accounts. When OAuth is not configured, the app falls back to + * cookie-only impersonation against this list. Returns null when OAuth is + * configured (production); otherwise reads DEV_ACCOUNTS env or hands back + * the baked-in alice/bob defaults. + */ export function getDevAccounts(): Record | null { + if (getAtprotoEnv() !== null) return null; const raw = process.env["DEV_ACCOUNTS"]; - if (!raw) return null; + if (!raw) return DEFAULT_DEV_ACCOUNTS; try { return JSON.parse(raw) as Record; } catch { - return null; + return DEFAULT_DEV_ACCOUNTS; } } diff --git a/src/atproto/routes.ts b/src/atproto/routes.ts index 6b06e30..738405b 100644 --- a/src/atproto/routes.ts +++ b/src/atproto/routes.ts @@ -6,7 +6,7 @@ import { LIMITS } from "../lib/limits.ts"; import { getClientIp, rateLimit } from "../lib/rate-limit.ts"; import { htmlResponse } from "../lib/response.ts"; import { loginPage } from "../views/login.ts"; -import { getDevAccounts, getDevPdsUrl } from "./env.ts"; +import { getDevAccounts } from "./env.ts"; import { getClient } from "./session.ts"; function getSafeReturnTo(cookieHeader: string | null): string { @@ -147,16 +147,11 @@ export function atprotoRoutes() { }); }); - // Dev-login bypass: only available when DEV_PDS_URL is set - const devPdsUrl = getDevPdsUrl(); - if (devPdsUrl) { + // Dev-login bypass: only available when OAuth is not configured. + const devAccounts = getDevAccounts(); + if (devAccounts) { app.get("/dev/login/:handle", ({ params, request }) => { - const accounts = getDevAccounts(); - if (!accounts) { - return new Response("DEV_ACCOUNTS not configured", { status: 503 }); - } - - const account = Object.values(accounts).find( + const account = Object.values(devAccounts).find( (a) => a.handle === params.handle, ); if (!account) { @@ -173,7 +168,7 @@ export function atprotoRoutes() { ["Location", returnToUrl], [ "Set-Cookie", - `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${LIMITS.sessionMaxAgeSecs}`, + `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${LIMITS.sessionMaxAgeSecs}`, ], [ "Set-Cookie", diff --git a/src/atproto/session.ts b/src/atproto/session.ts index 5c2315b..9ac7699 100644 --- a/src/atproto/session.ts +++ b/src/atproto/session.ts @@ -1,10 +1,9 @@ import { Client } from "@atcute/client"; import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; -import { PasswordSession } from "@atcute/password-session"; import { resolveProfile } from "../lib/profile.ts"; import { createOAuthClient } from "./client.ts"; -import { getAtprotoEnv, getDevAccounts, getDevPdsUrl } from "./env.ts"; +import { getAtprotoEnv, getDevAccounts } from "./env.ts"; export interface Session { did: string; @@ -33,9 +32,8 @@ async function getSession( } /** - * Get a dev-mode session from the DID cookie when OAuth is not configured. - * Returns a Session without oauthSession — use getAgent() to get an - * authenticated client that uses an app-password session against the dev PDS. + * Cookie-only dev session. The DID cookie is trusted because OAuth is not + * configured; only valid in dev where DEV_ACCOUNTS lists the impersonatable users. */ function getDevSession(cookieHeader: string | undefined): Session | null { if (!cookieHeader) return null; @@ -54,39 +52,14 @@ function getDevSession(cookieHeader: string | undefined): Session | null { } /** - * Get an RPC client authenticated for the session. - * In production: wraps the OAuth session. - * In dev-full mode (no oauthSession): logs in with the configured app password. + * Get an RPC client authenticated for the session. Returns null in dev mode — + * dev has no PDS, so orchestrators skip PDS writes and go straight to DB. */ -export async function getAgent(session: Session): Promise { +export function getAgent(session: Session): Client | null { if (session.oauthSession) { return new Client({ handler: session.oauthSession }); } - return loginDevClient(session); -} - -async function loginDevClient(session: Session): Promise { - const devPdsUrl = getDevPdsUrl(); - if (!devPdsUrl) { - throw new Error("DEV_PDS_URL not configured"); - } - - const accounts = getDevAccounts(); - if (!accounts) { - throw new Error("DEV_ACCOUNTS not configured"); - } - - const account = Object.values(accounts).find((a) => a.did === session.did); - if (!account) { - throw new Error(`No dev account for DID: ${session.did}`); - } - - const passwordSession = await PasswordSession.login({ - service: devPdsUrl, - identifier: account.handle, - password: account.password, - }); - return new Client({ handler: passwordSession }); + return null; } let oauthClient: OAuthClient | null = null; @@ -106,11 +79,5 @@ export async function getSessionFromRequest( if (client) { return getSession(client, request.headers.get("cookie") ?? undefined); } - - // Fall back to dev-mode session when OAuth is not configured - if (getDevPdsUrl()) { - return getDevSession(request.headers.get("cookie") ?? undefined); - } - - return null; + return getDevSession(request.headers.get("cookie") ?? undefined); } diff --git a/src/firehose/index.ts b/src/firehose/index.ts index 6677feb..ad16357 100644 --- a/src/firehose/index.ts +++ b/src/firehose/index.ts @@ -1,14 +1,13 @@ import { JetstreamSubscription } from "@atcute/jetstream"; -import { getDevPdsUrl, getJetstreamUrl } from "../atproto/env.ts"; +import { getJetstreamUrl, isAuthEnabled } from "../atproto/env.ts"; import { COLLECTIONS } from "../lib/constants.ts"; import { getCursor, setCursor } from "../server/db/queries/index.ts"; import { type FirehoseCommit, handleCommitEvent } from "./handlers.ts"; const jetstreamUrl = getJetstreamUrl(); -// In dev mode the PDS is ephemeral -- each run starts fresh. -// Persisting the cursor across sessions causes the subscriber to ask jetstream -// for events older than its retention window. -const isDevMode = !!getDevPdsUrl(); +// Dev mode has no real users on the network; persisting a cursor would only ask +// jetstream for events older than its retention window on the next run. +const isDevMode = !isAuthEnabled(); const savedCursor = isDevMode ? null : getCursor(); console.log(`Jetstream connecting to ${jetstreamUrl}`); diff --git a/src/lib/identity.ts b/src/lib/identity.ts index df3595a..59b81e8 100644 --- a/src/lib/identity.ts +++ b/src/lib/identity.ts @@ -9,7 +9,6 @@ import { } from "@atcute/identity-resolver"; import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; import type { Did } from "@atcute/lexicons/syntax"; -import { getDevPlcUrl } from "../atproto/env.ts"; let handleInstance: HandleResolver | null = null; let didDocInstance: DidDocumentResolver | null = null; @@ -28,12 +27,9 @@ export function getHandleResolver(): HandleResolver { export function getDidDocumentResolver(): DidDocumentResolver { if (!didDocInstance) { - const plcUrl = getDevPlcUrl(); didDocInstance = new CompositeDidDocumentResolver({ methods: { - plc: new PlcDidDocumentResolver( - plcUrl ? { apiUrl: plcUrl } : undefined, - ), + plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }); diff --git a/src/lib/import-export/import.ts b/src/lib/import-export/import.ts index a9e8661..d8c5c18 100644 --- a/src/lib/import-export/import.ts +++ b/src/lib/import-export/import.ts @@ -103,7 +103,7 @@ export async function importWikiAction( } async function uploadImages( - agent: Awaited>, + agent: NonNullable>, did: string, images: ImportedImage[], imageMap: Map, diff --git a/src/lib/orchestrators/bookmark.ts b/src/lib/orchestrators/bookmark.ts index 5a63a09..a8facb2 100644 --- a/src/lib/orchestrators/bookmark.ts +++ b/src/lib/orchestrators/bookmark.ts @@ -19,9 +19,9 @@ export async function addBookmarkAction( const tid = TID.now(); const atUri = `at://${did}/${COLLECTIONS.bookmark}/${tid}`; - if (session) { + const agent = session ? getAgent(session) : null; + if (agent && session) { await withPdsError("add bookmark", async () => { - const agent = await getAgent(session); await writeBookmarkRecord(agent, session.did, tid, wikiAtUri, now); }); } @@ -37,11 +37,11 @@ export async function deleteBookmarkAction( const bookmarkAtUri = getBookmarkAtUri(did, wikiAtUri); if (!bookmarkAtUri) return; - if (session) { + const agent = session ? getAgent(session) : null; + if (agent) { const parsed = parseAtUri(bookmarkAtUri); if (parsed) { await withPdsError("remove bookmark", async () => { - const agent = await getAgent(session); await deleteRecord( agent, parsed.did, diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index 29becd4..613679f 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -37,16 +37,18 @@ export async function requestAccessAction( const tid = TID.now(); const atUri = `at://${session.did}/wiki.lichen.memberRequest/${tid}`; - await withPdsError("request access", async () => { - const agent = await getAgent(session); - await writeMemberRequestRecord( - agent, - session.did, - tid, - ctx.wiki.at_uri, - now, - ); - }); + const agent = getAgent(session); + if (agent) { + await withPdsError("request access", async () => { + await writeMemberRequestRecord( + agent, + session.did, + tid, + ctx.wiki.at_uri, + now, + ); + }); + } upsertRequest(ctx.wiki.slug, session.did, atUri, now); } @@ -67,10 +69,10 @@ export async function approveMemberAction( const membershipTid = TID.now(); const membershipAtUri = `at://${did}/wiki.lichen.membership/${membershipTid}`; - if (ctx.session) { - const session = ctx.session; + const session = ctx.session; + const agent = session ? getAgent(session) : null; + if (agent && session) { await withPdsError("approve membership", async () => { - const agent = await getAgent(session); await writeMembershipRecord( agent, session.did, @@ -114,10 +116,9 @@ export async function changeMemberRoleAction( const newTid = TID.now(); const newAtUri = `at://${did}/wiki.lichen.membership/${newTid}`; - if (ctx.session) { - const session = ctx.session; - const agent = await getAgent(session); - + const session = ctx.session; + const agent = session ? getAgent(session) : null; + if (agent && session) { // Best-effort: delete old PDS record if we own it const parsed = parseAtUri(existing.at_uri); if (parsed && parsed.did === session.did) { @@ -172,10 +173,10 @@ export async function addMemberAction( const tid = TID.now(); const atUri = `at://${did}/wiki.lichen.membership/${tid}`; - if (ctx.session) { - const session = ctx.session; + const session = ctx.session; + const agent = session ? getAgent(session) : null; + if (agent && session) { await withPdsError("add member", async () => { - const agent = await getAgent(session); await writeMembershipRecord( agent, session.did, @@ -211,12 +212,12 @@ export async function deleteMemberAction( throw new NotFoundError("Membership not found"); } - if (ctx.session) { - const session = ctx.session; + const session = ctx.session; + const agent = session ? getAgent(session) : null; + if (agent) { const parsed = parseAtUri(atUri); if (parsed) { await withPdsError("remove member", async () => { - const agent = await getAgent(session); await deleteRecord( agent, parsed.did, diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index c4da588..85fcaa9 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -72,7 +72,7 @@ function validateRevisionFields( * Write a revision record to the PDS. Shared between create and edit. */ async function writePdsRevision( - agent: Awaited>, + agent: NonNullable>, did: string, revisionTid: string, noteAtUri: string, @@ -126,8 +126,8 @@ export async function createNoteAction( const noteAtUri = `at://${did}/wiki.lichen.note/${noteTid}`; const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; - if (ctx.session) { - const agent = await getAgent(ctx.session); + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { const now = new Date().toISOString(); await withPdsError("create note", async () => { await writeNoteRecord( @@ -206,8 +206,8 @@ export async function editNoteAction( const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; const currentContent = currentNote?.content ?? ""; - if (ctx.session) { - const agent = await getAgent(ctx.session); + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { await withPdsError("edit note", async () => { await writePdsRevision( agent, @@ -264,8 +264,8 @@ export async function deleteNoteAction( throw new NotFoundError("Note not found"); } - if (ctx.session) { - const agent = await getAgent(ctx.session); + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { const parsed = parseAtUri(note.at_uri); if (parsed) { try { diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index a38cd10..285ece4 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -35,7 +35,7 @@ export interface WikiFormFields { interface WikiCoreResult { wikiSlug: string; wikiAtUri: string; - agent: Awaited> | null; + agent: ReturnType; did: string; now: string; } @@ -83,7 +83,7 @@ export async function createWikiCore( if (!did) throw new ForbiddenError(); let atUri = `at://${did}/wiki.lichen.wiki/${slug}`; - const agent = ctx.session ? await getAgent(ctx.session) : null; + const agent = ctx.session ? getAgent(ctx.session) : null; const description = fields.description .trim() @@ -224,8 +224,8 @@ export async function editWikiAction( .trim() .slice(0, LIMITS.wiki.description); - if (ctx.session) { - const agent = await getAgent(ctx.session); + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { await withPdsError("edit wiki", async () => { await writeWikiRecord( agent, @@ -266,9 +266,8 @@ export async function deleteWikiAction(ctx: WikiRequestContext): Promise { throw new ForbiddenError(msg.settings.deleteWikiOwnerOnly); } - if (ctx.session) { - const agent = await getAgent(ctx.session); - + const agent = ctx.session ? getAgent(ctx.session) : null; + if (agent) { const wikiParsed = parseAtUri(ctx.wiki.at_uri); if (wikiParsed) { try { diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 59d582d..f19a7da 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import { isAuthEnabled } from "../../atproto/env.ts"; import { initSchema } from "./schema.ts"; import { seedIfEmpty } from "./seed.ts"; @@ -10,7 +11,8 @@ export function getDb(): Database { if (!db) { db = new Database(DB_PATH); initSchema(db); - if (process.env["SEED_DB"]) seedIfEmpty(db); + // Auto-seed dev databases. Tests use :memory: and manage their own state. + if (!isAuthEnabled() && DB_PATH !== ":memory:") seedIfEmpty(db); } return db; } diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index 483267e..c730466 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -2,133 +2,122 @@ import type { Database } from "bun:sqlite"; import * as TID from "@atcute/tid"; import { getDevAccounts } from "../../atproto/env.ts"; +interface SeedNote { + slug: string; + title: string; + content: string; +} + +function seedWiki( + db: Database, + owner: { did: string; handle: string }, + slug: string, + name: string, + description: string, + notes: SeedNote[], +): void { + const wikiAtUri = `at://${owner.did}/wiki.lichen.wiki/${slug}`; + db.run( + "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", + [slug, owner.did, name, "public", description, wikiAtUri], + ); + db.run( + "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", + [ + slug, + owner.did, + "admin", + `at://${owner.did}/wiki.lichen.membership/${TID.now()}`, + ], + ); + + for (const note of notes) { + const noteAtUri = `at://${owner.did}/wiki.lichen.note/${TID.now()}`; + db.run( + "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", + [note.slug, slug, note.title, owner.did, noteAtUri], + ); + db.run( + `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) + VALUES (?, ?, ?, datetime('now'))`, + [noteAtUri, note.content, `${noteAtUri}/rev/1`], + ); + } +} + export function seedIfEmpty(db: Database): void { const count = db.query("SELECT COUNT(*) as n FROM wikis").get() as { n: number; }; if (count.n > 0) return; - console.log("Seeding database with sample data..."); - - const homeTid = TID.now(); - const helloTid = TID.now(); - const gettingStartedTid = TID.now(); const accounts = getDevAccounts(); - const mockDid = accounts - ? (Object.values(accounts)[0]?.did ?? "did:plc:seed") - : "did:plc:seed"; - - const noteAtUris = { - home: `at://${mockDid}/wiki.lichen.note/${homeTid}`, - hello: `at://${mockDid}/wiki.lichen.note/${helloTid}`, - gettingStarted: `at://${mockDid}/wiki.lichen.note/${gettingStartedTid}`, - }; - - const noteContents: Record = { - [noteAtUris.home]: `# Welcome to the Test Wiki - -This is the **home page**. It appears when you visit the wiki root. + if (!accounts) return; + const alice = accounts["alice"]; + const bob = accounts["bob"]; + if (!alice || !bob) return; -## Quick Links + console.log("Seeding database with sample data..."); -- [[hello|Hello World]] — a sample note -- [[getting-started|Getting Started]] — how to use Lichen + db.run("BEGIN TRANSACTION"); + try { + seedWiki( + db, + alice, + "alices-garden", + "Alice's Garden", + "A sample public wiki owned by Alice for exploring Lichen.", + [ + { + slug: "home", + title: "Home", + content: `# Welcome to Alice's Garden -## About +This is the **home page**. Try editing it, then check the history. -This wiki is powered by ATProto. Every edit creates a diff stored permanently on the protocol. +- [[hello|Hello]] — a sample note +- [[bobs-notebook/home|Bob's Notebook]] — Alice contributes there too `, - [noteAtUris.hello]: `# Hello World - -Welcome to the **Test Wiki**. This is a sample note rendered with markdown-it. + }, + { + slug: "hello", + title: "Hello", + content: `# Hello -## Features - -- Supports **bold**, *italic*, and ~~strikethrough~~ -- [[getting-started|Check out the Getting Started guide]] -- Code blocks work too: - -\`\`\`js -console.log("hello from Lichen"); -\`\`\` +A short note. Edit me to see how diffs work. `, - [noteAtUris.gettingStarted]: `# Getting Started + }, + ], + ); -This wiki runs on [[hello|ATProto]]. Every edit is a diff stored permanently. + seedWiki( + db, + bob, + "bobs-notebook", + "Bob's Notebook", + "A sample public wiki owned by Bob. Alice has contributor access.", + [ + { + slug: "home", + title: "Home", + content: `# Bob's Notebook -1. Sign in with your Bluesky account -2. Create or join a wiki -3. Start writing +Bob owns this wiki. Alice can edit notes here as a contributor. `, - }; - - db.run("BEGIN TRANSACTION"); - try { - db.run( - "INSERT INTO wikis (slug, did, name, visibility, description, at_uri) VALUES (?, ?, ?, ?, ?, ?)", - [ - "test", - mockDid, - "Test Wiki", - "public", - "A sample wiki for exploring Lichen features.", - `at://${mockDid}/wiki.lichen.wiki/test`, + }, ], ); + // Alice is a contributor on Bob's wiki — log in as alice and edit it. db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - ["home", "test", "Home", mockDid, noteAtUris.home], - ); - db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - ["hello", "test", "Hello World", mockDid, noteAtUris.hello], - ); - db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO memberships (wiki_slug, did, role, at_uri) VALUES (?, ?, ?, ?)", [ - "getting-started", - "test", - "Getting Started", - mockDid, - noteAtUris.gettingStarted, + "bobs-notebook", + alice.did, + "contributor", + `at://${bob.did}/wiki.lichen.membership/${TID.now()}`, ], ); - for (const [atUri, content] of Object.entries(noteContents)) { - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now'))`, - [atUri, content, `${atUri}/rev/1`], - ); - } - - // Bulk filler notes so the sidebar overflows and the main pane scrolls. - // Useful for visually testing the app-shell layout locally. - const longBody = Array.from( - { length: 60 }, - (_, i) => - `## 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`, - ).join("\n"); - - for (let i = 1; i <= 40; i++) { - const slug = `filler-note-${String(i).padStart(2, "0")}`; - const tid = TID.now(); - const atUri = `at://${mockDid}/wiki.lichen.note/${tid}`; - db.run( - "INSERT INTO notes (slug, wiki_slug, title, did, at_uri) VALUES (?, ?, ?, ?, ?)", - [slug, "test", `Filler note ${i}`, mockDid, atUri], - ); - // Make a few notes very long so the main pane scrolls. - const content = - i % 5 === 0 - ? `# Filler note ${i} (long)\n\n${longBody}` - : `# Filler note ${i}\n\nShort placeholder content for sidebar overflow testing.\n`; - db.run( - `INSERT INTO current_note (note_at_uri, content, latest_revision_uri, updated_at) - VALUES (?, ?, ?, datetime('now'))`, - [atUri, content, `${atUri}/rev/1`], - ); - } db.run("COMMIT"); } catch (err) { diff --git a/src/server/index.ts b/src/server/index.ts index b0431f4..8220140 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,6 @@ import { staticPlugin } from "@elysiajs/static"; import { Elysia } from "elysia"; -import { getAtprotoEnv, getDevPdsUrl, isAuthEnabled } from "../atproto/env.ts"; +import { getAtprotoEnv, isAuthEnabled } from "../atproto/env.ts"; import { atprotoRoutes } from "../atproto/routes.ts"; import { AppError } from "../lib/errors.ts"; import { getDb } from "./db/index.ts"; @@ -15,12 +15,13 @@ import { profileRoutes } from "./routes/profile.ts"; import { searchRoutes } from "./routes/search.ts"; import { wikiRoutes } from "./routes/wiki.ts"; -// Dev and prod OAuth configurations must not coexist: the dev-session fallback -// trusts a `did=` cookie without a password check, so a prod instance with -// DEV_PDS_URL set would allow impersonation. -if (getAtprotoEnv() !== null && getDevPdsUrl() !== null) { +// Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` +// cookie without a password check. A prod instance that also sets DEV_ACCOUNTS +// would still ignore it (getDevAccounts returns null when OAuth env is set), +// but bail loudly to flag misconfiguration rather than silently dropping it. +if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) { throw new Error( - "Dev and prod OAuth configurations are mutually exclusive — unset DEV_PDS_URL or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", + "Dev and prod auth are mutually exclusive — unset DEV_ACCOUNTS or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", ); } @@ -34,10 +35,13 @@ if (!isAuthEnabled()) { } const app = new Elysia() - .onError(({ error }) => { + .onError(({ error, code }) => { if (error instanceof AppError) { return new Response(error.message, { status: error.statusCode }); } + if (code === "NOT_FOUND") { + return new Response("Not found", { status: 404 }); + } console.error("Unhandled error:", error); return new Response("Internal server error", { status: 500 }); }) diff --git a/src/server/routes/blob.ts b/src/server/routes/blob.ts index c860e3a..5748dd0 100644 --- a/src/server/routes/blob.ts +++ b/src/server/routes/blob.ts @@ -65,9 +65,9 @@ export const blobRoutes = new Elysia() ); } - if (session) { + const rpc = session ? getAgent(session) : null; + if (rpc && session) { // ATProto mode: upload to PDS - const rpc = await getAgent(session); const result = await ok( rpc.post("com.atproto.repo.uploadBlob", { input: processed.data, diff --git a/tests/atproto/env.test.ts b/tests/atproto/env.test.ts index 633c506..b7273e8 100644 --- a/tests/atproto/env.test.ts +++ b/tests/atproto/env.test.ts @@ -2,8 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { getAtprotoEnv, getDevAccounts, - getDevPdsUrl, - getDevPlcUrl, getHandleResolverUrl, getJetstreamUrl, isAuthEnabled, @@ -16,8 +14,6 @@ const ENV_KEYS = [ "OAUTH_PRIVATE_KEY_PATH", "JETSTREAM_URL", "HANDLE_RESOLVER_URL", - "DEV_PDS_URL", - "DEV_PLC_URL", "DEV_ACCOUNTS", ]; @@ -99,50 +95,37 @@ describe("getHandleResolverUrl", () => { }); }); -describe("getDevPdsUrl", () => { - test("returns null when not set", () => { - delete process.env["DEV_PDS_URL"]; - expect(getDevPdsUrl()).toBeNull(); - }); - - test("reads from env", () => { - process.env["DEV_PDS_URL"] = "http://localhost:2583"; - expect(getDevPdsUrl()).toBe("http://localhost:2583"); - }); -}); - -describe("getDevPlcUrl", () => { - test("returns null when not set", () => { - delete process.env["DEV_PLC_URL"]; - expect(getDevPlcUrl()).toBeNull(); +describe("getDevAccounts", () => { + test("returns null when OAuth is configured", () => { + process.env["PUBLIC_URL"] = "https://lichen.wiki"; + process.env["OAUTH_PRIVATE_KEY_PATH"] = "/path/to/key.pem"; + expect(getDevAccounts()).toBeNull(); }); - test("reads from env", () => { - process.env["DEV_PLC_URL"] = "http://localhost:2582"; - expect(getDevPlcUrl()).toBe("http://localhost:2582"); - }); -}); - -describe("getDevAccounts", () => { - test("returns null when not set", () => { + test("returns baked-in alice + bob defaults when OAuth not set", () => { + delete process.env["PUBLIC_URL"]; + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; delete process.env["DEV_ACCOUNTS"]; - expect(getDevAccounts()).toBeNull(); + const accounts = getDevAccounts(); + expect(accounts?.["alice"]?.handle).toBe("alice.test"); + expect(accounts?.["bob"]?.handle).toBe("bob.test"); }); - test("parses valid JSON", () => { + test("parses valid DEV_ACCOUNTS JSON", () => { + delete process.env["PUBLIC_URL"]; + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; process.env["DEV_ACCOUNTS"] = JSON.stringify({ - alice: { - did: "did:plc:alice", - handle: "alice.test", - password: "pw", - }, + carol: { did: "did:plc:carol", handle: "carol.test" }, }); const accounts = getDevAccounts(); - expect(accounts?.["alice"]?.did).toBe("did:plc:alice"); + expect(accounts?.["carol"]?.did).toBe("did:plc:carol"); }); - test("returns null for malformed JSON", () => { + test("falls back to defaults on malformed JSON", () => { + delete process.env["PUBLIC_URL"]; + delete process.env["OAUTH_PRIVATE_KEY_PATH"]; process.env["DEV_ACCOUNTS"] = "{not json}"; - expect(getDevAccounts()).toBeNull(); + const accounts = getDevAccounts(); + expect(accounts?.["alice"]?.handle).toBe("alice.test"); }); });