diff --git a/README.md b/README.md index 636c7e5..825c17e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,64 @@ curl https://lex.desertthunder.dev/api/resolve/app.bsky.feed.post # For just the schema: curl https://lex.desertthunder.dev/api/resolve/com.atproto.repo.getRecord | jq '.lexicon' + +# Resolve an AT URI or HTTPS getRecord URL: +curl "https://lex.desertthunder.dev/api/uri/at%3A%2F%2Fdid%3Aplc%3Aexample%2Fcom.atproto.lexicon.schema%2Fcom.example.foo" +curl "https://lex.desertthunder.dev/api/uri?uri=https%3A%2F%2Fpds.example.com%2Fxrpc%2Fcom.atproto.repo.getRecord%3Fcollection%3Dcom.atproto.lexicon.schema%26rkey%3Dcom.example.foo" +``` + +## API + +`GET /api/resolve/{nsid}` resolves a direct Lexicon NSID. + +`GET /api/uri/{aturi}` resolves an AT URI or HTTPS URL that points at a `com.atproto.lexicon.schema` record. URL-encode inputs that contain `/`, `?`, or `&`. The same input can also be passed as `GET /api/uri?uri=...`. + +Successful responses include: + +```json +{ + "version": 1, + "nsid": "app.bsky.feed.post", + "parsed": { + "nsid": "app.bsky.feed.post", + "authority": "app.bsky.feed", + "domain": "feed.bsky.app", + "name": "post", + "dnsName": "_lexicon.feed.bsky.app" + }, + "source": { + "name": "_lexicon.feed.bsky.app", + "url": "at://did:plc:.../com.atproto.lexicon.schema/app.bsky.feed.post" + }, + "trace": { + "dnsName": "_lexicon.feed.bsky.app", + "txtRecords": [], + "selectedDid": "did:plc:...", + "didDocument": { "url": "https://plc.directory/did:plc:...", "status": 200 }, + "pdsEndpoint": "https://pds.example.com/", + "repoGetRecord": { "url": "https://pds.example.com/xrpc/com.atproto.repo.getRecord?...", "status": 200 }, + "final": { "success": true, "message": "Resolved lexicon schema" } + }, + "lexicon": { "lexicon": 1, "id": "app.bsky.feed.post", "defs": {} } +} +``` + +Error responses keep the stable error shape and include the trace collected before failure: + +```json +{ + "version": 1, + "error": { "code": "not_found", "message": "No lexicon DID record found at _lexicon.example.com" }, + "trace": { + "dnsName": "_lexicon.example.com", + "txtRecords": [], + "selectedDid": null, + "didDocument": { "url": null, "status": null }, + "pdsEndpoint": null, + "repoGetRecord": { "url": null, "status": null }, + "final": { "success": false, "code": "not_found", "message": "No lexicon DID record found at _lexicon.example.com" } + } +} ``` ### How it Works diff --git a/src/lexicon.ts b/src/lexicon.ts index b4a1460..272e239 100644 --- a/src/lexicon.ts +++ b/src/lexicon.ts @@ -5,7 +5,9 @@ import type { LexiconDocument, ParsedNsid, DidDocument, - RepoGetRecordResponse + RepoGetRecordResponse, + ResolutionTrace, + ApiErrorCode } from './types'; const NSID_PATTERN = @@ -25,50 +27,67 @@ export class ResolveError extends Error { constructor( readonly status: number, readonly code: 'bad_request' | 'not_found' | 'upstream_error' | 'internal_error', - message: string + message: string, + readonly trace?: ResolutionTrace ) { super(message); this.name = 'ResolveError'; } } +/** Resolve a direct NSID into its ATProto Lexicon schema response. */ export async function resolveLexicon(nsid: string, env: RuntimeEnv): Promise { - const parsed = parseNsid(nsid); - if (parsed === null) { - throw new ResolveError(400, 'bad_request', 'Invalid NSID'); - } + const trace = emptyTrace(); - const cacheKey = `${LEXICON_CACHE_PREFIX}${parsed.nsid}`; - const cached = await env.LEXICONS.get(cacheKey, 'json'); - if (cached !== null && !isLegacyGeneratedCache(cached)) { - return { ...normalizeCachedLexicon(cached, parsed), cache: 'hit' }; - } + try { + const parsed = parseNsid(nsid); + if (parsed === null) { + throw resolveFailure(400, 'bad_request', 'Invalid NSID', trace); + } - const endpoint = env.DOH_ENDPOINT ?? 'https://cloudflare-dns.com/dns-query'; - const records = await lookupTxtRecords(endpoint, parsed.dnsName); - const did = findLexiconDid(records); + trace.dnsName = parsed.dnsName; - if (did === null) { - throw new ResolveError(404, 'not_found', `No lexicon DID record found at ${parsed.dnsName}`); - } + const cacheKey = `${LEXICON_CACHE_PREFIX}${parsed.nsid}`; + const cached = await env.LEXICONS.get(cacheKey, 'json'); + if (cached !== null && !isLegacyGeneratedCache(cached)) { + return normalizeCachedLexicon(cached, parsed); + } - const lexicon = await fetchPublishedLexicon(did, parsed.nsid); - const resolved: ResolvedLexicon = { - nsid: parsed.nsid, - hash: await sha256Json(lexicon), - fetchedAt: new Date().toISOString(), - source: { name: parsed.dnsName, url: `at://${did}/${LEXICON_SCHEMA_COLLECTION}/${parsed.nsid}` }, - lexicon, - cache: 'miss' - }; + const endpoint = env.DOH_ENDPOINT ?? 'https://cloudflare-dns.com/dns-query'; + const records = await lookupTxtRecords(endpoint, parsed.dnsName); + trace.txtRecords = records; + const did = findLexiconDid(records); + trace.selectedDid = did; - await env.LEXICONS.put(cacheKey, JSON.stringify(resolved), { expirationTtl: cacheTtl(env) }); + if (did === null) { + throw resolveFailure(404, 'not_found', `No lexicon DID record found at ${parsed.dnsName}`, trace); + } - return resolved; + const lexicon = await fetchPublishedLexicon(did, parsed.nsid, trace); + trace.final = { success: true, message: 'Resolved lexicon schema' }; + const resolved: ResolvedLexicon = { + version: 1, + nsid: parsed.nsid, + parsed, + hash: await sha256Json(lexicon), + fetchedAt: new Date().toISOString(), + source: { name: parsed.dnsName, url: `at://${did}/${LEXICON_SCHEMA_COLLECTION}/${parsed.nsid}` }, + lexicon, + cache: 'miss', + trace + }; + + await env.LEXICONS.put(cacheKey, JSON.stringify(resolved), { expirationTtl: cacheTtl(env) }); + + return resolved; + } catch (error) { + throw attachTrace(error, trace); + } } function normalizeCachedLexicon(value: ResolvedLexicon, parsed: ParsedNsid): ResolvedLexicon { - return { ...value, source: { name: value.source.name ?? parsed.dnsName, url: value.source.url } }; + const source = { name: value.source.name ?? parsed.dnsName, url: value.source.url }; + return { ...value, version: 1, parsed, source, cache: 'hit', trace: cachedTrace(parsed, source.url) }; } function isLegacyGeneratedCache(value: ResolvedLexicon): boolean { @@ -97,59 +116,71 @@ export function isLexiconDocument(value: unknown, nsid: string): value is Lexico ); } -async function fetchPublishedLexicon(did: string, nsid: string): Promise { - const didDocument = await fetchDidDocument(did); +async function fetchPublishedLexicon(did: string, nsid: string, trace: ResolutionTrace): Promise { + const didDocument = await fetchDidDocument(did, trace); const pdsEndpoint = findPdsEndpoint(didDocument); + trace.pdsEndpoint = pdsEndpoint?.toString() ?? null; if (pdsEndpoint === null) { - throw new ResolveError(502, 'upstream_error', `DID document has no ATProto PDS service: ${did}`); + throw resolveFailure(502, 'upstream_error', `DID document has no ATProto PDS service: ${did}`, trace); } 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); + trace.repoGetRecord.url = url.toString(); let response: Response; try { response = await fetch(url, { headers: { accept: 'application/json' } }); } catch (error) { - throw new ResolveError(502, 'upstream_error', `Failed to fetch lexicon: ${errorMessage(error)}`); + throw resolveFailure(502, 'upstream_error', `Failed to fetch lexicon: ${errorMessage(error)}`, trace); } + trace.repoGetRecord.status = response.status; if (!response.ok) { - throw new ResolveError( + throw resolveFailure( 502, 'upstream_error', - `Lexicon record fetch failed with ${response.status} ${response.statusText}` + `Lexicon record fetch failed with ${response.status} ${response.statusText}`, + trace ); } 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'); + throw resolveFailure(502, 'upstream_error', 'Fetched lexicon record is not a matching Lexicon schema', trace); } return body.value; } -async function fetchDidDocument(did: string): Promise { +async function fetchDidDocument(did: string, trace: ResolutionTrace): Promise { const url = didDocumentUrl(did); + trace.didDocument.url = url?.toString() ?? null; if (url === null) { - throw new ResolveError(502, 'upstream_error', `Unsupported DID method for lexicon publisher: ${did}`); + throw resolveFailure(502, 'upstream_error', `Unsupported DID method for lexicon publisher: ${did}`, trace); } 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)}`); + throw resolveFailure( + 502, + 'upstream_error', + `Failed to resolve lexicon publisher DID: ${errorMessage(error)}`, + trace + ); } + trace.didDocument.status = response.status; if (!response.ok) { - throw new ResolveError( + throw resolveFailure( 502, 'upstream_error', - `DID resolution failed with ${response.status} ${response.statusText}` + `DID resolution failed with ${response.status} ${response.statusText}`, + trace ); } @@ -223,6 +254,75 @@ export function mapResolveError(error: unknown): ResolveError { return new ResolveError(500, 'internal_error', errorMessage(error)); } +function emptyTrace(): ResolutionTrace { + return { + dnsName: null, + txtRecords: [], + selectedDid: null, + didDocument: { url: null, status: null }, + pdsEndpoint: null, + repoGetRecord: { url: null, status: null }, + final: { success: false, message: 'Resolution did not complete' } + }; +} + +function resolveFailure(status: number, code: ApiErrorCode, message: string, trace: ResolutionTrace): ResolveError { + trace.final = { success: false, code, message }; + return new ResolveError(status, code, message, trace); +} + +function attachTrace(error: unknown, trace: ResolutionTrace): ResolveError { + if (error instanceof ResolveError) { + if (error.trace !== undefined) { + return error; + } + + return resolveFailure(error.status, error.code, error.message, trace); + } + + const mapped = mapResolveError(error); + return resolveFailure(mapped.status, mapped.code, mapped.message, trace); +} + +function cachedTrace(parsed: ParsedNsid, sourceUrl: string): ResolutionTrace { + const trace = emptyTrace(); + trace.dnsName = parsed.dnsName; + trace.selectedDid = didFromAtUri(sourceUrl); + trace.final = { success: true, message: 'Resolved lexicon schema from cache' }; + return trace; +} + +function didFromAtUri(value: string): string | null { + const parsed = parseAtUri(value); + return parsed?.authority ?? null; +} + +function parseAtUri(input: string): { authority: string; segments: string[] } | null { + if (!input.startsWith('at://')) { + return null; + } + + const withoutScheme = input.slice('at://'.length); + const slashIndex = withoutScheme.indexOf('/'); + if (slashIndex < 0) { + return null; + } + + const authority = withoutScheme.slice(0, slashIndex); + const path = withoutScheme.slice(slashIndex + 1); + if (authority.length === 0 || path.length === 0) { + return null; + } + + return { + authority: decodeURIComponent(authority), + segments: path + .split('/') + .filter((segment) => segment.length > 0) + .map((segment) => decodeURIComponent(segment)) + }; +} + export function isValidNsid(nsid: string): boolean { if (nsid.length > 317 || !NSID_PATTERN.test(nsid)) { return false; @@ -243,6 +343,7 @@ export function isValidNsid(nsid: string): boolean { return topLevelDomain !== undefined && !/^[0-9]/.test(topLevelDomain); } +/** Parse a valid NSID into authority, domain, terminal name, and DNS lookup name metadata. */ export function parseNsid(nsid: string): ParsedNsid | null { if (!isValidNsid(nsid)) { return null; @@ -262,6 +363,58 @@ export function parseNsid(nsid: string): ParsedNsid | null { return { nsid, authority, domain, name, dnsName }; } +/** Normalize direct NSIDs, AT URIs, and HTTPS URLs into the requested Lexicon schema NSID. */ +export function normalizeResolveInput(input: string): ParsedNsid | null { + const trimmed = input.trim(); + const direct = parseNsid(trimmed); + if (direct !== null) { + return direct; + } + + const uriNsid = nsidFromUri(trimmed); + return uriNsid === null ? null : parseNsid(uriNsid); +} + +function nsidFromUri(input: string): string | null { + const atUri = parseAtUri(input); + if (atUri !== null) { + return atUri.segments[0] === LEXICON_SCHEMA_COLLECTION ? (atUri.segments[1] ?? null) : null; + } + + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null; + } + + const collection = url.searchParams.get('collection'); + const rkey = url.searchParams.get('rkey'); + if (collection === LEXICON_SCHEMA_COLLECTION && rkey !== null) { + return rkey; + } + + const segments = pathSegments(url); + const collectionIndex = segments.indexOf(LEXICON_SCHEMA_COLLECTION); + if (collectionIndex >= 0) { + return segments[collectionIndex + 1] ?? null; + } + + const last = segments.at(-1); + return last === undefined ? null : last; +} + +function pathSegments(url: URL): string[] { + return url.pathname + .split('/') + .filter((segment) => segment.length > 0) + .map((segment) => decodeURIComponent(segment)); +} + export function resolvePathNsid(pathname: string): string { const prefix = '/api/resolve/'; if (!pathname.startsWith(prefix)) { @@ -279,3 +432,22 @@ export function resolvePathNsid(pathname: string): string { return ''; } } + +/** Decode the wildcard URI value from an `/api/uri/*` request path. */ +export function resolvePathUri(pathname: string): string { + const prefix = '/api/uri/'; + if (!pathname.startsWith(prefix)) { + return ''; + } + + const encoded = pathname.slice(prefix.length); + if (encoded.length === 0) { + return ''; + } + + try { + return decodeURIComponent(encoded); + } catch { + return ''; + } +} diff --git a/src/main.ts b/src/main.ts index 84707ec..dd00cec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { apiError, jsonResponse } from './http'; -import { mapResolveError, resolveLexicon, resolvePathNsid } from './lexicon'; +import { mapResolveError, normalizeResolveInput, resolveLexicon, resolvePathNsid, resolvePathUri } from './lexicon'; import type { ResolvedLexicon, ResolvedLexiconResponse } from './types'; import { Index } from './view'; import { JSONView } from './view/partials'; @@ -25,7 +25,7 @@ app.get('/partials/resolve', async (c) => { return c.html(JSONView(withLinks(resolved, new URL(c.req.url).origin), nsid)); } catch (error) { const mapped = mapResolveError(error); - return c.html(JSONView({ error: { code: mapped.code, message: mapped.message } }, nsid)); + return c.html(JSONView(errorBody(mapped), nsid)); } }); @@ -33,6 +33,10 @@ app.options('/api/resolve/*', () => { return new Response(null, { status: 204, headers: CORS_HEADERS }); }); +app.options('/api/uri/*', () => { + return new Response(null, { status: 204, headers: CORS_HEADERS }); +}); + app.get('/api/resolve/*', async (c) => { try { const url = new URL(c.req.url); @@ -41,10 +45,20 @@ app.get('/api/resolve/*', async (c) => { return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); } catch (error) { const mapped = mapResolveError(error); - return jsonResponse( - { error: { code: mapped.code, message: mapped.message } }, - { status: mapped.status, headers: CORS_HEADERS } - ); + return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); + } +}); + +app.get('/api/uri/*', async (c) => { + try { + const url = new URL(c.req.url); + const input = c.req.query('uri') ?? resolvePathUri(url.pathname); + const parsed = normalizeResolveInput(input); + const resolved = await resolveLexicon(parsed?.nsid ?? '', c.env); + return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); + } catch (error) { + const mapped = mapResolveError(error); + return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); } }); @@ -62,5 +76,9 @@ export function withLinks(resolved: ResolvedLexicon, origin: string): ResolvedLe }; } +function errorBody(error: ReturnType) { + return { version: 1, error: { code: error.code, message: error.message }, trace: error.trace }; +} + export { app }; export default app; diff --git a/src/types.ts b/src/types.ts index b51611f..f41582a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +/** Parsed metadata for an ATProto Lexicon NSID and its DNS discovery name. */ export type ParsedNsid = { nsid: string; authority: string; domain: string; name: string; dnsName: string }; export type TxtRecord = { name: string; data: string; ttl?: number }; @@ -14,23 +15,39 @@ export type LexiconDocument = { lexicon: 1; id: string; defs: Record }; diff --git a/test/lexicon.test.ts b/test/lexicon.test.ts index 3b0cab5..369a4cb 100644 --- a/test/lexicon.test.ts +++ b/test/lexicon.test.ts @@ -1,17 +1,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isLexiconDocument, resolveLexicon } from '../src/lexicon'; +import { isLexiconDocument, normalizeResolveInput, parseNsid, resolveLexicon } from '../src/lexicon'; import type { RuntimeEnv, ResolvedLexicon } from '../src/types'; const originalFetch = globalThis.fetch; function resolvedLexicon(nsid = 'com.example.foo'): ResolvedLexicon { + const parsed = parseNsid(nsid); + if (parsed === null) { + throw new Error(`Invalid test NSID: ${nsid}`); + } + return { + version: 1, nsid, + parsed, hash: 'sha256:test', fetchedAt: '2026-06-18T00:00:00.000Z', source: { name: '_lexicon.example.com', url: `at://did:plc:example/com.atproto.lexicon.schema/${nsid}` }, lexicon: { lexicon: 1, id: nsid, defs: {} }, - cache: 'miss' + cache: 'miss', + trace: { + dnsName: parsed.dnsName, + txtRecords: [], + selectedDid: 'did:plc:example', + didDocument: { url: null, status: null }, + pdsEndpoint: null, + repoGetRecord: { url: null, status: null }, + final: { success: true, message: 'Resolved lexicon schema' } + } }; } @@ -72,6 +88,22 @@ afterEach(() => { }); describe('Lexicon resolver', () => { + it('normalizes direct NSIDs, AT URIs, and HTTPS URLs', () => { + expect(normalizeResolveInput('com.example.foo')).toMatchObject({ nsid: 'com.example.foo' }); + expect(normalizeResolveInput('at://did:plc:example/com.atproto.lexicon.schema/com.example.foo')).toMatchObject({ + nsid: 'com.example.foo' + }); + expect( + normalizeResolveInput( + 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Aexample&collection=com.atproto.lexicon.schema&rkey=com.example.foo' + ) + ).toMatchObject({ nsid: 'com.example.foo' }); + expect(normalizeResolveInput('https://example.com/com.atproto.lexicon.schema/com.example.foo')).toMatchObject({ + nsid: 'com.example.foo' + }); + expect(normalizeResolveInput('https://example.com/not-a-schema')).toBe(null); + }); + it('recognizes matching Lexicon documents', () => { expect(isLexiconDocument({ lexicon: 1, id: 'com.example.foo', defs: {} }, 'com.example.foo')).toBe(true); expect(isLexiconDocument({ lexicon: 1, id: 'com.example.bar', defs: {} }, 'com.example.foo')).toBe(false); @@ -85,8 +117,15 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).resolves.toMatchObject({ nsid: 'com.example.foo', + version: 1, cache: 'hit', - source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' } + source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' }, + parsed: { dnsName: '_lexicon.example.com' }, + trace: { + dnsName: '_lexicon.example.com', + selectedDid: 'did:plc:example', + final: { success: true, message: 'Resolved lexicon schema from cache' } + } }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -98,7 +137,13 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).rejects.toMatchObject({ status: 404, code: 'not_found', - message: 'No lexicon DID record found at _lexicon.example.com' + message: 'No lexicon DID record found at _lexicon.example.com', + trace: { + dnsName: '_lexicon.example.com', + txtRecords: [], + selectedDid: null, + final: { success: false, code: 'not_found', message: 'No lexicon DID record found at _lexicon.example.com' } + } }); }); @@ -119,12 +164,26 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('app.bsky.feed.post', env)).resolves.toMatchObject({ nsid: 'app.bsky.feed.post', + version: 1, cache: 'miss', + parsed: { authority: 'app.bsky.feed', domain: 'feed.bsky.app', name: 'post', dnsName: '_lexicon.feed.bsky.app' }, 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: {} } + lexicon: { lexicon: 1, id: 'app.bsky.feed.post', defs: {} }, + trace: { + dnsName: '_lexicon.feed.bsky.app', + txtRecords: [{ name: '_lexicon.feed.bsky.app', data: 'did=did:plc:bsky', ttl: 300 }], + selectedDid: 'did:plc:bsky', + didDocument: { url: 'https://plc.directory/did:plc:bsky', status: 200 }, + pdsEndpoint: 'https://pds.example.com/', + repoGetRecord: { + url: 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Absky&collection=com.atproto.lexicon.schema&rkey=app.bsky.feed.post', + status: 200 + }, + final: { success: true, message: 'Resolved lexicon schema' } + } }); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock.mock.calls[2]?.[0]?.toString()).toBe( @@ -144,5 +203,4 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).rejects.toMatchObject({ status: 404, code: 'not_found' }); expect(fetchMock).toHaveBeenCalledOnce(); }); - }); diff --git a/test/main.test.ts b/test/main.test.ts index c5efab7..0ca97a5 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,18 +1,77 @@ -import { describe, expect, it } from 'vitest'; -import { withLinks } from '../src/main'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { app, withLinks } from '../src/main'; +import { parseNsid } from '../src/lexicon'; import type { ResolvedLexicon } from '../src/types'; +const originalFetch = globalThis.fetch; + function resolvedLexicon(nsid = 'com.example.foo'): ResolvedLexicon { + const parsed = parseNsid(nsid); + if (parsed === null) { + throw new Error(`Invalid test NSID: ${nsid}`); + } + return { + version: 1, nsid, + parsed, hash: 'sha256:test', fetchedAt: '2026-06-18T00:00:00.000Z', source: { name: '_lexicon.example.com', url: `at://did:plc:example/com.atproto.lexicon.schema/${nsid}` }, lexicon: { lexicon: 1, id: nsid, defs: {} }, - cache: 'miss' + cache: 'miss', + trace: { + dnsName: parsed.dnsName, + txtRecords: [], + selectedDid: 'did:plc:example', + didDocument: { url: null, status: null }, + pdsEndpoint: null, + repoGetRecord: { url: null, status: null }, + final: { success: true, message: 'Resolved lexicon schema' } + } }; } +function testEnv(storage = new Map()): Env { + 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 mockFetchSequence(responses: Response[]) { + const fetchMock = vi.fn(async (): 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(); +}); + describe('API response presenter', () => { it('adds HAL-style links to resolved lexicons', () => { expect(withLinks(resolvedLexicon(), 'https://lexdns.example')).toMatchObject({ @@ -22,4 +81,42 @@ describe('API response presenter', () => { } }); }); + + it('resolves encoded AT URIs through /api/uri', async () => { + mockFetchSequence([ + Response.json({ + Status: 0, + Answer: [{ name: '_lexicon.example.com', type: 16, data: '"did=did:plc:example"', TTL: 60 }] + }), + Response.json({ + id: 'did:plc:example', + service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }] + }), + Response.json({ value: { lexicon: 1, id: 'com.example.foo', defs: {} } }) + ]); + + const uri = encodeURIComponent('at://did:plc:example/com.atproto.lexicon.schema/com.example.foo'); + const response = await app.fetch(new Request(`https://lexdns.example/api/uri/${uri}`), testEnv()); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + version: 1, + nsid: 'com.example.foo', + parsed: { dnsName: '_lexicon.example.com' }, + trace: { selectedDid: 'did:plc:example', final: { success: true } } + }); + }); + + it('returns stable API errors with traces', async () => { + const response = await app.fetch(new Request('https://lexdns.example/api/uri/not-a-uri'), testEnv()); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + version: 1, + error: { code: 'bad_request', message: 'Invalid NSID' }, + trace: { final: { success: false, code: 'bad_request', message: 'Invalid NSID' } } + }); + }); });