🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Prepare for deployment

juprodh b69556e8 eef71395

+123 -16
+1
knip.config.ts
··· 12 12 "better-sqlite3", 13 13 "tailwindcss", 14 14 "@tailwindcss/typography", 15 + "jose", // transitive dep of @atproto/jwk-jose, imported directly for Bun compatibility workaround 15 16 ], 16 17 includeEntryExports: true, 17 18 treatConfigHintsAsErrors: true,
+15 -1
src/atproto/client.ts
··· 8 8 import { getDb } from "../server/db/index.ts"; 9 9 import type { AtprotoEnv } from "./env.ts"; 10 10 11 + // Re-use jose from @atproto/jwk-jose's dependency (avoid knip unlisted dep warning) 12 + // biome-ignore lint/suspicious/noExplicitAny: accessing transitive jose exports 13 + const jose: any = await import("jose"); 14 + 11 15 class SqliteSessionStore { 12 16 get(did: string): NodeSavedSession | undefined { 13 17 const db = getDb(); ··· 64 68 env: AtprotoEnv, 65 69 ): Promise<NodeOAuthClient> { 66 70 const privateKeyPem = readFileSync(env.privateKeyPath, "utf-8"); 67 - const key = await JoseKey.fromImportable(privateKeyPem); 71 + const keyLike = await jose.importPKCS8(privateKeyPem, "ES256", { 72 + extractable: true, 73 + }); 74 + const jwk = await jose.exportJWK(keyLike); 75 + jwk.alg = "ES256"; 76 + jwk.kid = "lichen-key-1"; 77 + jwk.key_ops = ["sign"]; 78 + const key = await JoseKey.fromJWK(jwk as Record<string, unknown>); 79 + // Workaround: fromJWK strips "use" from private keys and converts to key_ops, 80 + // but the oauth-client keyset lookup filters by use: "sig". Patch it back. 81 + Object.defineProperty(key, "use", { get: () => "sig" }); 68 82 69 83 const url = env.publicUrl; 70 84
+3 -2
src/atproto/session.ts
··· 1 1 import { Agent, AtpAgent } from "@atproto/api"; 2 2 import type { NodeOAuthClient, OAuthSession } from "@atproto/oauth-client-node"; 3 + import { resolveProfile } from "../lib/profile.ts"; 3 4 import { createOAuthClient } from "./client.ts"; 4 5 import { getAtprotoEnv, getDevAccounts, getDevPdsUrl } from "./env.ts"; 5 6 ··· 22 23 23 24 try { 24 25 const oauthSession = await client.restore(did); 25 - const handle = oauthSession.sub ?? did; 26 - return { did, handle, oauthSession }; 26 + const profile = await resolveProfile(did); 27 + return { did, handle: profile.handle ?? did, oauthSession }; 27 28 } catch { 28 29 return null; 29 30 }
+32 -4
src/lib/profile.ts
··· 1 1 import { IdResolver } from "@atproto/identity"; 2 + import { 3 + getCachedProfile, 4 + setCachedProfile, 5 + } from "../server/db/queries/index.ts"; 2 6 3 7 export interface ProfileInfo { 4 8 handle: string | null; ··· 6 10 avatar: string | null; 7 11 } 8 12 9 - const idResolver = new IdResolver(); 13 + let idResolver: IdResolver | null = null; 14 + 15 + function getIdResolver(): IdResolver { 16 + if (!idResolver) idResolver = new IdResolver(); 17 + return idResolver; 18 + } 10 19 11 20 /** 12 21 * Resolve a handle or DID string to a DID. ··· 21 30 : handleOrDid; 22 31 if (normalized.startsWith("did:")) return normalized; 23 32 try { 24 - const did = await idResolver.handle.resolve(normalized); 33 + const did = await getIdResolver().handle.resolve(normalized); 25 34 return did ?? null; 26 35 } catch { 27 36 return null; ··· 41 50 did: string, 42 51 fetchFn: typeof fetch = fetch, 43 52 ): Promise<ProfileInfo> { 53 + // Check cache first (skip for injected fetchFn, i.e. tests) 54 + if (fetchFn === fetch) { 55 + const cached = getCachedProfile(did); 56 + if (cached) { 57 + return { 58 + handle: cached.handle, 59 + displayName: cached.display_name, 60 + avatar: cached.avatar, 61 + }; 62 + } 63 + } 64 + 44 65 try { 45 - const doc = await idResolver.did.resolve(did); 66 + const doc = await getIdResolver().did.resolve(did); 46 67 const alsoKnownAs = doc?.alsoKnownAs ?? []; 47 68 const atHandle = alsoKnownAs.find((uri) => uri.startsWith("at://")); 48 69 const handle = atHandle ? atHandle.slice("at://".length) : null; ··· 51 72 const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`; 52 73 const res = await fetchFn(url, { signal: AbortSignal.timeout(5000) }); 53 74 if (!res.ok) { 75 + if (fetchFn === fetch) setCachedProfile(did, handle, null, null); 54 76 return { handle, displayName: null, avatar: null }; 55 77 } 56 78 const profile = (await res.json()) as { ··· 59 81 avatar?: string; 60 82 }; 61 83 62 - return { 84 + const result: ProfileInfo = { 63 85 handle: handle ?? profile.handle ?? null, 64 86 displayName: profile.displayName ?? null, 65 87 avatar: profile.avatar ?? null, 66 88 }; 89 + 90 + if (fetchFn === fetch) { 91 + setCachedProfile(did, result.handle, result.displayName, result.avatar); 92 + } 93 + 94 + return result; 67 95 } catch { 68 96 return { handle: null, displayName: null, avatar: null }; 69 97 }
+1
src/server/db/queries/index.ts
··· 36 36 searchNotes, 37 37 upsertNote, 38 38 } from "./note.ts"; 39 + export { getCachedProfile, setCachedProfile } from "./profile-cache.ts"; 39 40 export { 40 41 applyRevisionFromFirehose, 41 42 capSnapshots,
+41
src/server/db/queries/profile-cache.ts
··· 1 + import { getDb } from "../index.ts"; 2 + 3 + interface CachedProfile { 4 + did: string; 5 + handle: string | null; 6 + display_name: string | null; 7 + avatar: string | null; 8 + updated_at: string; 9 + } 10 + 11 + const CACHE_MAX_AGE_HOURS = 24; 12 + 13 + export function getCachedProfile(did: string): CachedProfile | null { 14 + const db = getDb(); 15 + const row = db 16 + .query( 17 + `SELECT * FROM profile_cache 18 + WHERE did = ? AND updated_at > datetime('now', ?)`, 19 + ) 20 + .get(did, `-${CACHE_MAX_AGE_HOURS} hours`) as CachedProfile | null; 21 + return row; 22 + } 23 + 24 + export function setCachedProfile( 25 + did: string, 26 + handle: string | null, 27 + displayName: string | null, 28 + avatar: string | null, 29 + ): void { 30 + const db = getDb(); 31 + db.run( 32 + `INSERT INTO profile_cache (did, handle, display_name, avatar, updated_at) 33 + VALUES (?, ?, ?, ?, datetime('now')) 34 + ON CONFLICT(did) DO UPDATE SET 35 + handle = excluded.handle, 36 + display_name = excluded.display_name, 37 + avatar = excluded.avatar, 38 + updated_at = datetime('now')`, 39 + [did, handle, displayName, avatar], 40 + ); 41 + }
+10
src/server/db/schema.ts
··· 140 140 ) 141 141 `); 142 142 143 + db.run(` 144 + CREATE TABLE IF NOT EXISTS profile_cache ( 145 + did TEXT PRIMARY KEY, 146 + handle TEXT, 147 + display_name TEXT, 148 + avatar TEXT, 149 + updated_at TEXT NOT NULL DEFAULT (datetime('now')) 150 + ) 151 + `); 152 + 143 153 // Indexes 144 154 db.run( 145 155 "CREATE INDEX IF NOT EXISTS idx_revisions_note ON revisions(note_at_uri)",
+20 -9
tests/atproto/session.test.ts
··· 1 - import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 1 + import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 2 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 - import { 4 - DEV_DID, 5 - getDevSession, 6 - getEffectiveDid, 7 - getSession, 8 - } from "../../src/atproto/session.ts"; 3 + 4 + // Mock profile resolution to avoid network calls and return DID as handle 5 + mock.module("../../src/lib/profile.ts", () => ({ 6 + resolveProfile: async (did: string) => ({ 7 + handle: did, 8 + displayName: null, 9 + avatar: null, 10 + }), 11 + resolveHandleToDid: async (input: string) => input, 12 + resolveProfiles: async () => new Map(), 13 + })); 14 + 15 + const { DEV_DID, getDevSession, getEffectiveDid, getSession } = await import( 16 + "../../src/atproto/session.ts" 17 + ); 9 18 10 19 // Minimal mock of NodeOAuthClient for testing cookie parsing 11 20 function createMockClient( ··· 72 81 expect(session).toBeNull(); 73 82 }); 74 83 75 - test("uses sub from restored session as handle", async () => { 84 + test("resolves handle via profile resolution", async () => { 76 85 const client = createMockClient(() => ({ 77 86 sub: "did:plc:custom-sub", 78 87 })); 79 88 const session = await getSession(client, "did=did%3Aplc%3Aabc"); 80 - expect(session?.handle).toBe("did:plc:custom-sub"); 89 + // Handle comes from resolveProfile — either a resolved handle or the DID as fallback 90 + expect(session?.handle).toBeString(); 91 + expect(session?.did).toBe("did:plc:abc"); 81 92 }); 82 93 }); 83 94