Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
10 kB · 281 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282// 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<string, { value: string; at: number }>()
export async function resolveHandleToDid(handle: string): Promise<string> { 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<string | null> { 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<string, { value: ResolvedDid; at: number }>()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 <img src> (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<string, { value: DidDoc; at: number }>()
/** * 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<DidDoc> { 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<string> { 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<ResolvedDid> { 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<T>( 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<T> { 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<T>( pds: string, did: string, collection: string, limit: number = LIST_LIMIT,): Promise<ListedRecord<T>[]> { const out: ListedRecord<T>[] = [] 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<T>[]; 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<string | undefined> { try { const { value } = await getRecord<ProfileRecord>(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<T>(url: string): Promise<T> { return requestJson<T>(url)}