diff --git a/packages/api/README.md b/packages/api/README.md index 646d538..97a6e93 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -67,6 +67,13 @@ Every 60 seconds, a Durable Object alarm (`Index.alarm`, `src/worker.ts`) runs o The first alarm is armed from the Durable Object's constructor (`blockConcurrencyWhile`), so the first request that wakes the object starts ingestion. +Relays, PDSes, PLC, and Slingshot are untrusted. Every call to them has a 10-second timeout +(`src/net.ts`). Listings stop on an empty or repeated cursor, and after 1000 relay pages or 100 +PDS pages per repo and collection. A repo that answers with an error or a malformed page is +skipped, and its rows are not pruned. A PDS endpoint must be a public `https:` URL +(`isPublicHttpsUrl`), and `did:web` documents are fetched from the path the DID names +(`src/identity.ts`). Backfilled records must name the repo and collection that were listed. + Every record is validated against its generated `@nibling/lexicons` schema at the boundary (`toPutEvent` in `src/index/jetstream.ts`, shared by the live tail and the backfill path); a record that fails validation is dropped, not indexed. `IndexModel.apply()` is idempotent — in diff --git a/packages/api/src/identity.ts b/packages/api/src/identity.ts new file mode 100644 index 0000000..8dc1613 --- /dev/null +++ b/packages/api/src/identity.ts @@ -0,0 +1,29 @@ +import { FETCH_TIMEOUT_MS, isPublicHttpsUrl } from './net.ts'; + +/** Where a DID's document lives: `plc.directory` for `did:plc:`, and for `did:web:` the host + * (with `%3A` for a port) plus any `:`-separated path, per the did:web spec. `undefined` for any + * other method or a host that isn't safe to fetch. */ +export function didDocumentUrl(did: string): string | undefined { + if (/^did:plc:[a-z2-7]{24}$/.test(did)) return `https://plc.directory/${did}`; + if (!did.startsWith('did:web:')) return undefined; + const [host, ...path] = did.slice('did:web:'.length).split(':'); + if (!host) return undefined; + const url = path.length > 0 + ? `https://${decodeURIComponent(host)}/${path.map(decodeURIComponent).join('/')}/did.json` + : `https://${decodeURIComponent(host)}/.well-known/did.json`; + return isPublicHttpsUrl(url) ? url : undefined; +} + +/** Resolves a DID to its PDS endpoint. Throws when the document can't be fetched or names no + * PDS, or names one that isn't a public `https:` URL. */ +export async function resolveDidPds(did: string, fetchFn: typeof fetch = fetch, timeoutMs = FETCH_TIMEOUT_MS): Promise { + const docUrl = didDocumentUrl(did); + if (!docUrl) throw new Error(`cannot resolve ${did}`); + const res = await fetchFn(docUrl, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) throw new Error(`DID document for ${did}: HTTP ${res.status}`); + const doc = (await res.json()) as { service?: unknown }; + const services = Array.isArray(doc.service) ? (doc.service as { type?: unknown; serviceEndpoint?: unknown }[]) : []; + const pds = services.find((s) => s?.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; + if (typeof pds !== 'string' || !isPublicHttpsUrl(pds)) throw new Error(`no usable PDS in DID document for ${did}`); + return pds; +} diff --git a/packages/api/src/index/backfill.ts b/packages/api/src/index/backfill.ts index 4172ea9..59f1087 100644 --- a/packages/api/src/index/backfill.ts +++ b/packages/api/src/index/backfill.ts @@ -1,5 +1,6 @@ import type { IndexCollection, IndexEvent } from './model.ts'; import { toPutEvent, WANTED_COLLECTIONS } from './jetstream.ts'; +import { FETCH_TIMEOUT_MS, isPublicHttpsUrl } from '../net.ts'; /** Injected rather than `globalThis.fetch` so tests run with no network. */ export interface BackfillDeps { @@ -7,8 +8,15 @@ export interface BackfillDeps { fetch: typeof fetch; /** Resolves a DID to its PDS base URL (normally via `plc.directory` or `did:web`). */ resolvePds(did: string): Promise; + /** Per-request timeout. Default `FETCH_TIMEOUT_MS`. */ + timeoutMs?: number; } +/** Page caps, so a server that never stops handing out cursors can't hold the alarm forever: + * 1000 relay pages of 1000 repos, and 100 PDS pages of 100 records per repo and collection. */ +export const MAX_REPO_PAGES = 1000; +export const MAX_RECORD_PAGES = 100; + /** What a backfill produces. `repo` names every record one repo's PDS still holds in a * collection, so the index can drop rows deleted while it wasn't listening. It is emitted only * after that repo's listing finished, never after a partial one. */ @@ -16,30 +24,49 @@ export type BackfillItem = | { kind: 'event'; event: IndexEvent } | { kind: 'repo'; collection: IndexCollection; did: string; uris: ReadonlySet }; -interface ListReposByCollectionResponse { - repos: { did: string }[]; - cursor?: string; +/** One page of a paged XRPC listing, or `undefined` if the request failed or the body isn't + * JSON. */ +async function getJson(url: URL, deps: BackfillDeps): Promise { + try { + const res = await deps.fetch(url.toString(), { signal: AbortSignal.timeout(deps.timeoutMs ?? FETCH_TIMEOUT_MS) }); + if (!res.ok) return undefined; + return await res.json(); + } catch { + return undefined; + } } -interface ListRecordsResponse { - records: { uri: string; cid: string; value: unknown }[]; - cursor?: string; +type Next = { kind: 'page'; cursor: string } | { kind: 'end' } | { kind: 'repeat' }; + +/** Where a listing goes next. A missing or empty cursor ends it; a cursor the server already + * handed out means it would loop forever. */ +function nextCursor(body: { cursor?: unknown }, seen: Set): Next { + const cursor = body.cursor; + if (typeof cursor !== 'string' || cursor === '') return { kind: 'end' }; + if (seen.has(cursor)) return { kind: 'repeat' }; + seen.add(cursor); + return { kind: 'page', cursor }; } -/** `com.atproto.sync.listReposByCollection` on the relay, paged to exhaustion. */ +/** `com.atproto.sync.listReposByCollection` on the relay, paged until it ends, repeats a + * cursor, or hits `MAX_REPO_PAGES`. */ async function* listReposByCollection(collection: string, deps: BackfillDeps): AsyncGenerator { + const seen = new Set(); let cursor: string | undefined; - do { + for (let page = 0; page < MAX_REPO_PAGES; page++) { const url = new URL('/xrpc/com.atproto.sync.listReposByCollection', deps.relayUrl); url.searchParams.set('collection', collection); url.searchParams.set('limit', '1000'); if (cursor) url.searchParams.set('cursor', cursor); - const res = await deps.fetch(url.toString()); - if (!res.ok) throw new Error(`listReposByCollection ${collection}: HTTP ${res.status}`); - const body = (await res.json()) as ListReposByCollectionResponse; - for (const { did } of body.repos) yield did; - cursor = body.cursor; - } while (cursor); + const body = (await getJson(url, deps)) as { repos?: unknown; cursor?: unknown } | undefined; + if (!body || !Array.isArray(body.repos)) throw new Error(`listReposByCollection ${collection}: bad response`); + for (const repo of body.repos as { did?: unknown }[]) { + if (typeof repo?.did === 'string' && repo.did.startsWith('did:')) yield repo.did; + } + const next = nextCursor(body, seen); + if (next.kind !== 'page') return; + cursor = next.cursor; + } } /** The record's URI rebuilt from the repo we asked for, or `undefined` when the PDS claims a @@ -54,9 +81,10 @@ export function ownedRecordUri(uri: unknown, did: string, collection: string): s return `at://${did}/${collection}/${rkey}`; } -/** `com.atproto.repo.listRecords` against one repo's own PDS, paged to exhaustion. A repo whose - * PDS can't be reached is skipped rather than failing the whole backfill, and gets no `repo` - * item, so none of its rows are pruned. */ +/** `com.atproto.repo.listRecords` against one repo's own PDS. A repo whose PDS can't be + * resolved, isn't a public `https:` URL, or answers with an error or a malformed page is skipped + * rather than failing the whole backfill. Only a listing that ended normally yields a `repo` + * item, so a skipped, looping, or capped repo has none of its rows pruned. */ async function* listRecords(did: string, collection: IndexCollection, deps: BackfillDeps): AsyncGenerator { let pds: string; try { @@ -64,32 +92,30 @@ async function* listRecords(did: string, collection: IndexCollection, deps: Back } catch { return; } + if (!isPublicHttpsUrl(pds)) return; const uris = new Set(); + const seen = new Set(); let cursor: string | undefined; - do { + for (let page = 0; page < MAX_RECORD_PAGES; page++) { const url = new URL('/xrpc/com.atproto.repo.listRecords', pds); url.searchParams.set('repo', did); url.searchParams.set('collection', collection); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); - let res: Response; - try { - res = await deps.fetch(url.toString()); - } catch { - return; - } - if (!res.ok) return; - const body = (await res.json()) as ListRecordsResponse; - for (const record of body.records) { - const uri = ownedRecordUri(record.uri, did, collection); - if (!uri) continue; + const body = (await getJson(url, deps)) as { records?: unknown; cursor?: unknown } | undefined; + if (!body || !Array.isArray(body.records)) return; + for (const record of body.records as { uri?: unknown; cid?: unknown; value?: unknown }[]) { + const uri = ownedRecordUri(record?.uri, did, collection); + if (!uri || typeof record.cid !== 'string') continue; uris.add(uri); const event = toPutEvent(collection, uri, record.cid, did, record.value); if (event) yield { kind: 'event', event }; } - cursor = body.cursor; - } while (cursor); - yield { kind: 'repo', collection, did, uris }; + const next = nextCursor(body, seen); + if (next.kind === 'end') yield { kind: 'repo', collection, did, uris }; + if (next.kind !== 'page') return; + cursor = next.cursor; + } } /** Rebuilds the index for one collection from the relay and each repo's PDS, used when the diff --git a/packages/api/src/net.ts b/packages/api/src/net.ts new file mode 100644 index 0000000..d34271b --- /dev/null +++ b/packages/api/src/net.ts @@ -0,0 +1,29 @@ +/** How long any one call to a relay, PDS, PLC, or Slingshot may take. */ +export const FETCH_TIMEOUT_MS = 10_000; + +const PRIVATE_SUFFIXES = ['.localhost', '.internal', '.local']; + +function isPrivateIPv4(host: string): boolean { + const parts = host.split('.'); + if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p))) return false; + const [a, b] = parts.map(Number) as [number, number]; + return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +function isPrivateIPv6(host: string): boolean { + if (!host.startsWith('[')) return false; + const addr = host.slice(1, -1); + return addr === '::1' || addr === '::' || addr.startsWith('fc') || addr.startsWith('fd') || addr.startsWith('fe80:') || addr.startsWith('::ffff:'); +} + +/** Whether a PDS endpoint (or any URL named by a DID document) is safe to fetch: `https:`, and + * not a host that obviously points inside a network. `URL` canonicalizes IPv4 spellings such as + * `2130706433` or `0x7f.1` to dotted decimal, so the checks below see the real address. */ +export function isPublicHttpsUrl(value: string): boolean { + if (!URL.canParse(value)) return false; + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password) return false; + const host = url.hostname.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost' || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false; + return !isPrivateIPv4(host) && !isPrivateIPv6(host); +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 1476317..7daeac9 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -1,6 +1,7 @@ import { renderEmbedPage, renderOEmbedJson, renderProjectPage, type ProjectCardData } from './cards.ts'; import type { RecentOptions, TrendingOptions } from './index/model.ts'; import { clientMetadata } from '@nibling/sdk/metadata'; +import { FETCH_TIMEOUT_MS, isPublicHttpsUrl } from './net.ts'; /** What the router needs from the Index DO, kept to just the RPC surface it calls. Isolating * this (rather than importing `cloudflare:workers` types here) is what lets this file run under @@ -42,26 +43,32 @@ function parseLimit(raw: string | null, fallback = 30, max = 100): number { return Math.min(n, max); } -/** `blue.microcosm.identity.resolveMiniDoc` via Slingshot: handle or DID in, `{did, pds}` out. */ +/** One Slingshot query, or `undefined` if it errors, times out, or isn't JSON. */ +async function slingshot(fetchFn: typeof fetch, slingshotUrl: string, nsid: string, params: Record): Promise | undefined> { + const url = new URL(`/xrpc/${nsid}`, slingshotUrl); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + try { + const res = await fetchFn(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return undefined; + const body: unknown = await res.json(); + return typeof body === 'object' && body !== null ? (body as Record) : undefined; + } catch { + return undefined; + } +} + +/** `blue.microcosm.identity.resolveMiniDoc`: handle or DID in, `{did, pds}` out. */ async function resolveMiniDoc(fetchFn: typeof fetch, slingshotUrl: string, actor: string): Promise<{ did: string; pds: string } | undefined> { - const url = new URL('/xrpc/blue.microcosm.identity.resolveMiniDoc', slingshotUrl); - url.searchParams.set('identifier', actor); - const res = await fetchFn(url.toString()); - if (!res.ok) return undefined; - const body = (await res.json()) as { did?: string; pds?: string }; - if (!body.did || !body.pds) return undefined; + const body = await slingshot(fetchFn, slingshotUrl, 'blue.microcosm.identity.resolveMiniDoc', { identifier: actor }); + if (typeof body?.did !== 'string' || typeof body.pds !== 'string') return undefined; return { did: body.did, pds: body.pds }; } -/** `blue.microcosm.repo.getRecordByUri` via Slingshot. */ +/** `blue.microcosm.repo.getRecordByUri`. */ async function getRecordByUri(fetchFn: typeof fetch, slingshotUrl: string, uri: string): Promise<{ cid: string; value: Record } | undefined> { - const url = new URL('/xrpc/blue.microcosm.repo.getRecordByUri', slingshotUrl); - url.searchParams.set('at_uri', uri); - const res = await fetchFn(url.toString()); - if (!res.ok) return undefined; - const body = (await res.json()) as { cid?: string; value?: Record }; - if (!body.cid || !body.value) return undefined; - return { cid: body.cid, value: body.value }; + const body = await slingshot(fetchFn, slingshotUrl, 'blue.microcosm.repo.getRecordByUri', { at_uri: uri }); + if (typeof body?.cid !== 'string' || typeof body.value !== 'object' || body.value === null) return undefined; + return { cid: body.cid, value: body.value as Record }; } async function loadProjectCard(env: RouterEnv, actor: string, rkey: string): Promise { @@ -75,7 +82,7 @@ async function loadProjectCard(env: RouterEnv, actor: string, rkey: string): Pro const name = typeof value.name === 'string' ? value.name : rkey; const description = typeof value.description === 'string' ? value.description : undefined; const thumbnail = value.thumbnail as { ref?: { $link?: string } } | undefined; - const thumbnailUrl = thumbnail?.ref?.$link + const thumbnailUrl = thumbnail?.ref?.$link && isPublicHttpsUrl(identity.pds) ? `${identity.pds}/xrpc/com.atproto.sync.getBlob?did=${identity.did}&cid=${thumbnail.ref.$link}` : undefined; const card: ProjectCardData = { actor, did: identity.did, rkey, name }; diff --git a/packages/api/src/worker.ts b/packages/api/src/worker.ts index 8978fb3..c191608 100644 --- a/packages/api/src/worker.ts +++ b/packages/api/src/worker.ts @@ -3,6 +3,7 @@ import { ensureAlarm, runAndRearm } from './alarm.ts'; import { ingest, type WebSocketLike } from './index/ingest.ts'; import { IndexModel, type RecentOptions, type TrendingOptions } from './index/model.ts'; import { handleRequest } from './router.ts'; +import { resolveDidPds } from './identity.ts'; export interface Env { INDEX: DurableObjectNamespace; @@ -65,19 +66,9 @@ export class Index extends DurableObject { backfill: { relayUrl: this.env.RELAY_URL, fetch, - resolvePds: async (did) => resolveDidPds(did), + resolvePds: (did) => resolveDidPds(did), }, now: () => Date.now(), }); } } - -/** Resolves a DID's PDS endpoint via `plc.directory` (for `did:plc:`) or the `did:web` document. */ -async function resolveDidPds(did: string): Promise { - const doc: { service?: { id: string; type: string; serviceEndpoint: string }[] } = did.startsWith('did:web:') - ? await (await fetch(`https://${decodeURIComponent(did.slice('did:web:'.length))}/.well-known/did.json`)).json() - : await (await fetch(`https://plc.directory/${did}`)).json(); - const pds = doc.service?.find((s) => s.type === 'AtprotoPersonalDataServer'); - if (!pds) throw new Error(`no PDS service in DID document for ${did}`); - return pds.serviceEndpoint; -} diff --git a/packages/api/tests/backfill.test.ts b/packages/api/tests/backfill.test.ts index ce2c277..ae0e0b1 100644 --- a/packages/api/tests/backfill.test.ts +++ b/packages/api/tests/backfill.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { backfillCollection, type BackfillDeps, type BackfillItem } from '../src/index/backfill.ts'; +import { backfillCollection, MAX_RECORD_PAGES, type BackfillDeps, type BackfillItem } from '../src/index/backfill.ts'; async function collect(items: AsyncIterable): Promise { const out: BackfillItem[] = []; @@ -111,3 +111,98 @@ test('backfillCollection drops records whose URI names another repo or collectio [{ uri: 'at://did:plc:evil/run.nibling.project/3own', did: 'did:plc:evil' }, 'repo'], ); }); + +function relayThen(listRecords: (url: URL, init?: RequestInit) => Response | Promise, dids = ['did:plc:alice']): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === 'string' ? input : input.toString()); + if (url.pathname === '/xrpc/com.atproto.sync.listReposByCollection') return jsonResponse({ repos: dids.map((did) => ({ did })) }); + return listRecords(url, init); + }) as typeof fetch; +} + +const aliceRecord = (rkey: string) => ({ + uri: `at://did:plc:alice/run.nibling.project/${rkey}`, + cid: `bafy-${rkey}`, + value: { $type: 'run.nibling.project', name: rkey, files: [], createdAt: '2026-11-01T00:00:00.000Z' }, +}); + +test('a PDS that repeats a cursor stops the listing, and the repo is not pruned', async () => { + let calls = 0; + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: relayThen(() => { + calls++; + return jsonResponse({ records: [aliceRecord('3la1')], cursor: 'same' }); + }), + resolvePds: async () => 'https://alice.pds.example', + }; + const items = await collect(backfillCollection('run.nibling.project', deps)); + assert.equal(calls, 2); + assert.deepStrictEqual(items.map((item) => item.kind), ['event', 'event']); +}); + +test('a PDS that never stops paging is cut off at MAX_RECORD_PAGES', async () => { + let calls = 0; + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: relayThen(() => jsonResponse({ records: [], cursor: `page${++calls}` })), + resolvePds: async () => 'https://alice.pds.example', + }; + const items = await collect(backfillCollection('run.nibling.project', deps)); + assert.equal(calls, MAX_RECORD_PAGES); + assert.deepStrictEqual(items, []); +}); + +test('a relay that repeats a cursor stops listing repos', async () => { + let calls = 0; + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: (async () => { + calls++; + return jsonResponse({ repos: [], cursor: 'same' }); + }) as typeof fetch, + resolvePds: async () => 'https://alice.pds.example', + }; + assert.deepStrictEqual(await collect(backfillCollection('run.nibling.project', deps)), []); + assert.equal(calls, 2); +}); + +test('a repo answering bad JSON or a non-array is skipped, and the next repo is still listed', async () => { + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: relayThen((url) => { + const repo = url.searchParams.get('repo'); + if (repo === 'did:plc:broken') return new Response('oops'); + if (repo === 'did:plc:weird') return jsonResponse({ records: 'nope' }); + return jsonResponse({ records: [aliceRecord('3la1')] }); + }, ['did:plc:broken', 'did:plc:weird', 'did:plc:alice']), + resolvePds: async () => 'https://pds.example', + }; + const items = await collect(backfillCollection('run.nibling.project', deps)); + assert.deepStrictEqual(eventUris(items), ['at://did:plc:alice/run.nibling.project/3la1']); + assert.deepStrictEqual(items.flatMap((item) => (item.kind === 'repo' ? [item.did] : [])), ['did:plc:alice']); +}); + +test('a PDS that hangs is abandoned after the timeout', async () => { + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: relayThen((_url, init) => new Promise((_, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal!.reason)))), + resolvePds: async () => 'https://alice.pds.example', + timeoutMs: 20, + }; + assert.deepStrictEqual(await collect(backfillCollection('run.nibling.project', deps)), []); +}); + +test('a repo whose PDS is not a public https URL is never fetched', async () => { + const fetched: string[] = []; + const deps: BackfillDeps = { + relayUrl: 'https://relay.example.com', + fetch: relayThen((url) => { + fetched.push(url.origin); + return jsonResponse({ records: [aliceRecord('3la1')] }); + }), + resolvePds: async () => 'http://169.254.169.254', + }; + assert.deepStrictEqual(await collect(backfillCollection('run.nibling.project', deps)), []); + assert.deepStrictEqual(fetched, []); +}); diff --git a/packages/api/tests/net.test.ts b/packages/api/tests/net.test.ts new file mode 100644 index 0000000..0b34b9f --- /dev/null +++ b/packages/api/tests/net.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { isPublicHttpsUrl } from '../src/net.ts'; +import { didDocumentUrl, resolveDidPds } from '../src/identity.ts'; + +const ENDPOINTS: [string, boolean][] = [ + ['https://puffball.us-east.host.bsky.network', true], + ['https://pds.example.com:8443/', true], + ['https://172.32.0.1', true], + ['http://pds.example.com', false], + ['ftp://pds.example.com', false], + ['not a url', false], + ['https://user:pass@pds.example.com', false], + ['https://localhost', false], + ['https://LOCALHOST.', false], + ['https://api.localhost', false], + ['https://metadata.google.internal', false], + ['https://printer.local', false], + ['https://127.0.0.1', false], + ['https://127.255.0.9', false], + ['https://2130706433', false], + ['https://0x7f.1', false], + ['https://0.0.0.0', false], + ['https://10.1.2.3', false], + ['https://172.16.0.1', false], + ['https://172.31.255.255', false], + ['https://192.168.1.1', false], + ['https://169.254.169.254', false], + ['https://[::1]', false], + ['https://[fd00::1]', false], + ['https://[fe80::1]', false], + ['https://[::ffff:127.0.0.1]', false], + ['https://[2606:4700::1111]', true], +]; + +for (const [endpoint, expected] of ENDPOINTS) { + test(`isPublicHttpsUrl(${JSON.stringify(endpoint)}) is ${expected}`, () => { + assert.equal(isPublicHttpsUrl(endpoint), expected); + }); +} + +const DID_DOCUMENTS: [string, string | undefined][] = [ + ['did:plc:z72i7hdynmk6r22z27h6tvur', 'https://plc.directory/did:plc:z72i7hdynmk6r22z27h6tvur'], + ['did:plc:../../etc', undefined], + ['did:web:example.com', 'https://example.com/.well-known/did.json'], + ['did:web:example.com:user:alice', 'https://example.com/user/alice/did.json'], + ['did:web:example.com%3A8443', 'https://example.com:8443/.well-known/did.json'], + ['did:web:localhost%3A8080', undefined], + ['did:web:10.0.0.1', undefined], + ['did:key:z6Mk', undefined], +]; + +for (const [did, expected] of DID_DOCUMENTS) { + test(`didDocumentUrl(${did}) is ${expected}`, () => { + assert.equal(didDocumentUrl(did), expected); + }); +} + +test('resolveDidPds refuses a DID document that names a private PDS', async () => { + const fakeFetch = (async () => + Response.json({ service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'http://169.254.169.254' }] })) as typeof fetch; + await assert.rejects(resolveDidPds('did:web:example.com', fakeFetch), /no usable PDS/); +}); + +test('resolveDidPds returns a public https PDS', async () => { + const fakeFetch = (async () => + Response.json({ service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }] })) as typeof fetch; + assert.equal(await resolveDidPds('did:web:example.com', fakeFetch), 'https://pds.example.com'); +});