// Unauthenticated atproto plumbing: at-uri parsing, DID -> PDS resolution, // public record fetches. Authenticated writes live in oauth.ts / background.ts. import { requestJson } from './http' import type { AtUri, BlobRef } from './types' export function parseAtUri(uri: string): AtUri | null { const m = /^at:\/\/(did:[a-z0-9]+:[^/]+)\/([^/]+)\/([^/?#]+)$/.exec(uri.trim()) if (!m) return null return { did: m[1]!, collection: m[2]!, rkey: m[3]! } } export function buildAtUri(did: string, collection: string, rkey: string): string { return `at://${did}/${collection}/${rkey}` } /** * bsky.app profile URL for a handle or DID (bsky.app accepts either). * Tolerates a leading "@" on stored handles. * * The handle comes from a DID document's alsoKnownAs, which its owner writes, * so it is escaped: unencoded it could add path segments, a query or a * fragment to the URL. ":" is put back because it is legal inside a path * segment and DIDs are full of it. */ export function bskyProfileUrl(handleOrDid: string): string { const bare = encodeURIComponent(handleOrDid.replace(/^@/, '')).replace(/%3A/g, ':') return `https://bsky.app/profile/${bare}` } /** * Handle -> DID, cached with the same TTL as the DID documents below. A handle * can be moved to a different account, so this must expire; it used to be kept * for the life of the worker, which meant a rebind was invisible until the * worker happened to restart. */ const handleCache = new Map() export async function resolveHandleToDid(handle: string): Promise { const cached = handleCache.get(handle) if (cached && Date.now() - cached.at < DID_TTL) return cached.value const u = new URL('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle') u.searchParams.set('handle', handle) const { did } = await fetchJson<{ did: string }>(u.toString()) handleCache.set(handle, { value: did, at: Date.now() }) return did } /** * At-uris in the wild may use a handle as authority (e.g. * at://example.com/site.standard.publication/self). Normalize to DID form; * returns null if the uri is malformed or the handle doesn't resolve. */ export async function normalizeAtUri(uri: string): Promise { const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/?#]+)$/.exec(uri.trim()) if (!m) return null let authority = m[1]! if (!authority.startsWith('did:')) { try { authority = await resolveHandleToDid(authority) } catch { return null } } return buildAtUri(authority, m[2]!, m[3]!) } interface DidDoc { alsoKnownAs?: string[] service?: { id: string; type: string; serviceEndpoint: string }[] } export interface ResolvedDid { pds: string handle?: string } const didCache = new Map() const DID_TTL = 60 * 60 * 1000 /** * The host part of a did:web, which is a hostname with an optional port whose * colon is percent-encoded. Anything else — a "/" or "@" smuggled in as %2F or * %40, an extra ":" path segment we do not implement — is rejected rather than * interpolated into the did.json URL, where it would move the fetch to a host * the DID does not name. */ const DID_WEB_HOST = /^[a-z0-9-]+(\.[a-z0-9-]+)*(:\d{1,5})?$/i function didWebHost(did: string): string { let host: string try { host = decodeURIComponent(did.slice('did:web:'.length)) } catch { throw new Error(`Malformed did:web identifier: ${did}`) } if (!DID_WEB_HOST.test(host)) throw new Error(`Unsupported did:web host: ${did}`) return host } /** * A DID document is published by whoever controls the DID, so its * serviceEndpoint is third-party input on its way to fetch() (getRecord, * listRecords) and to (blobUrl). Require https: — the extension * holds host permissions for cleartext http, so a downgraded endpoint would * otherwise just work — and keep only the origin and path, dropping * credentials, query and fragment that the callers' URL building mangles. */ function pdsEndpoint(did: string, serviceEndpoint: string, kind = 'PDS'): string { let u: URL try { u = new URL(serviceEndpoint) } catch { throw new Error(`Malformed ${kind} endpoint for ${did}: ${serviceEndpoint}`) } if (u.protocol !== 'https:') { throw new Error(`Refusing non-https ${kind} endpoint for ${did}: ${serviceEndpoint}`) } return (u.origin + u.pathname).replace(/\/$/, '') } const docCache = new Map() /** * A DID's document, cached with the same TTL as the resolutions built from * it. Shared so that asking the same account for two different services — * its PDS and its labeler endpoint — is one fetch, not two. */ async function didDocument(did: string): Promise { const cached = docCache.get(did) if (cached && Date.now() - cached.at < DID_TTL) return cached.value let doc: DidDoc if (did.startsWith('did:plc:')) { doc = await fetchJson(`https://plc.directory/${did}`) } else if (did.startsWith('did:web:')) { doc = await fetchJson(`https://${didWebHost(did)}/.well-known/did.json`) } else { throw new Error(`Unsupported DID method: ${did}`) } docCache.set(did, { value: doc, at: Date.now() }) return doc } /** A named service from the document, by the fragment atproto identifies it by. */ function service(doc: DidDoc, id: string): string | undefined { return doc.service?.find((s) => s.id === `#${id}` || s.id.endsWith(`#${id}`))?.serviceEndpoint } /** * A labeler's own query endpoint. It is a second service on the same account * as the PDS, and an account that is not a labeler simply does not name one. */ export async function resolveLabelerEndpoint(did: string): Promise { const endpoint = service(await didDocument(did), 'atproto_labeler') if (!endpoint) throw new Error(`No labeler service in DID document for ${did}`) return pdsEndpoint(did, endpoint, 'labeler') } /** Resolve a DID to its PDS endpoint and (unverified) handle. */ export async function resolveDid(did: string): Promise { const cached = didCache.get(did) if (cached && Date.now() - cached.at < DID_TTL) return cached.value const doc = await didDocument(did) const endpoint = service(doc, 'atproto_pds') if (!endpoint) throw new Error(`No PDS service in DID document for ${did}`) let pds: string try { pds = pdsEndpoint(did, endpoint) } catch (err) { // Callers swallow resolution failures (avatars, detection), so say why here. console.debug('[substandard] rejected PDS endpoint', did, endpoint) throw err } const aka = doc.alsoKnownAs?.find((a) => a.startsWith('at://')) const value: ResolvedDid = { pds, handle: aka?.slice('at://'.length) } didCache.set(did, { value, at: Date.now() }) return value } /** Fetch a record from its owner's PDS (public, unauthenticated). */ export async function getRecord( pds: string, did: string, collection: string, rkey: string, ): Promise<{ uri: string; cid: string; value: T }> { const u = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`) u.searchParams.set('repo', did) u.searchParams.set('collection', collection) u.searchParams.set('rkey', rkey) return fetchJson(u.toString()) } export interface ListedRecord { uri: string cid: string value: T } /** * Where a walk stops when the caller does not say. A backstop against a repo * large enough to page forever, not a considered limit for any one collection: * a caller that knows how big its collection gets, and what it costs to stop * short, passes its own. */ export const LIST_LIMIT = 2000 /** * List every record in a collection (public, paginated), up to `limit`. * * A result of exactly `limit` may or may not be the whole collection — the * walk stops without asking whether there was more. Callers that care read * `records.length >= limit` as "possibly incomplete", which is wrong only for * a collection whose size is exactly the cap. */ export async function listRecords( pds: string, did: string, collection: string, limit: number = LIST_LIMIT, ): Promise[]> { const out: ListedRecord[] = [] let cursor: string | undefined do { const u = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`) u.searchParams.set('repo', did) u.searchParams.set('collection', collection) u.searchParams.set('limit', '100') if (cursor) u.searchParams.set('cursor', cursor) const page: { records: ListedRecord[]; cursor?: string } = await fetchJson( u.toString(), ) out.push(...page.records) cursor = page.cursor } while (cursor && out.length < limit) return out } /** app.bsky.actor.profile record value — only the field we render. */ interface ProfileRecord { avatar?: BlobRef } /** * The account's avatar as a blob URL on its own PDS. Undefined when there is * no profile record or no avatar; never throws — avatars are cosmetic. */ export async function profileAvatarUrl(pds: string, did: string): Promise { try { const { value } = await getRecord(pds, did, 'app.bsky.actor.profile', 'self') return isBlobRef(value.avatar) ? blobUrl(pds, did, value.avatar) : undefined } catch (err) { console.debug('[substandard] no avatar for', did, err) return undefined } } /** * Whether a record field really is a blob reference. Record values are * third-party data and a field typed as a blob in the lexicon can hold * anything — a publication with a plain path string for its `icon` must cost * a missing icon, not a thrown detection. */ export function isBlobRef(value: unknown): value is BlobRef { return typeof (value as BlobRef | undefined)?.ref?.$link === 'string' } export function blobUrl(pds: string, did: string, blob: BlobRef): string { const u = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`) u.searchParams.set('did', did) u.searchParams.set('cid', blob.ref.$link) return u.toString() } function fetchJson(url: string): Promise { return requestJson(url) }