char-slop/stars.hackery.site
git clone https://git.t4t.associates/char-slop/stars.hackery.site
92004f9
main
1import { DatabaseSync } from "node:sqlite" ; 2import { 3MemoryStore , 4OAuthClient , 5type OAuthSession , 6type StoredSession , 7type StoredState , 8} from "@atcute/oauth-node-client" ; 9import { 10CompositeDidDocumentResolver , 11CompositeHandleResolver , 12LocalActorResolver , 13PlcDidDocumentResolver , 14WebDidDocumentResolver , 15WellKnownHandleResolver , 16} from "@atcute/identity-resolver" ; 17import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node" ; 18import { cookie , readCookie } from "./http.ts" ; 19import { type Did , Follow , isDid , parseAtUri , Star } from "./records.ts" ; 20 21export const scope = `atproto repo: ${ Star . id } repo: ${ Follow . id } ` ; 22const SESSION_TTL = 14 * 86400 ; 23 24export type Session = { 25id :string ; 26did :Did ; 27csrf :string ; 28oauth :OAuthSession ; 29}; 30 31// A key/value table with JSON values, as the OAuth client's store interface. 32function jsonTable < V > ( db :DatabaseSync , name :string ) { 33db . exec ( 34`CREATE TABLE IF NOT EXISTS ${ name } (key TEXT PRIMARY KEY, value TEXT NOT NULL)` , 35); 36return { 37get :( key :string ) :V | undefined => { 38const row = db . prepare ( `SELECT value FROM ${ name } WHERE key = ?` ). get ( 39key , 40); 41return row && JSON . parse ( String ( row . value )); 42}, 43set :( key :string , value :V ) => { 44db . prepare ( `INSERT OR REPLACE INTO ${ name } VALUES (?, ?)` ). run ( 45key , 46JSON . stringify ( value ), 47); 48}, 49delete :( key :string ) => { 50db . prepare ( `DELETE FROM ${ name } WHERE key = ?` ). run ( key ); 51}, 52clear :() => { 53db . prepare ( `DELETE FROM ${ name } ` ). run (); 54}, 55}; 56} 57 58export class Auth { 59readonly client :OAuthClient ; 60constructor ( readonly db :DatabaseSync , readonly origin :URL ) { 61db . exec ( 62"CREATE TABLE IF NOT EXISTS browser_session (id TEXT PRIMARY KEY, did TEXT NOT NULL, csrf TEXT NOT NULL, expires INTEGER NOT NULL)" , 63); 64const states = jsonTable < StoredState >( db , "oauth_state" ); 65const local = origin . hostname === "127.0.0.1" ; 66this . client = new OAuthClient ({ 67metadata :{ 68 ...( ! local && { 69client_id :` ${ origin . origin } /oauth-client-metadata.json` , 70client_name :"Stars" , 71client_uri :origin . origin , 72}), 73redirect_uris :[ ` ${ origin . origin } /oauth/callback` ], 74 scope, 75}, 76// the default caches purge on a timer, which keeps the process alive after SIGTERM 77stores :{ 78prMetadata :new MemoryStore ({ maxSize :100 , ttl :60e3 }), 79asMetadata :new MemoryStore ({ maxSize :100 , ttl :60e3 }), 80dpopNonces :new MemoryStore ({ maxSize :100 , ttl :60e3 }), 81sessions :jsonTable < StoredSession >( db , "oauth_session" ), 82states :{ 83 ...states , 84get :( key ) => { 85const state = states . get ( key ); 86if ( state && state . expiresAt <= Date . now ()) { 87states . delete ( key ); 88return ; 89} 90return state ; 91}, 92}, 93}, 94actorResolver :new LocalActorResolver ({ 95handleResolver :new CompositeHandleResolver ({ 96methods :{ 97dns :new NodeDnsHandleResolver (), 98http :new WellKnownHandleResolver (), 99}, 100}), 101didDocumentResolver :new CompositeDidDocumentResolver ({ 102methods :{ 103plc :new PlcDidDocumentResolver (), 104web :new WebDidDocumentResolver (), 105}, 106}), 107}), 108}); 109} 110 111get secure () :boolean { 112return this . origin . protocol === "https:" ; 113} 114 115private browser ( req :Request ) { 116const id = readCookie ( req , "stars_session" ); 117if ( ! id || ! /^[0-9a-f]{64}$ / . test ( id )) return ; 118const row = this . db . prepare ( 119"SELECT did, csrf FROM browser_session WHERE id = ? AND expires > ?" , 120). get ( id , Date . now ()); 121if ( ! row ) return ; 122const did = String ( row . did ), csrf = String ( row . csrf ); 123if ( isDid ( did )) return { id, did, csrf}; 124} 125 126async session ( req :Request ) :Promise < Session | undefined > { 127const browser = this . browser ( req ); 128if ( ! browser ) return ; 129try { 130return { ...browser , oauth :await this . client . restore ( browser . did ) }; 131} catch { 132return ; 133} 134} 135 136createBrowser ( did :string ) :string { 137const id = randomHex (), csrf = randomHex (); 138this . db . prepare ( "INSERT INTO browser_session VALUES (?, ?, ?, ?)" ). run ( 139id , 140did , 141csrf , 142Date . now () + SESSION_TTL * 1000 , 143); 144return id ; 145} 146 147deleteBrowser ( id :string ) :void { 148this . db . prepare ( "DELETE FROM browser_session WHERE id = ?" ). run ( id ); 149} 150 151sessionCookie ( id :string , maxAge = SESSION_TTL ) :string { 152return cookie ( "stars_session" , id , this . secure , maxAge ); 153} 154} 155 156function randomHex () :string { 157return Array . from ( 158crypto . getRandomValues ( new Uint8Array ( 32 )), 159( byte ) => byte . toString ( 16 ). padStart ( 2 , "0" ), 160). join ( "" ); 161} 162 163export async function writeRecord ( 164oauth :OAuthSession , 165collection :string , 166record :unknown , 167) :Promise < string > { 168const res = await oauth . handle ( "/xrpc/com.atproto.repo.createRecord" , { 169method :"POST" , 170headers :{ "content-type" :"application/json" }, 171body :JSON . stringify ({ repo :oauth . did , collection, record}), 172}); 173if ( ! res . ok ) throw new Error ( `PDS write failed: ${ res . status } ` ); 174const data = await res . json (); 175if ( 176typeof data . uri !== "string" || 177! data . uri . startsWith ( `at:// ${ oauth . did } / ${ collection } /` ) 178) throw new Error ( "Invalid PDS write response" ); 179return data . uri ; 180} 181 182export async function deleteRecord ( 183oauth :OAuthSession , 184uri :string , 185) :Promise < void > { 186const at = parseAtUri ( uri ); 187const owned = at ?. did === oauth . did && 188( at . collection === Star . id || at . collection === Follow . id ); 189if ( ! owned ) throw new Error ( "Invalid owned record URI" ); 190const res = await oauth . handle ( "/xrpc/com.atproto.repo.deleteRecord" , { 191method :"POST" , 192headers :{ "content-type" :"application/json" }, 193body :JSON . stringify ({ 194repo :oauth . did , 195collection :at . collection , 196rkey :at . rkey , 197}), 198}); 199if ( ! res . ok ) throw new Error ( `PDS deletion failed: ${ res . status } ` ); 200}