GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

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

add PDS-direct profile fallback for overridden accounts

bsky API returns nothing for banned/suspended accounts, so actors with
show overrides get indexed without avatar or displayName. now fetches
profile data directly from the actor's PDS via com.atproto.repo.getRecord
when bsky refuses and a show override exists. zero cost in the normal
case — only triggers for the intersection of bsky-missing + show-override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+204 -20
+39 -5
src/cron.ts
··· 1 1 import type { Stmt, TursoDB } from "./db"; 2 2 import type { Env } from "./types"; 3 3 import { BSKY_GET_PROFILES_URL } from "./types"; 4 - import { extractProfileFields } from "./utils"; 4 + import { extractProfileFields, fetchProfileFromPds } from "./utils"; 5 + import { getOverrides } from "./moderation"; 5 6 import { recordActorDelta } from "./metrics"; 6 7 7 8 /** refresh moderation labels, walking the full index over multiple cron runs */ ··· 11 12 const cursor = cursorStr ? Number(cursorStr) : 0; 12 13 13 14 const { results } = await db.prepare( 14 - "SELECT rowid, did, handle, hidden, labels FROM actors WHERE rowid > ?1 ORDER BY rowid ASC LIMIT 1000" 15 - ).bind(cursor).all<{ rowid: number; did: string; handle: string; hidden: number; labels: string }>(); 15 + "SELECT rowid, did, handle, hidden, labels, pds FROM actors WHERE rowid > ?1 ORDER BY rowid ASC LIMIT 1000" 16 + ).bind(cursor).all<{ rowid: number; did: string; handle: string; hidden: number; labels: string; pds: string }>(); 16 17 17 18 if (!results || results.length === 0) { 18 19 // wrapped around — reset cursor for next run ··· 26 27 let deleted = 0; 27 28 let skipped = 0; 28 29 const t0 = Date.now(); 30 + 31 + // fetch all overrides for this page so cron respects them 32 + const overrides = await getOverrides(db, results.map((r) => r.did)); 29 33 30 34 // batch into groups of 25 (getProfiles limit), ~200ms pause between calls 31 35 for (let i = 0; i < results.length; i += 25) { ··· 53 57 54 58 const stmts: Stmt[] = []; 55 59 for (const p of profiles) { 56 - const f = extractProfileFields(p); 60 + const f = extractProfileFields(p, overrides.get(p.did) ?? null); 57 61 const cur = current.get(p.did); 58 62 // skip no-op updates — only write if labels or hidden actually changed 59 63 if (cur && cur.hidden === f.hidden && cur.labels === f.labels) { skipped++; continue; } ··· 77 81 changed += batchResults.filter((r) => r.meta.changes > 0).length; 78 82 } 79 83 80 - // dead-actor cleanup: DIDs not returned by getProfiles with empty handles 84 + // PDS fallback for unreturned DIDs with show overrides 81 85 const returnedDids = new Set(profiles.map((p: any) => p.did)); 86 + const pdsStmts: Stmt[] = []; 87 + for (const r of batch) { 88 + if (returnedDids.has(r.did)) continue; 89 + if (overrides.get(r.did) === 'show' && r.pds) { 90 + const pdsProfile = await fetchProfileFromPds(r.did, r.pds); 91 + if (pdsProfile) { 92 + const f = extractProfileFields(pdsProfile, 'show'); 93 + const cur = current.get(r.did); 94 + if (cur && cur.hidden === f.hidden && cur.labels === f.labels) continue; 95 + pdsStmts.push( 96 + db.prepare( 97 + `UPDATE actors SET hidden = ?1, 98 + display_name = COALESCE(NULLIF(?3, ''), display_name), 99 + avatar_url = COALESCE(NULLIF(?4, ''), avatar_url), 100 + labels = ?5, 101 + created_at = COALESCE(NULLIF(?6, ''), created_at), 102 + associated = COALESCE(NULLIF(?7, '{}'), associated) 103 + WHERE did = ?2` 104 + ).bind(f.hidden, r.did, f.displayName, f.avatarCid, f.labels, f.createdAt, f.associated) 105 + ); 106 + returnedDids.add(r.did); // don't delete this actor 107 + } 108 + } 109 + } 110 + if (pdsStmts.length > 0) { 111 + const batchResults = await db.batch(pdsStmts); 112 + changed += batchResults.filter((r) => r.meta.changes > 0).length; 113 + } 114 + 115 + // dead-actor cleanup: DIDs not returned by getProfiles with empty handles 82 116 const deadDids = batch.filter((r) => !returnedDids.has(r.did) && r.handle === ""); 83 117 if (deadDids.length > 0) { 84 118 const delStmts: Stmt[] = deadDids.flatMap((r) => [
+32 -11
src/enrichment.ts
··· 1 1 import type { Stmt, TursoDB } from "./db"; 2 2 import type { Env, SlingshotResponse } from "./types"; 3 3 import { SLINGSHOT_URL, BSKY_GET_PROFILES_URL } from "./types"; 4 - import { extractProfileFields } from "./utils"; 4 + import { extractProfileFields, fetchProfileFromPds } from "./utils"; 5 + import { getOverrides } from "./moderation"; 5 6 6 7 /** record an actor-count snapshot for the current hour (idempotent) */ 7 8 export async function recordSnapshot(db: TursoDB): Promise<void> { ··· 99 100 // phase 2: profile + labels enrichment via getProfiles batch 100 101 // targets actors missing avatar OR labels — one API call per 25 actors 101 102 const { results: profileRows } = await db.prepare( 102 - `SELECT did FROM actors 103 + `SELECT did, pds FROM actors 103 104 WHERE handle != '' 104 105 AND (avatar_url = '' OR labels = '[]' OR created_at = '') 105 106 AND profile_checked_at < unixepoch() - 3600 106 107 ORDER BY profile_checked_at ASC LIMIT 75` 107 - ).all<{ did: string }>(); 108 + ).all<{ did: string; pds: string }>(); 108 109 109 110 if (profileRows && profileRows.length > 0) { 111 + const overrides = await getOverrides(db, profileRows.map((r) => r.did)); 110 112 let enriched = 0; 111 113 for (let i = 0; i < profileRows.length; i += 25) { 112 114 const batch = profileRows.slice(i, i + 25); ··· 127 129 const returned = new Set<string>(); 128 130 for (const p of profiles) { 129 131 returned.add(p.did); 130 - const f = extractProfileFields(p); 132 + const f = extractProfileFields(p, overrides.get(p.did) ?? null); 131 133 stmts.push( 132 134 db.prepare( 133 135 `UPDATE actors SET ··· 147 149 ); 148 150 if (f.avatarCid) enriched++; 149 151 } 150 - // mark actors not returned by getProfiles so we back off 152 + // PDS fallback for unreturned DIDs with show overrides 151 153 for (const r of batch) { 152 - if (!returned.has(r.did)) { 153 - stmts.push( 154 - db.prepare( 155 - "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 156 - ).bind(r.did) 157 - ); 154 + if (returned.has(r.did)) continue; 155 + if (overrides.get(r.did) === 'show' && r.pds) { 156 + const pdsProfile = await fetchProfileFromPds(r.did, r.pds); 157 + if (pdsProfile) { 158 + const f = extractProfileFields(pdsProfile, 'show'); 159 + stmts.push( 160 + db.prepare( 161 + `UPDATE actors SET 162 + display_name = COALESCE(NULLIF(?2, ''), display_name), 163 + avatar_url = COALESCE(NULLIF(?3, ''), avatar_url), 164 + labels = ?4, hidden = ?5, 165 + created_at = COALESCE(NULLIF(?6, ''), created_at), 166 + associated = COALESCE(NULLIF(?7, '{}'), associated), 167 + profile_checked_at = unixepoch() 168 + WHERE did = ?1` 169 + ).bind(r.did, f.displayName, f.avatarCid, f.labels, f.hidden, f.createdAt, f.associated) 170 + ); 171 + if (f.avatarCid) enriched++; 172 + continue; 173 + } 158 174 } 175 + stmts.push( 176 + db.prepare( 177 + "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 178 + ).bind(r.did) 179 + ); 159 180 } 160 181 if (stmts.length > 0) await db.batch(stmts); 161 182 } catch {
+97 -2
src/handlers/admin.ts
··· 1 1 import type { TursoDB } from "../db"; 2 2 import type { Env, SlingshotResponse } from "../types"; 3 3 import { SLINGSHOT_URL } from "../types"; 4 - import { clientIP, json, extractProfileFields } from "../utils"; 4 + import { clientIP, json, extractProfileFields, fetchProfileFromPds } from "../utils"; 5 + import { getOverride, shouldHide } from "../moderation"; 5 6 import { recordActorDelta } from "../metrics"; 6 7 7 8 export async function handleDelete( ··· 107 108 108 109 // fetch profile from public API for display name + avatar + labels + metadata 109 110 let f = { displayName: '', avatarCid: '', hidden: 0, labels: '[]', createdAt: '', associated: '{}' }; 111 + const override = await getOverride(db, identity.did); 110 112 try { 111 113 const profileRes = await fetch( 112 114 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identity.did)}` 113 115 ); 114 116 if (profileRes.ok) { 115 - f = extractProfileFields(await profileRes.json()); 117 + f = extractProfileFields(await profileRes.json(), override); 118 + } else if (override === 'show' && identity.pds) { 119 + const pdsProfile = await fetchProfileFromPds(identity.did, identity.pds); 120 + if (pdsProfile) f = extractProfileFields(pdsProfile, override); 116 121 } 117 122 } catch { 118 123 // profile enrichment is best-effort ··· 140 145 ...(f.hidden ? { hidden: true, reason: "hidden by moderation" } : { hidden: false }), 141 146 }); 142 147 } 148 + 149 + function authCheck(request: Request, env: Env): Response | null { 150 + const auth = request.headers.get("Authorization"); 151 + if (auth === `Bearer ${env.ADMIN_SECRET}`) return null; 152 + const ip = clientIP(request); 153 + console.log(JSON.stringify({ event: "auth_failure", endpoint: "/admin/mod-override", ip })); 154 + return json({ error: "unauthorized" }, 401); 155 + } 156 + 157 + export async function handleModOverrideSet( 158 + request: Request, 159 + db: TursoDB, 160 + env: Env, 161 + ): Promise<Response> { 162 + const denied = authCheck(request, env); 163 + if (denied) return denied; 164 + 165 + let body: { did: string; action: string; reason?: string }; 166 + try { 167 + body = await request.json(); 168 + } catch { 169 + return json({ error: "invalid json" }, 400); 170 + } 171 + 172 + const { did, action, reason } = body; 173 + if (!did || (action !== 'show' && action !== 'hide')) { 174 + return json({ error: "did required, action must be 'show' or 'hide'" }, 400); 175 + } 176 + 177 + // upsert override 178 + await db.prepare( 179 + `INSERT INTO mod_overrides (did, action, reason) VALUES (?1, ?2, ?3) 180 + ON CONFLICT(did) DO UPDATE SET action = ?2, reason = ?3, created_at = unixepoch()` 181 + ).bind(did, action, reason || '').run(); 182 + 183 + // immediately apply to actors table (touch updated_at so fly replica syncs) 184 + const hidden = action === 'show' ? 0 : 1; 185 + await db.prepare( 186 + "UPDATE actors SET hidden = ?1, updated_at = unixepoch() WHERE did = ?2" 187 + ).bind(hidden, did).run(); 188 + 189 + return json({ did, action, reason: reason || '', hidden }); 190 + } 191 + 192 + export async function handleModOverrideDelete( 193 + request: Request, 194 + db: TursoDB, 195 + env: Env, 196 + ): Promise<Response> { 197 + const denied = authCheck(request, env); 198 + if (denied) return denied; 199 + 200 + const url = new URL(request.url); 201 + const did = url.searchParams.get("did") || ""; 202 + if (!did) return json({ error: "did query param required" }, 400); 203 + 204 + await db.prepare("DELETE FROM mod_overrides WHERE did = ?1").bind(did).run(); 205 + 206 + // recompute hidden from stored labels 207 + const row = await db.prepare( 208 + "SELECT labels FROM actors WHERE did = ?1" 209 + ).bind(did).first<{ labels: string }>(); 210 + 211 + let hidden = 0; 212 + if (row) { 213 + try { 214 + hidden = shouldHide(JSON.parse(row.labels)) ? 1 : 0; 215 + } catch { 216 + hidden = 0; 217 + } 218 + await db.prepare("UPDATE actors SET hidden = ?1, updated_at = unixepoch() WHERE did = ?2").bind(hidden, did).run(); 219 + } 220 + 221 + return json({ did, hidden }); 222 + } 223 + 224 + export async function handleModOverrideList( 225 + request: Request, 226 + db: TursoDB, 227 + env: Env, 228 + ): Promise<Response> { 229 + const denied = authCheck(request, env); 230 + if (denied) return denied; 231 + 232 + const { results } = await db.prepare( 233 + "SELECT did, action, reason, created_at FROM mod_overrides ORDER BY created_at DESC" 234 + ).all<{ did: string; action: string; reason: string; created_at: number }>(); 235 + 236 + return json({ overrides: results || [] }); 237 + }
+36 -2
src/utils.ts
··· 23 23 return match?.[1] ?? ''; 24 24 } 25 25 26 + /** fetch profile directly from an actor's PDS — fallback for banned/suspended accounts */ 27 + export async function fetchProfileFromPds(did: string, pds: string): Promise<any | null> { 28 + try { 29 + const res = await fetch( 30 + `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self` 31 + ); 32 + if (!res.ok) return null; 33 + const data: any = await res.json(); 34 + const val = data?.value; 35 + if (!val) return null; 36 + 37 + let avatar = ''; 38 + const cid = val.avatar?.ref?.$link || val.avatar?.ref?.['$link']; 39 + if (cid) { 40 + avatar = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 41 + } 42 + 43 + return { 44 + did, 45 + handle: '', 46 + displayName: val.displayName || '', 47 + avatar, 48 + labels: [], 49 + createdAt: val.createdAt || '', 50 + associated: {}, 51 + }; 52 + } catch { 53 + return null; 54 + } 55 + } 56 + 26 57 /** strip zero/false fields from associated object to match bsky's typeahead shape */ 27 58 export function cleanAssociated(assoc: any): Record<string, unknown> { 28 59 if (!assoc || typeof assoc !== 'object') return {}; ··· 45 76 associated: string; 46 77 } 47 78 48 - export function extractProfileFields(profile: any): ProfileFields { 79 + export function extractProfileFields(profile: any, override?: 'show' | 'hide' | null): ProfileFields { 80 + let hidden = shouldHide(profile.labels) ? 1 : 0; 81 + if (override === 'show') hidden = 0; 82 + if (override === 'hide') hidden = 1; 49 83 return { 50 84 handle: profile.handle || '', 51 85 displayName: profile.displayName || '', 52 86 avatarCid: extractAvatarCid(profile.avatar || ''), 53 87 labels: JSON.stringify(profile.labels || []), 54 - hidden: shouldHide(profile.labels) ? 1 : 0, 88 + hidden, 55 89 createdAt: profile.createdAt || '', 56 90 associated: JSON.stringify(cleanAssociated(profile.associated)), 57 91 };