diff --git a/README.md b/README.md index e69de29..52b5bdb 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,33 @@ +# lexdns + +`lexdns` is a tiny web app that resolves ATProto Lexicon schemas from NSIDs. + +1. Validate the requested NSID. +2. Convert its authority to a DNS name and query TXT over DNS over HTTPS. +3. Read a `did=...` TXT value from `_lexicon.`. +4. Resolve that DID document and find its `#atproto_pds` service. +5. Fetch the schema record from the publisher repo: + - collection: `com.atproto.lexicon.schema` + - record key: the full NSID + +For example, `app.bsky.feed.post` resolves through `_lexicon.feed.bsky.app`, then fetches: + +```text +at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.post +``` + +## Development + +```sh +pnpm install +pnpm dev +``` + +By default, the local Worker runs at `http://localhost:8787/`. + +Try a published lexicon: + +```sh +curl http://127.0.0.1:8787/api/resolve/app.bsky.feed.post +curl http://127.0.0.1:8787/api/resolve/com.atproto.repo.getRecord +``` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e5996ad --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +# TODO + +## API + +- `GET /api/resolve/{url_encoded_nsid}` +- Validate the decoded NSID before doing network work. +- Return JSON for all API responses. +- Return useful status codes: + - `200` for resolved Lexicon. + - `400` for invalid NSID. + - `404` when DNS discovery has no usable Lexicon publisher DID record. + - `502` when DNS, DID resolution, PDS discovery, or upstream Lexicon record fetch fails. + - `500` for unexpected server errors. + +## DNS Convention + +Use the ATProto Lexicon publication convention used by `goat` and Indigo. + +## Testing Checklist + +- [ ] Test API status codes for invalid NSID, missing DNS record, upstream failure, and success. + +## Deployment Checklist + +- [ ] Create production and preview KV namespaces. +- [ ] Add namespace IDs to `wrangler.jsonc`. +- [ ] Confirm no secrets are hardcoded. +- [ ] Deploy with Wrangler. +- [ ] Test the deployed `/` page. +- [ ] Test the deployed `/api/resolve/{nsid}` endpoint with a real published Lexicon, such as `app.bsky.feed.post`. diff --git a/src/http.ts b/src/http.ts index 8d3de99..10961f1 100644 --- a/src/http.ts +++ b/src/http.ts @@ -45,20 +45,16 @@ export async function lookupTxtRecords(endpoint: string, name: string): Promise< }); } -export function findLexiconUrl(records: TxtRecord[]): URL | null { +export function findLexiconDid(records: TxtRecord[]): string | null { for (const record of records) { const value = record.data.trim(); - if (!value.startsWith('lexicon=')) { + if (!value.startsWith('did=')) { continue; } - try { - const url = new URL(value.slice('lexicon='.length)); - if (url.protocol === 'https:') { - return url; - } - } catch { - continue; + const did = value.slice('did='.length); + if (/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(did)) { + return did; } } diff --git a/src/lexicon.ts b/src/lexicon.ts index b12f5d2..66289e0 100644 --- a/src/lexicon.ts +++ b/src/lexicon.ts @@ -1,11 +1,22 @@ -import { lookupTxtRecords, findLexiconUrl, DnsLookupError } from './http'; -import type { RuntimeEnv, ResolvedLexicon, LexiconDocument, ParsedNsid } from './types'; +import { lookupTxtRecords, findLexiconDid, DnsLookupError } from './http'; +import type { + RuntimeEnv, + ResolvedLexicon, + LexiconDocument, + ParsedNsid, + DidDocument, + RepoGetRecordResponse +} from './types'; + +const NSID_PATTERN = + /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/; + +const LEXICON_SCHEMA_COLLECTION = 'com.atproto.lexicon.schema'; export async function sha256Json(value: unknown): Promise { const bytes = new TextEncoder().encode(JSON.stringify(value)); const hash = await crypto.subtle.digest('SHA-256', bytes); const hex = [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); - return `sha256:${hex}`; } @@ -34,25 +45,25 @@ export async function resolveLexicon(nsid: string, env: RuntimeEnv): Promise { +async function fetchPublishedLexicon(did: string, nsid: string): Promise { + const didDocument = await fetchDidDocument(did); + const pdsEndpoint = findPdsEndpoint(didDocument); + if (pdsEndpoint === null) { + throw new ResolveError(502, 'upstream_error', `DID document has no ATProto PDS service: ${did}`); + } + + const url = new URL('/xrpc/com.atproto.repo.getRecord', pdsEndpoint); + url.searchParams.set('repo', did); + url.searchParams.set('collection', LEXICON_SCHEMA_COLLECTION); + url.searchParams.set('rkey', nsid); + let response: Response; try { response = await fetch(url, { headers: { accept: 'application/json' } }); @@ -97,16 +119,82 @@ async function fetchLexicon(url: URL, nsid: string): Promise { throw new ResolveError( 502, 'upstream_error', - `Lexicon fetch failed with ${response.status} ${response.statusText}` + `Lexicon record fetch failed with ${response.status} ${response.statusText}` ); } - const body = await response.json(); - if (!isLexiconDocument(body, nsid)) { - throw new ResolveError(502, 'upstream_error', 'Fetched document is not a matching Lexicon schema'); + const body = (await response.json()) as RepoGetRecordResponse; + if (!isLexiconDocument(body.value, nsid)) { + throw new ResolveError(502, 'upstream_error', 'Fetched lexicon record is not a matching Lexicon schema'); } - return body; + return body.value; +} + +async function fetchDidDocument(did: string): Promise { + const url = didDocumentUrl(did); + if (url === null) { + throw new ResolveError(502, 'upstream_error', `Unsupported DID method for lexicon publisher: ${did}`); + } + + let response: Response; + try { + response = await fetch(url, { headers: { accept: 'application/did+json, application/json' } }); + } catch (error) { + throw new ResolveError(502, 'upstream_error', `Failed to resolve lexicon publisher DID: ${errorMessage(error)}`); + } + + if (!response.ok) { + throw new ResolveError( + 502, + 'upstream_error', + `DID resolution failed with ${response.status} ${response.statusText}` + ); + } + + return (await response.json()) as DidDocument; +} + +function didDocumentUrl(did: string): URL | null { + if (did.startsWith('did:plc:')) { + return new URL(`https://plc.directory/${did}`); + } + + if (did.startsWith('did:web:')) { + const id = did.slice('did:web:'.length); + const parts = id.split(':').map((part) => decodeURIComponent(part)); + const host = parts.shift(); + if (host === undefined || host.length === 0) { + return null; + } + + return new URL(`https://${host}/${parts.length === 0 ? '.well-known' : parts.join('/')}/did.json`); + } + + return null; +} + +function findPdsEndpoint(didDocument: DidDocument): URL | null { + for (const service of didDocument.service ?? []) { + if (service.id !== '#atproto_pds' || service.type !== 'AtprotoPersonalDataServer') { + continue; + } + + if (service.serviceEndpoint === undefined) { + continue; + } + + try { + const url = new URL(service.serviceEndpoint); + if (url.protocol === 'https:') { + return url; + } + } catch { + continue; + } + } + + return null; } function cacheTtl(env: RuntimeEnv): number { @@ -134,11 +222,8 @@ export function mapResolveError(error: unknown): ResolveError { return new ResolveError(500, 'internal_error', errorMessage(error)); } -const nsidPattern = - /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/; - export function isValidNsid(nsid: string): boolean { - if (nsid.length > 317 || !nsidPattern.test(nsid)) { + if (nsid.length > 317 || !NSID_PATTERN.test(nsid)) { return false; } @@ -171,7 +256,7 @@ export function parseNsid(nsid: string): ParsedNsid | null { const authoritySegments = segments.slice(0, -1); const authority = authoritySegments.join('.'); const domain = [...authoritySegments].reverse().join('.').toLowerCase(); - const dnsName = `_lexicon.${name}.${domain}`; + const dnsName = `_lexicon.${domain}`; return { nsid, authority, domain, name, dnsName }; } diff --git a/src/types.ts b/src/types.ts index 235a3df..a40e0ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,3 +22,7 @@ export type ResolvedLexicon = { lexicon: LexiconDocument; cache: 'hit' | 'miss'; }; + +export type DidDocument = { service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }> }; + +export type RepoGetRecordResponse = { value?: unknown }; diff --git a/test/doh.test.ts b/test/doh.test.ts index 38f612e..62e1c1c 100644 --- a/test/doh.test.ts +++ b/test/doh.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { findLexiconUrl } from '../src/http'; +import { findLexiconDid } from '../src/http'; import type { TxtRecord } from '../src/types'; describe('DoH helpers', () => { - it('finds https lexicon URLs in TXT records', () => { + it('finds lexicon publisher DIDs in TXT records', () => { const records: TxtRecord[] = [ - { name: '_lexicon.foo.example.com', data: 'v=other' }, - { name: '_lexicon.foo.example.com', data: 'lexicon=https://example.com/lexicons/com.example.foo.json' } + { name: '_lexicon.example.com', data: 'v=other' }, + { name: '_lexicon.example.com', data: 'did=did:plc:example' } ]; - expect(findLexiconUrl(records)?.toString()).toBe('https://example.com/lexicons/com.example.foo.json'); + expect(findLexiconDid(records)).toBe('did:plc:example'); }); - it('ignores non-https lexicon URLs', () => { + it('ignores invalid DIDs', () => { const records: TxtRecord[] = [ - { name: '_lexicon.foo.example.com', data: 'lexicon=http://example.com/lexicons/com.example.foo.json' } + { name: '_lexicon.example.com', data: 'did=https://example.com/lexicons/com.example.foo.json' } ]; - expect(findLexiconUrl(records)).toBe(null); + expect(findLexiconDid(records)).toBe(null); }); }); diff --git a/test/lexicon.test.ts b/test/lexicon.test.ts index 69d1196..4c2c9be 100644 --- a/test/lexicon.test.ts +++ b/test/lexicon.test.ts @@ -4,6 +4,58 @@ import type { RuntimeEnv, ResolvedLexicon } from '../src/types'; const originalFetch = globalThis.fetch; +function resolvedLexicon(): ResolvedLexicon { + return { + nsid: 'com.example.foo', + hash: 'sha256:test', + fetchedAt: '2026-06-18T00:00:00.000Z', + source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' }, + lexicon: { lexicon: 1, id: 'com.example.foo', defs: {} }, + cache: 'miss' + }; +} + +function testEnv(storage = new Map()): RuntimeEnv { + const kv = { + async get(key: string, type?: 'json'): Promise { + const value = storage.get(key); + if (value === undefined) { + return null; + } + + return type === 'json' ? (JSON.parse(value) as T) : value; + }, + async put(key: string, value: string): Promise { + storage.set(key, value); + } + }; + + return { + LEXICONS: kv as KVNamespace, + CACHE_TTL_SECONDS: '86400', + DOH_ENDPOINT: 'https://cloudflare-dns.com/dns-query' + }; +} + +function mockFetch(response: Response) { + const fetchMock = vi.fn(async (..._args: Parameters): Promise => response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function mockFetchSequence(responses: Response[]) { + const fetchMock = vi.fn(async (..._args: Parameters): Promise => { + const response = responses.shift(); + if (response === undefined) { + throw new Error('No mocked response'); + } + + return response; + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); @@ -24,7 +76,7 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).resolves.toMatchObject({ nsid: 'com.example.foo', cache: 'hit', - source: { name: '_lexicon.foo.example.com', url: 'https://example.com/lexicons/com.example.foo.json' } + source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' } }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -36,8 +88,39 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).rejects.toMatchObject({ status: 404, code: 'not_found', - message: 'No lexicon TXT record found at _lexicon.foo.example.com' + message: 'No lexicon DID record found at _lexicon.example.com' + }); + }); + + it('fetches lexicon schema records from the resolved publisher DID', async () => { + const storage = new Map(); + const env = testEnv(storage); + const fetchMock = mockFetchSequence([ + Response.json({ + Status: 0, + Answer: [{ name: '_lexicon.feed.bsky.app', type: 16, data: '"did=did:plc:bsky"', TTL: 300 }] + }), + Response.json({ + id: 'did:plc:bsky', + service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }] + }), + Response.json({ value: { $type: 'com.atproto.lexicon.schema', lexicon: 1, id: 'app.bsky.feed.post', defs: {} } }) + ]); + + await expect(resolveLexicon('app.bsky.feed.post', env)).resolves.toMatchObject({ + nsid: 'app.bsky.feed.post', + cache: 'miss', + source: { + name: '_lexicon.feed.bsky.app', + url: 'at://did:plc:bsky/com.atproto.lexicon.schema/app.bsky.feed.post' + }, + lexicon: { lexicon: 1, id: 'app.bsky.feed.post', defs: {} } }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[2]?.[0]?.toString()).toBe( + 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Absky&collection=com.atproto.lexicon.schema&rkey=app.bsky.feed.post' + ); + expect(storage.has('lexicon:app.bsky.feed.post')).toBe(true); }); it('ignores old generated namespace cache entries', async () => { @@ -52,42 +135,3 @@ describe('Lexicon resolver', () => { expect(fetchMock).toHaveBeenCalledOnce(); }); }); - -function resolvedLexicon(): ResolvedLexicon { - return { - nsid: 'com.example.foo', - hash: 'sha256:test', - fetchedAt: '2026-06-18T00:00:00.000Z', - source: { name: '_lexicon.foo.example.com', url: 'https://example.com/lexicons/com.example.foo.json' }, - lexicon: { lexicon: 1, id: 'com.example.foo', defs: {} }, - cache: 'miss' - }; -} - -function testEnv(storage = new Map()): RuntimeEnv { - const kv = { - async get(key: string, type?: 'json'): Promise { - const value = storage.get(key); - if (value === undefined) { - return null; - } - - return type === 'json' ? (JSON.parse(value) as T) : value; - }, - async put(key: string, value: string): Promise { - storage.set(key, value); - } - }; - - return { - LEXICONS: kv as KVNamespace, - CACHE_TTL_SECONDS: '86400', - DOH_ENDPOINT: 'https://cloudflare-dns.com/dns-query' - }; -} - -function mockFetch(response: Response) { - const fetchMock = vi.fn(async () => response); - globalThis.fetch = fetchMock as typeof fetch; - return fetchMock; -} diff --git a/test/nsid.test.ts b/test/nsid.test.ts index fd69d54..5b7f7d5 100644 --- a/test/nsid.test.ts +++ b/test/nsid.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from 'vitest'; import { isValidNsid, parseNsid } from '../src/lexicon'; describe('NSID helpers', () => { + // TODO: turn into it.each it('validates NSIDs', () => { expect(isValidNsid('com.example.fooBar')).toBe(true); expect(isValidNsid('net.users.bob.ping')).toBe(true); expect(isValidNsid('a.b.c')).toBe(true); }); + // TODO: turn into it.each it('rejects invalid NSIDs', () => { expect(isValidNsid('com.example')).toBe(false); expect(isValidNsid('com.example.3')).toBe(false); @@ -15,13 +17,32 @@ describe('NSID helpers', () => { expect(isValidNsid('com.-example.foo')).toBe(false); }); + // TODO: turn into it.each it('maps NSIDs to lexicon DNS names', () => { expect(parseNsid('com.example.foo')).toEqual({ nsid: 'com.example.foo', authority: 'com.example', domain: 'example.com', name: 'foo', - dnsName: '_lexicon.foo.example.com' + dnsName: '_lexicon.example.com' + }); + expect(parseNsid('site.standard.publication')).toMatchObject({ + authority: 'site.standard', + domain: 'standard.site', + name: 'publication', + dnsName: '_lexicon.standard.site' + }); + expect(parseNsid('app.bsky.feed.post')).toMatchObject({ + authority: 'app.bsky.feed', + domain: 'feed.bsky.app', + name: 'post', + dnsName: '_lexicon.feed.bsky.app' + }); + expect(parseNsid('com.atproto.repo.getRecord')).toMatchObject({ + authority: 'com.atproto.repo', + domain: 'repo.atproto.com', + name: 'getRecord', + dnsName: '_lexicon.repo.atproto.com' }); }); });