Something went wrong. Try again.
A CLI for Semble link crawling and connection making
Something went wrong. Try again.
8.9 kB · 277 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278import http from 'node:http';import { mkdir, readFile, writeFile, access, rm } from 'node:fs/promises';import { resolve } from 'node:path';import { text, isCancel } from '@clack/prompts';import { Client, ok, type FetchHandlerObject } from '@atcute/client';import { PasswordSession } from '@atcute/password-session';import { OAuthClient, MemoryStore, type Store, type StoredState, scope,} from '@atcute/oauth-node-client';import { CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver,} from '@atcute/identity-resolver';import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node';import { isActorIdentifier, type ActorIdentifier } from '@atcute/lexicons/syntax';import type { SessionInfo } from './types.js';import { CONFIG_DIR } from './paths.js';import type {} from '@atcute/atproto';
const PASSWORD_SESSION_FILE = resolve(CONFIG_DIR, 'password-session.json');const OAUTH_ACTIVE_FILE = resolve(CONFIG_DIR, 'oauth-active.json');const OAUTH_STORE_FILE = resolve(CONFIG_DIR, 'oauth-store.json');
export async function ensureSession(): Promise<SessionInfo> { const passwordSession = await tryPasswordSession(); if (passwordSession) return passwordSession; return ensureOAuthSession();}
export async function clearOAuthSession(): Promise<void> { await rm(OAUTH_ACTIVE_FILE, { force: true }); await rm(OAUTH_STORE_FILE, { force: true });}
async function mkdirPrivate(): Promise<void> { await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });}
async function writePrivateJson(path: string, data: unknown): Promise<void> { await mkdirPrivate(); await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 });}
async function readPrivateJson<T>(path: string): Promise<T | undefined> { try { await access(path); const raw = await readFile(path, 'utf8'); return JSON.parse(raw) as T; } catch { return undefined; }}
// ---------- password session ----------
async function tryPasswordSession(): Promise<SessionInfo | undefined> { const { ATP_HANDLE, ATP_PASSWORD, PDS_URL } = process.env; if (!ATP_HANDLE || !ATP_PASSWORD || !PDS_URL) return;
const saved = await readPrivateJson<Record<string, unknown>>(PASSWORD_SESSION_FILE);
let auth: PasswordSession; if (saved && Object.keys(saved).length > 0) { try { auth = await PasswordSession.resume(saved as never, { onUpdate: (session) => writePrivateJson(PASSWORD_SESSION_FILE, session), onDelete: () => writePrivateJson(PASSWORD_SESSION_FILE, {}), }); } catch { auth = await loginPasswordSession(PDS_URL, ATP_HANDLE, ATP_PASSWORD); } } else { auth = await loginPasswordSession(PDS_URL, ATP_HANDLE, ATP_PASSWORD); }
const rpc = new Client({ handler: auth }); const session = await ok(rpc.get('com.atproto.server.getSession')); return { did: session.did, handle: session.handle, service: PDS_URL, handler: auth };}
function loginPasswordSession(service: string, identifier: string, password: string): Promise<PasswordSession> { return PasswordSession.login( { service, identifier, password }, { onUpdate: (session) => writePrivateJson(PASSWORD_SESSION_FILE, session), onDelete: () => writePrivateJson(PASSWORD_SESSION_FILE, {}), }, );}
// ---------- oauth file store ----------
class FileStore<T> implements Store<string, T> { private cache = new Map<string, T>(); private loaded = false;
constructor(private path: string) {}
private async load(): Promise<void> { if (this.loaded) return; this.loaded = true; const data = await readPrivateJson<Record<string, T>>(this.path); if (!data) return; for (const [key, value] of Object.entries(data)) { this.cache.set(key, value); } }
private async save(): Promise<void> { const data: Record<string, T> = {}; for (const [key, value] of this.cache) { data[key] = value; } await writePrivateJson(this.path, data); }
async get(key: string): Promise<T | undefined> { await this.load(); return this.cache.get(key); }
async set(key: string, value: T): Promise<void> { await this.load(); this.cache.set(key, value); await this.save(); }
async delete(key: string): Promise<void> { await this.load(); this.cache.delete(key); await this.save(); }
async clear(): Promise<void> { this.cache.clear(); await this.save(); }}
// ---------- oauth session ----------
async function ensureOAuthSession(): Promise<SessionInfo> { const active = await readPrivateJson<{ did: string }>(OAUTH_ACTIVE_FILE); if (active?.did) { try { const oauth = createRestoreOAuthClient(); const session = await oauth.restore(active.did as `did:${string}:${string}`); const rpc = new Client({ handler: session }); const profile = await ok(rpc.get('com.atproto.server.getSession')); return { did: profile.did, handle: profile.handle, handler: session }; } catch { // fall through to fresh login } }
const identifier = await text({ message: 'Enter your ATProto handle or DID', placeholder: 'you.bsky.social', validate(value) { if (!value) return 'A handle or DID is required'; if (!isActorIdentifier(value)) return 'Enter a valid handle or DID'; }, }); if (isCancel(identifier)) throw new Error('Cancelled');
const port = await getRandomPort(); const redirectUri = `http://127.0.0.1:${port}/callback`; const oauth = createOAuthClient(redirectUri);
const { url } = await oauth.authorize({ target: { type: 'account', identifier: identifier as ActorIdentifier }, redirectUri, });
console.log(`\nOpen this URL in your browser to authorize:\n${url.href}\n`);
const params = await runLoopbackCallbackServer(redirectUri); const { session } = await oauth.callback(params, { redirectUri });
await writePrivateJson(OAUTH_ACTIVE_FILE, { did: session.did });
const rpc = new Client({ handler: session }); const profile = await ok(rpc.get('com.atproto.server.getSession')); return { did: profile.did, handle: profile.handle, handler: session };}
function createOAuthClient(redirectUri: string): OAuthClient { return new OAuthClient({ metadata: { redirect_uris: [redirectUri], scope: [ scope.repo({ collection: ['network.cosmik.card', 'network.cosmik.connection'], action: ['create'] }), scope.rpc({ lxm: ['com.atproto.server.getSession'], aud: '*' }), ], }, actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns: new NodeDnsHandleResolver(), http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), stores: { sessions: new FileStore(OAUTH_STORE_FILE), states: new MemoryStore<string, StoredState>({ maxSize: 10, ttl: 10 * 60_000, ttlAutopurge: true }), }, });}
function createRestoreOAuthClient(): OAuthClient { return createOAuthClient('http://127.0.0.1/callback');}
async function getRandomPort(): Promise<number> { return new Promise((resolve, reject) => { const server = http.createServer(); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(() => reject(new Error('Could not determine callback port'))); return; } server.close(() => resolve(address.port)); }); server.on('error', reject); });}
function runLoopbackCallbackServer(redirectUri: string): Promise<URLSearchParams> { const callbackUrl = new URL(redirectUri); return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); if (url.pathname === callbackUrl.pathname) { res.writeHead(200, { 'content-type': 'text/html' }); res.end( `<!doctype html><html><head><title>authenticated</title></head><body><h1>authenticated!</h1><p>you can close this window and return to the terminal.</p></body></html>`, ); server.close(() => resolve(url.searchParams)); } else { res.writeHead(404); res.end('not found'); } });
const port = Number(callbackUrl.port); server.listen(port, '127.0.0.1', () => { server.unref(); });
const timeout = setTimeout(() => { server.close(() => {}); reject(new Error('OAuth callback timed out after 5 minutes')); }, 5 * 60_000);
server.on('error', (err) => { clearTimeout(timeout); reject(err); }); });}