import { habitatAuth } from '../auth/habitatAuth'; import { HABITAT_DOCS_COLLECTION, HABITAT_DOCS_EDIT_COLLECTION, } from './config'; import { ListRecordsResponse, TypedRecord, XRPCError } from './types'; /** * Typed XRPC over `habitatAuth.fetch`. We hand-roll a tiny subset of the * endpoints we actually call rather than pulling in the upstream `internal` * package or `@atproto/api` codegen. Endpoints are validated at the type * level via the params/output maps below. * * TODO: When we replace `habitatAuth.fetch` with a proper ATProto client * (existing TODO in `src/auth/habitatAuth.ts`), fold this layer into it. */ type QueryEndpoints = { 'network.habitat.repo.listRecords': { params: { collection: string; limit?: number; cursor?: string; subjects?: string[]; includePermissions?: boolean; }; output: ListRecordsResponse; }; 'network.habitat.repo.getRecord': { params: { collection: string; rkey: string; repo: string; includePermissions?: boolean; }; output: TypedRecord; }; 'network.habitat.clique.getMembers': { params: { clique: string }; output: { members?: string[] }; }; 'com.atproto.server.getServiceAuth': { params: { aud: string; lxm?: string; exp?: number }; output: { token: string }; }; }; type ProcedureEndpoints = { 'network.habitat.clique.createClique': { params: { members: string[] }; output: { clique: string }; }; 'network.habitat.repo.putRecord': { params: { repo: string; collection: string; rkey?: string; record: Record; grantees?: Array>; }; output: { uri: string; cid: string }; }; 'network.habitat.repo.deleteRecord': { params: { repo: string; collection: string; rkey: string }; output: Record; }; }; function appendParam( params: URLSearchParams, key: string, value: unknown, ): void { if (value === undefined || value === null) return; if (Array.isArray(value)) { for (const v of value) params.append(key, String(v)); return; } params.set(key, String(value)); } async function parseResponse( response: Response, endpoint: string, requestPath: string, ): Promise { // Read once as text so we can both attempt JSON parsing AND include the // raw body in error messages. JSON-error envelopes from the habitat server // are small; the extra string allocation is negligible. const text = await response.text(); let data: unknown; try { data = text ? JSON.parse(text) : undefined; } catch { if (response.ok) return undefined as T; throw new XRPCError( response.status, { message: response.statusText }, { endpoint, requestPath, rawBody: text }, ); } if (!response.ok) { const payload = data && typeof data === 'object' ? (data as { error?: string; message?: string }) : {}; throw new XRPCError(response.status, payload, { endpoint, requestPath, rawBody: text, }); } return data as T; } export async function query( endpoint: K, params: QueryEndpoints[K]['params'], ): Promise { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { appendParam(search, key, value); } const requestPath = `/xrpc/${endpoint}?${search.toString()}`; const response = await habitatAuth.fetch(requestPath, { method: 'GET' }); return parseResponse( response, endpoint, requestPath, ); } export async function procedure( endpoint: K, body: ProcedureEndpoints[K]['params'], ): Promise { const requestPath = `/xrpc/${endpoint}`; const response = await habitatAuth.fetch(requestPath, { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, }); return parseResponse( response, endpoint, requestPath, ); } /** Typed helpers for the most-used "private" record paths. */ export async function listPrivateRecords>( collection: string, options: { limit?: number; cursor?: string; subjects?: string[] } = {}, ): Promise> { const response = await query('network.habitat.repo.listRecords', { collection, limit: options.limit, cursor: options.cursor, subjects: options.subjects ?? [], }); return response as ListRecordsResponse; } export async function getPrivateRecord>( collection: string, rkey: string, repo: string, includePermissions = false, ): Promise> { const response = await query('network.habitat.repo.getRecord', { collection, rkey, repo, includePermissions, }); return response as TypedRecord; } /** Re-exported for callers that want to grab the canonical collection names. */ export { HABITAT_DOCS_COLLECTION, HABITAT_DOCS_EDIT_COLLECTION };