// Minimal, zero-dependency atproto plumbing for the write CLIs. // // Reads use public endpoints (handle->DID, DID doc->PDS). Writes use an app // password: createSession against the user's own PDS, then createRecord with // the returned access token. No SDK — these are plain XRPC calls over fetch. // // Get an app password at: /settings/app-passwords (on Bluesky: // Settings -> Privacy and security -> App passwords). NEVER use your main // account password here. const PLC_DIRECTORY = "https://plc.directory"; const HANDLE_RESOLVER = "https://public.api.bsky.app"; // --- identity / PDS resolution ------------------------------------------------ export async function resolveDid(actor) { if (actor.startsWith("did:")) return actor; const handle = actor.replace(/^@/, ""); const url = `${HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; const res = await fetch(url); if (!res.ok) throw new Error(`resolveHandle ${handle}: HTTP ${res.status}`); const json = await res.json(); if (!json.did) throw new Error(`no DID for handle ${handle}`); return json.did; } export async function resolveDidDoc(did) { if (did.startsWith("did:plc:")) { const res = await fetch(`${PLC_DIRECTORY}/${did}`); if (!res.ok) throw new Error(`plc ${did}: HTTP ${res.status}`); return res.json(); } if (did.startsWith("did:web:")) { const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/"); const res = await fetch(`https://${host}/.well-known/did.json`); if (!res.ok) throw new Error(`did:web ${did}: HTTP ${res.status}`); return res.json(); } throw new Error(`unsupported DID method: ${did}`); } export function pdsFromDidDoc(doc) { const svc = (doc.service || []).find( (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", ); if (!svc?.serviceEndpoint) throw new Error("no #atproto_pds service in DID doc"); return svc.serviceEndpoint; } export async function resolvePds(didOrHandle) { const did = await resolveDid(didOrHandle); return pdsFromDidDoc(await resolveDidDoc(did)); } // --- authenticated session (app password) ------------------------------------- // Returns { pds, did, accessJwt, handle }. If `pds` is given we skip resolution // (useful when a handle's DID doc is unreachable but you know the host). export async function createSession({ identifier, password, pds }) { if (!identifier) throw new Error("missing identifier (handle or DID)"); if (!password) throw new Error("missing app password"); const host = pds || (await resolvePds(identifier)); const res = await fetch(`${host}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identifier, password }), }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`createSession failed: HTTP ${res.status} ${body}`); } const json = await res.json(); return { pds: host, did: json.did, accessJwt: json.accessJwt, handle: json.handle }; } // Create a record in the session's repo. `rkey` optional (server assigns a TID // when omitted). Returns { uri, cid }. export async function createRecord(session, { collection, record, rkey }) { const body = { repo: session.did, collection, record }; if (rkey) body.rkey = rkey; const res = await fetch(`${session.pds}/xrpc/com.atproto.repo.createRecord`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${session.accessJwt}`, }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`createRecord ${collection} failed: HTTP ${res.status} ${text}`); } return res.json(); }