// an app-password session against the user's own PDS, and the XRPC calls // the lore extension makes with it. every response is parsed at this // boundary against the lexicon output it names. import { Type, type Static, type TSchema } from "typebox"; import { Parse } from "typebox/value"; const sessionOutput = Type.Object({ did: Type.String(), handle: Type.String(), accessJwt: Type.String(), refreshJwt: Type.String(), }); export type Session = Static & { pds: string }; const xrpcError = Type.Object({ error: Type.Optional(Type.String()), message: Type.Optional(Type.String()) }); export class XrpcError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); } } const didDocument = Type.Object({ service: Type.Optional(Type.Array(Type.Object({ id: Type.String(), serviceEndpoint: Type.String() }))), }); export type Query = Record; /** plain JSON, the only thing that crosses the xrpc boundary. */ export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; export type JsonObject = { [key: string]: JsonValue | undefined }; /** the calls the extension needs; `FakeXrpc` in the tests implements the same. */ export interface Xrpc { readonly did: string; get(nsid: string, params: Query, output: T): Promise>; post(nsid: string, body: JsonObject, output: T): Promise>; } async function failFrom(response: Response): Promise { let code = `HTTP${response.status}`; let message = response.statusText; try { const body = Parse(xrpcError, await response.json()); code = body.error ?? code; message = body.message ?? message; } catch { /* not an xrpc error body */ } throw new XrpcError(response.status, code, message); } const miniDocOutput = Type.Object({ did: Type.String(), handle: Type.String(), pds: Type.String() }); export type MiniDoc = Static; /** what people paste as a handle: a leading @, a scheme, a path, or a trailing dot are not part of it. */ export function normalizeActor(raw: string): string { const s = raw.trim(); if (s.startsWith("did:")) return s; return s .replace(/^@/, "") .replace(/^[a-z]+:\/\//i, "") .replace(/[/?#].*$/, "") .replace(/\.$/, "") .toLowerCase(); } /** identity through slingshot: handle or DID in, {did, handle, pds} out. no appview in the path. */ export async function resolveIdentity(raw: string): Promise { const identifier = normalizeActor(raw); const r = await fetch(`https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`); if (r.status === 400) throw new Error(`${identifier} did not resolve (slingshot could not find it); check the spelling and try again`); if (!r.ok) await failFrom(r); return Parse(miniDocOutput, await r.json()); } export async function resolveHandle(handle: string): Promise { return (await resolveIdentity(handle)).did; } /** the PDS endpoint for a handle or DID, via public identity resolution. */ export async function resolvePds(identifier: string): Promise<{ did: string; pds: string }> { const did = identifier.startsWith("did:") ? identifier : await resolveHandle(identifier); const url = did.startsWith("did:web:") ? `https://${did.slice("did:web:".length)}/.well-known/did.json` : `https://plc.directory/${did}`; const r = await fetch(url, { headers: { accept: "application/json" } }); if (!r.ok) throw new Error(`${r.status} resolving ${did}`); const doc = Parse(didDocument, await r.json()); const pds = doc.service?.find((s) => s.id.endsWith("#atproto_pds"))?.serviceEndpoint; if (!pds) throw new Error(`no PDS in the DID document for ${did}`); return { did, pds: pds.replace(/\/+$/, "") }; } export async function login(pds: string, identifier: string, password: string): Promise { const r = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identifier, password }), }); if (!r.ok) await failFrom(r); return { ...Parse(sessionOutput, await r.json()), pds }; } export async function refresh(session: Session): Promise { const r = await fetch(`${session.pds}/xrpc/com.atproto.server.refreshSession`, { method: "POST", headers: { authorization: `Bearer ${session.refreshJwt}` }, }); if (!r.ok) await failFrom(r); return { ...Parse(sessionOutput, await r.json()), pds: session.pds }; } /** a response that means "your access token is no good": the reference PDS says 400 ExpiredToken * or InvalidToken, zds says 401 InvalidToken or AuthenticationRequired. one refresh, then retry. */ /** the JSON body, or an empty object when the host answered with nothing: the reference * PDS returns 200 and no body from putMember and deleteSpace, where zds returns `{}`. */ export async function bodyOf(r: Response, output: T): Promise> { const text = await r.text(); return Parse(output, text.trim() ? JSON.parse(text) : {}); } export function tokenRejected(status: number, code: string | undefined): boolean { if (status === 401) return true; return status === 400 && (code === "ExpiredToken" || code === "InvalidToken"); } /** bearer-authenticated XRPC over a session; refreshes once on a rejected token. */ export class SessionXrpc implements Xrpc { constructor( private session: Session, private readonly onRefresh: (session: Session) => Promise, ) {} get did(): string { return this.session.did; } get(nsid: string, params: Query, output: T): Promise> { return this.call(`${nsid}?${new URLSearchParams(params)}`, { method: "GET" }, output); } post(nsid: string, body: JsonObject, output: T): Promise> { return this.call( nsid, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, output, ); } private async call(path: string, init: RequestInit, output: T, retried = false): Promise> { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${this.session.accessJwt}`); const r = await fetch(`${this.session.pds}/xrpc/${path}`, { ...init, headers }); if (!retried && (r.status === 401 || r.status === 400)) { const body = Parse(xrpcError, await r.clone().json().catch(() => ({}))); if (tokenRejected(r.status, body.error)) { try { this.session = await refresh(this.session); } catch (error) { const why = error instanceof XrpcError ? error.code : "refresh failed"; throw new XrpcError(401, "SessionExpired", `session for ${this.session.handle} expired (${why})`); } await this.onRefresh(this.session); return this.call(path, init, output, true); } } if (!r.ok) await failFrom(r); return bodyOf(r, output); } }