import { Type, type Static, type TSchema } from "typebox"; import { Parse } from "typebox/value"; import { P256Keypair } from "@atproto/crypto"; import { spaceSignatureHeaders } from "./space-signature.ts"; import { type Query, type Xrpc, XrpcError } from "./xrpc.ts"; export type SpaceRef = { authority: string; type: string; skey: string }; export function spaceUri(ref: SpaceRef): string { return `at://${ref.authority}/space/${ref.type}/${ref.skey}`; } export function parseSpaceUri(uri: string): SpaceRef | null { const m = uri.match(/^at:\/\/(did:[^/]+)\/space\/([^/]+)\/([^/]+)/); return m && m[1] && m[2] && m[3] ? { authority: m[1], type: m[2], skey: m[3] } : null; } const didDocument = Type.Object({ alsoKnownAs: Type.Optional(Type.Array(Type.String())), service: Type.Optional(Type.Array(Type.Object({ id: Type.String(), serviceEndpoint: Type.String() }))), }); type DidDocument = Static; const documents = new Map>(); export function didDocumentFor(did: string): Promise { let p = documents.get(did); if (!p) { p = (async () => { 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}`); return Parse(didDocument, await r.json()); })().catch((error) => { documents.delete(did); throw error; }); documents.set(did, p); } return p; } function endpoint(doc: DidDocument, did: string, fragment: string): string | null { const url = doc.service?.find((s) => s.id === fragment || s.id === `${did}${fragment}`)?.serviceEndpoint; return url ? url.replace(/\/+$/, "") : null; } export async function pdsFor(did: string): Promise { const pds = endpoint(await didDocumentFor(did), did, "#atproto_pds"); if (!pds) throw new Error(`no PDS for ${did}`); return pds; } /** the authority's space host: its #atproto_space_host if published, else its PDS */ export async function spaceHostFor(did: string): Promise { const doc = await didDocumentFor(did); return endpoint(doc, did, "#atproto_space_host") ?? endpoint(doc, did, "#atproto_pds") ?? Promise.reject(new Error(`no host for ${did}`)); } export async function handleFor(did: string): Promise { try { return (await didDocumentFor(did)).alsoKnownAs?.[0]?.replace(/^at:\/\//, "") ?? did; } catch { return did; } } const xrpcError = Type.Object({ error: Type.Optional(Type.String()), message: Type.Optional(Type.String()) }); export class SpaceCredential { constructor( readonly space: string, readonly token: string, readonly key: P256Keypair, readonly expiresAt: number, ) {} get fresh(): boolean { return Date.now() < this.expiresAt - 60_000; } async get(host: string, audience: string, nsid: string, params: Query, output: T): Promise> { const url = `${host}/xrpc/${nsid}?${new URLSearchParams(params)}`; const r = await fetch(url, { headers: await spaceSignatureHeaders(this.key, `Atproto-Space ${this.token}`, audience), }); if (!r.ok) { let code = `HTTP${r.status}`; let message = r.statusText; try { const body = Parse(xrpcError, await r.json()); code = body.error ?? code; message = body.message ?? message; } catch { /* not an xrpc error body */ } throw new XrpcError(r.status, code, message); } return Parse(output, await r.json()); } } function jwtExpiry(token: string): number { const payload = Parse(Type.Object({ exp: Type.Number({ minimum: 1 }) }), JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString())); return payload.exp * 1000; } const delegationOutput = Type.Object({ token: Type.String() }); const credentialOutput = Type.Object({ credential: Type.String() }); /** mint a credential for `space`: my pds delegates, the authority's host issues. */ export async function mintCredential(xrpc: Xrpc, space: string): Promise { const ref = parseSpaceUri(space); if (!ref) throw new Error(`not a space uri: ${space}`); let delegation: string; try { ({ token: delegation } = await xrpc.get("com.atproto.space.getDelegationToken", { space }, delegationOutput)); } catch (error) { // which side refused decides the diagnosis: this is the member's own server if (error instanceof XrpcError) throw new XrpcError(error.status, error.code, `your PDS refused to delegate: ${error.message} (${error.code})`); throw error; } const host = await spaceHostFor(ref.authority); const key = await P256Keypair.create(); const url = `${host}/xrpc/com.atproto.space.getSpaceCredential`; const r = await fetch(url, { method: "POST", headers: { ...await spaceSignatureHeaders(key, `Bearer ${delegation}`), "content-type": "application/json" }, body: JSON.stringify({ space }), }); if (!r.ok) { let code = `HTTP${r.status}`; let message = r.statusText; try { const body = Parse(xrpcError, await r.json()); code = body.error ?? code; message = body.message ?? message; } catch { /* not an xrpc error body */ } throw new XrpcError(r.status, code, `the space host refused: ${message} (${code})`); } const { credential } = Parse(credentialOutput, await r.json()); return new SpaceCredential(space, credential, key, jwtExpiry(credential)); } const listReposOutput = Type.Object({ cursor: Type.Optional(Type.String()), repos: Type.Array(Type.Object({ did: Type.String() })) }); const listRecordsOutput = Type.Object({ cursor: Type.Optional(Type.String()), records: Type.Array(Type.Object({ collection: Type.String(), rkey: Type.String(), cid: Type.String(), value: Type.Optional(Type.Unknown()) })), }); export type SpaceRecordView = Static["records"][number]; /** what a store needs from the credentialed side; the tests fake it. */ export interface SpaceReader { repos(space: string): Promise; records(space: string, repo: string, collection: string): Promise; } /** credentials held in lore only and re-minted when stale; one mint in flight per space. */ export class CredentialReader implements SpaceReader { private readonly credentials = new Map>(); constructor(private readonly xrpc: Xrpc) {} async credential(space: string): Promise { const cached = this.credentials.get(space); if (cached) { const credential = await cached; if (credential.fresh) return credential; if (this.credentials.get(space) === cached) this.credentials.delete(space); return this.credential(space); } return this.remint(space); } private remint(space: string): Promise { const p = mintCredential(this.xrpc, space).catch((error) => { this.credentials.delete(space); throw error; }); this.credentials.set(space, p); return p; } private async get(space: string, host: string, audience: string, nsid: string, params: Query, output: T): Promise> { const credential = await this.credential(space); try { return await credential.get(host, audience, nsid, params, output); } catch (error) { if (!(error instanceof XrpcError) || error.status !== 401) throw error; if (await this.credentials.get(space) === credential) this.credentials.delete(space); return (await this.credential(space)).get(host, audience, nsid, params, output); } } async repos(space: string): Promise { const ref = parseSpaceUri(space); if (!ref) throw new Error(`not a space uri: ${space}`); const host = await spaceHostFor(ref.authority); const dids: string[] = []; let cursor: string | undefined; do { const params: Query = { space, limit: "1000" }; if (cursor) params.cursor = cursor; const page = await this.get(space, host, ref.authority, "com.atproto.space.listRepos", params, listReposOutput); dids.push(...page.repos.map((r) => r.did)); if (page.cursor && page.cursor === cursor) throw new Error("writer cursor did not advance"); cursor = page.cursor; } while (cursor); return [...new Set(dids)]; } async records(space: string, repo: string, collection: string): Promise { const pds = await pdsFor(repo); const out: SpaceRecordView[] = []; let cursor: string | undefined; do { const params: Query = { space, repo, collection, limit: "100" }; if (cursor) params.cursor = cursor; const page = await this.get(space, pds, repo, "com.atproto.space.listRecords", params, listRecordsOutput); out.push(...page.records); if (page.cursor && page.cursor === cursor) throw new Error("record cursor did not advance"); cursor = page.cursor; } while (cursor); return out; } }