char-slop/stars.hackery.site
git clone https://git.t4t.associates/char-slop/stars.hackery.site
8463413
main
1import * as l from "@cerulea/lexicon" ; 2import { isBareOrigin , readBytes } from "./http.ts" ; 3import { 4type Did , 5isDid , 6parseAtUri , 7Profile , 8Star , 9starUrl , 10} from "./records.ts" ; 11 12export type Entry = { uri :string ; value :unknown }; 13export type StarEntry = { uri :string ; star :Star ; link :string }; 14export type Actor = { 15did :Did ; 16handle :string ; 17pds :string ; 18handleVerified ?:boolean ; 19avatar ?:string ; 20}; 21 22// Public services consulted for accounts Hydrant doesn't track. 23const SLINGSHOT = new URL ( "https://slingshot.microcosm.blue" ); 24const PLC = new URL ( "https://plc.directory" ); 25const BSKY = new URL ( "https://public.api.bsky.app" ); 26const CDN = new URL ( "https://cdn.cerulea.blue" ); 27 28type Json = Record < string , unknown >; 29type Params = Record < string , string | number | boolean | undefined >; 30const isJson = ( data :unknown ) :data isJson => 31typeof data === "object" && data !== null && ! Array . isArray ( data ); 32 33const xrpc = ( base :URL , method :string , params :Params ) :URL => { 34const url = new URL ( `/xrpc/ ${ method } ` , base ); 35for ( const [ key , value ] of Object . entries ( params )) { 36if ( value !== undefined ) url . searchParams . set ( key , String ( value )); 37} 38return url ; 39}; 40 41// A bounded JSON object fetch; 404 is absence rather than failure. 42async function getJson ( url :URL ) :Promise < Json | undefined > { 43const res = await fetch ( url , { 44headers :{ accept :"application/json" }, 45redirect :"error" , 46signal :AbortSignal . timeout ( 10000 ), 47}); 48if ( res . status === 404 ) return ; 49if ( ! res . ok || Number ( res . headers . get ( "content-length" )) > 4_194_304 ) { 50throw new Error ( `Upstream ${ url . origin } : ${ res . status } ` ); 51} 52const bytes = await readBytes ( res . body ?? [], 4_194_304 ); 53const data :unknown = JSON . parse ( new TextDecoder (). decode ( bytes )); 54if ( ! isJson ( data )) throw new Error ( `Invalid response from ${ url . origin } ` ); 55return data ; 56} 57 58// For lookups with a fallback, where failure is just absence. 59const tryJson = ( url :URL ) => getJson ( url ). catch (() => undefined ); 60 61const MiniDoc = l . object ({ 62did :l . string , 63pds :l . string , 64handle :l . optional ( l . string ), 65}); 66 67// What we know of an account before checking its avatar. 68type Identity = Omit < Actor , "avatar" >; 69 70function identity ( 71did :Did , 72pds :string , 73claimed :string | undefined , 74handleVerified :boolean , 75) :Identity | undefined { 76const endpoint = new URL ( pds ); 77if ( ! isBareOrigin ( endpoint ) || endpoint . protocol !== "https:" ) return ; 78const usable = claimed && claimed !== "handle.invalid" && 79l . handle . validate ( claimed ). success ; 80return { 81 did, 82pds :endpoint . href , 83handle :usable ?claimed :did , 84 handleVerified, 85}; 86} 87 88// A DID document's PDS endpoint and the handle it claims. 89function plcPds ( doc :Json ) :string | undefined { 90for ( const service of Array . isArray ( doc . service ) ?doc . service :[]) { 91if ( 92service ?. id === "#atproto_pds" && 93typeof service . serviceEndpoint === "string" 94) { 95return service . serviceEndpoint ; 96} 97} 98} 99function plcHandle ( doc :Json ) :string | undefined { 100for ( const alias of Array . isArray ( doc . alsoKnownAs ) ?doc . alsoKnownAs :[]) { 101const claimed = typeof alias === "string" && alias . startsWith ( "at://" ) && 102alias . slice ( 5 ); 103if ( claimed && l . handle . validate ( claimed ). success ) return claimed ; 104} 105} 106 107// Stars whose link we can't render are treated as absent. 108function parseStar ( entry :Entry ) :StarEntry | undefined { 109const parsed = Star . validate ( entry . value ); 110const link = parsed . success ?starUrl ( parsed . value . url ) :undefined ; 111if ( parsed . success && link ) { 112return { uri :entry . uri , star :parsed . value , link}; 113} 114} 115 116export class Hydrant { 117constructor ( readonly base :URL ) {} 118 119private get ( method :string , params :Params ) :Promise < Json | undefined > { 120return getJson ( xrpc ( this . base , method , params )); 121} 122 123// Hydrant's identity cache, then the public one. 124private async miniDoc ( identifier :string ) { 125const method = "blue.microcosm.identity.resolveMiniDoc" ; 126const doc = await tryJson ( xrpc ( this . base , method , { identifier})) ?? 127await tryJson ( xrpc ( SLINGSHOT , method , { identifier})); 128const parsed = MiniDoc . validate ( doc ); 129if ( parsed . success && isDid ( parsed . value . did )) { 130return { ...parsed . value , did :parsed . value . did }; 131} 132} 133 134// A PLC account whose handle can't be verified still has a claimed one in 135// its DID document, which we show as unverified. 136private async unverified ( 137identifier :string , 138pds ?:string , 139) :Promise < Identity | undefined > { 140if ( ! isDid ( identifier ) || ! identifier . startsWith ( "did:plc:" )) return ; 141const doc = await getJson ( new URL ( `/ ${ identifier } ` , PLC )); 142if ( doc ?. id !== identifier ) return ; 143const endpoint = pds ?? plcPds ( doc ); 144if ( ! endpoint ) return ; 145return identity ( identifier , endpoint , plcHandle ( doc ), false ); 146} 147 148async actor ( identifier :string ) :Promise < Actor | undefined > { 149const doc = await this . miniDoc ( identifier ); 150const known = doc && doc . handle !== "handle.invalid" 151 ?identity ( doc . did , doc . pds , doc . handle , true ) 152 :await this . unverified ( 153doc ?. did ?? identifier , 154doc ?. pds , 155); 156if ( ! known ) return ; 157const avatar = await this . avatar ( known . did ); 158return avatar ?{ ...known , avatar} :known ; 159} 160 161private async avatar ( did :Did ) :Promise < string | undefined > { 162const profile = await this . get ( "com.atproto.repo.getRecord" , { 163repo :did , 164collection :"app.bsky.actor.profile" , 165rkey :"self" , 166}); 167const parsed = Profile . validate ( profile ?. value ); 168if ( parsed . success && parsed . value . avatar ) { 169return new URL ( `/ ${ did } / ${ parsed . value . avatar . ref . $link } ` , CDN ). href ; 170} 171// Hydrant only holds tracked profiles; Bluesky's AppView has the rest. 172const view = await tryJson ( 173xrpc ( BSKY , "app.bsky.actor.getProfile" , { actor :did }), 174); 175const url = view ?. did === did && typeof view . avatar === "string" 176 ?URL . parse ( view . avatar ) 177 :null ; 178if ( url ?. protocol === "https:" && url . hostname === "cdn.bsky.app" ) { 179return url . href ; 180} 181} 182 183async records ( 184did :string , 185collection :string , 186cursor ?:string , 187limit = 50 , 188) :Promise <{ entries :Entry []; cursor ?:string }> { 189const data = await this . get ( "com.atproto.repo.listRecords" , { 190repo :did , 191 collection, 192 limit, 193 cursor, 194reverse :false , 195}); 196if ( ! data ) return { entries :[] }; 197const { records, cursor :next } = data ; 198if ( 199! Array . isArray ( records ) || records . length > limit || 200( next !== undefined && typeof next !== "string" ) 201) throw new Error ( "Invalid record page" ); 202const entries = records . filter (( entry ) :entry isEntry => 203typeof entry ?. uri === "string" && 204entry . uri . startsWith ( `at:// ${ did } / ${ collection } /` ) 205); 206return { entries, cursor :next }; 207} 208 209// Follows a collection's listRecords cursor to the end. 210async * allRecords ( did :string , collection :string ) :AsyncGenerator < Entry > { 211let cursor :string | undefined ; 212do { 213const page = await this . records ( did , collection , cursor , 100 ); 214yield * page . entries ; 215if ( page . cursor && page . cursor === cursor ) { 216throw new Error ( "Repeated Hydrant cursor" ); 217} 218cursor = page . cursor ; 219} while ( cursor ); 220} 221 222async record ( uri :string ) :Promise < Entry | undefined > { 223const at = parseAtUri ( uri ); 224if ( ! at ) return ; 225const data = await this . get ( "com.atproto.repo.getRecord" , { 226repo :at . did , 227collection :at . collection , 228rkey :at . rkey , 229}); 230if ( data ?. uri !== uri ) return ; 231return { uri, value :data . value }; 232} 233 234async star ( uri :string ) :Promise < StarEntry | undefined > { 235const entry = await this . record ( uri ); 236return entry && parseStar ( entry ); 237} 238 239async stars ( 240did :string , 241cursor ?:string , 242) :Promise <{ stars :StarEntry []; cursor ?:string }> { 243const page = await this . records ( did , Star . id , cursor , 25 ); 244return { 245stars :page . entries . flatMap (( entry ) => parseStar ( entry ) ?? []), 246cursor :page . cursor , 247}; 248} 249 250// One page of the repos Hydrant tracks, from its management API. 251private async repoPage ( cursor ?:string ) :Promise < string []> { 252const url = new URL ( "/repos" , this . base ); 253url . searchParams . set ( "limit" , "1000" ); 254if ( cursor ) url . searchParams . set ( "cursor" , cursor ); 255const res = await fetch ( url , { 256headers :{ accept :"application/json" }, 257signal :AbortSignal . timeout ( 30000 ), 258}); 259if ( ! res . ok ) throw new Error ( `Hydrant repos: ${ res . status } ` ); 260const repos :unknown = await res . json (); 261if ( ! Array . isArray ( repos ) || repos . length > 1000 ) { 262throw new Error ( "Invalid repo list" ); 263} 264const dids :unknown [] = repos . map (( row ) => row ?. did ); 265const valid = dids . every (( did ) :did isstring => typeof did === "string" ); 266if ( ! valid || ( cursor && dids . includes ( cursor ))) { 267throw new Error ( "Invalid repo page" ); 268} 269return dids ; 270} 271 272async * repos () :AsyncGenerator < string > { 273let page :string [], cursor :string | undefined ; 274do { 275page = await this . repoPage ( cursor ); 276yield * page ; 277cursor = page . at ( - 1 ); 278} while ( page . length ); 279} 280 281async track ( did :string ) :Promise < void > { 282const res = await fetch ( new URL ( "/repos" , this . base ), { 283method :"PUT" , 284headers :{ "content-type" :"application/json" }, 285body :JSON . stringify ([{ did}]), 286signal :AbortSignal . timeout ( 10000 ), 287}); 288if ( ! res . ok ) throw new Error ( `Could not track repo: ${ res . status } ` ); 289await res . body ?. cancel (); 290} 291 292// The event stream, resuming from the given event ID. 293stream ( cursor :number ) :URL { 294const url = new URL ( "/stream" , this . base ); 295url . protocol = url . protocol === "https:" ?"wss:" :"ws:" ; 296url . searchParams . set ( "cursor" , String ( cursor )); 297return url ; 298} 299}