# Lexicon Resolver Rewrite Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace `@atproto/lexicon` and `@atproto/lex-resolver` with `@bigmoves/lexicon` and custom resolver logic for full AT Protocol spec compliance. **Architecture:** Custom DNS-over-HTTPS resolver extracts NSID authority, looks up `_atproto.{domain}` TXT record, resolves DID to PDS endpoint, fetches lexicon. Validation uses `@bigmoves/lexicon` zero-dependency validator. Universal runtime support (Node, Cloudflare Workers, Deno). **Tech Stack:** `@bigmoves/lexicon` (validation), DNS-over-HTTPS (Cloudflare), PLC directory (DID resolution) --- ## Task 1: Create authority.js module **Files:** - Create: `packages/lexicon-resolver/src/authority.js` - Test: `packages/lexicon-resolver/test/authority.test.js` **Step 1: Write the failing test** Create `packages/lexicon-resolver/test/authority.test.js`: ```javascript import { describe, expect, test } from 'vitest'; import { nsidToDomain, domainToAtprotoDns } from '../src/authority.js'; describe('authority', () => { describe('nsidToDomain', () => { test('converts app.bsky.feed.post to bsky.app', () => { expect(nsidToDomain('app.bsky.feed.post')).toBe('bsky.app'); }); test('converts com.atproto.repo.createRecord to atproto.com', () => { expect(nsidToDomain('com.atproto.repo.createRecord')).toBe('atproto.com'); }); test('converts org.example.lexicon.test to example.org', () => { expect(nsidToDomain('org.example.lexicon.test')).toBe('example.org'); }); }); describe('domainToAtprotoDns', () => { test('prepends _atproto to domain', () => { expect(domainToAtprotoDns('bsky.app')).toBe('_atproto.bsky.app'); }); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/authority.test.js ``` Expected: FAIL with "Cannot find module '../src/authority.js'" **Step 3: Write minimal implementation** Create `packages/lexicon-resolver/src/authority.js`: ```javascript /** * Extract authority from NSID and convert to domain. * NSID format: tld.domain.name (e.g., app.bsky.feed.post) * Authority is everything except the last segment, reversed. * * @param {string} nsid - e.g., "app.bsky.feed.post" * @returns {string} - e.g., "bsky.app" */ export function nsidToDomain(nsid) { const parts = nsid.split('.'); // Remove the last segment (the name), take first two for domain const authority = parts.slice(0, -1); // Reverse: [app, bsky] -> [bsky, app] -> "bsky.app" return authority.slice(0, 2).reverse().join('.'); } /** * Convert domain to AT Protocol DNS lookup name. * * @param {string} domain - e.g., "bsky.app" * @returns {string} - e.g., "_atproto.bsky.app" */ export function domainToAtprotoDns(domain) { return `_atproto.${domain}`; } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/authority.test.js ``` Expected: PASS (3 tests) **Step 5: Commit** ```bash git add packages/lexicon-resolver/src/authority.js packages/lexicon-resolver/test/authority.test.js git commit -m "feat(lexicon-resolver): add authority module for NSID to domain conversion Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 2: Create dns.js module **Files:** - Create: `packages/lexicon-resolver/src/dns.js` - Test: `packages/lexicon-resolver/test/dns.test.js` **Step 1: Write the failing test** Create `packages/lexicon-resolver/test/dns.test.js`: ```javascript import { describe, expect, test, vi } from 'vitest'; import { lookupTxt, parseDidFromTxt } from '../src/dns.js'; describe('dns', () => { describe('parseDidFromTxt', () => { test('extracts DID from TXT record value', () => { expect(parseDidFromTxt('did=did:plc:xyz123')).toBe('did:plc:xyz123'); }); test('handles quoted TXT record', () => { expect(parseDidFromTxt('"did=did:plc:xyz123"')).toBe('did:plc:xyz123'); }); test('returns null for invalid format', () => { expect(parseDidFromTxt('invalid')).toBeNull(); }); test('returns null for empty string', () => { expect(parseDidFromTxt('')).toBeNull(); }); }); describe('lookupTxt', () => { test('fetches TXT records via DoH', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ Answer: [ { type: 16, data: '"did=did:plc:z72i7hdynmk6r22z27h6tvur"' }, ], }), }); const records = await lookupTxt('_atproto.bsky.app', { fetch: mockFetch }); expect(mockFetch).toHaveBeenCalledWith( 'https://cloudflare-dns.com/dns-query?name=_atproto.bsky.app&type=TXT', expect.objectContaining({ headers: { Accept: 'application/dns-json' }, }), ); expect(records).toEqual(['did=did:plc:z72i7hdynmk6r22z27h6tvur']); }); test('returns empty array on network error', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); const records = await lookupTxt('_atproto.example.com', { fetch: mockFetch }); expect(records).toEqual([]); }); test('returns empty array when no Answer', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}), }); const records = await lookupTxt('_atproto.example.com', { fetch: mockFetch }); expect(records).toEqual([]); }); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/dns.test.js ``` Expected: FAIL with "Cannot find module '../src/dns.js'" **Step 3: Write minimal implementation** Create `packages/lexicon-resolver/src/dns.js`: ```javascript const DEFAULT_DOH_URL = 'https://cloudflare-dns.com/dns-query'; /** * Parse DID from AT Protocol TXT record value. * Format: "did=did:plc:xyz123" or did=did:plc:xyz123 * * @param {string} txt - TXT record value * @returns {string|null} - DID or null if invalid */ export function parseDidFromTxt(txt) { if (!txt) return null; // Remove surrounding quotes if present const unquoted = txt.replace(/^"|"$/g, ''); const match = unquoted.match(/^did=(.+)$/); return match ? match[1] : null; } /** * Lookup TXT records via DNS-over-HTTPS. * * @param {string} domain - Domain to lookup (e.g., "_atproto.bsky.app") * @param {Object} [opts] * @param {string} [opts.dohUrl] - DoH endpoint URL * @param {typeof fetch} [opts.fetch] - Fetch implementation * @returns {Promise} - Array of TXT record values */ export async function lookupTxt(domain, opts = {}) { const dohUrl = opts.dohUrl ?? DEFAULT_DOH_URL; const fetchFn = opts.fetch ?? globalThis.fetch; try { const url = `${dohUrl}?name=${encodeURIComponent(domain)}&type=TXT`; const response = await fetchFn(url, { headers: { Accept: 'application/dns-json' }, }); if (!response.ok) return []; const data = await response.json(); if (!data.Answer) return []; // Extract TXT record values (type 16 = TXT) return data.Answer .filter((record) => record.type === 16) .map((record) => { // Remove surrounding quotes from data field const value = record.data?.replace(/^"|"$/g, '') ?? ''; return value; }); } catch { return []; } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/dns.test.js ``` Expected: PASS (6 tests) **Step 5: Commit** ```bash git add packages/lexicon-resolver/src/dns.js packages/lexicon-resolver/test/dns.test.js git commit -m "feat(lexicon-resolver): add dns module for DoH TXT lookups Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 3: Create did.js module **Files:** - Create: `packages/lexicon-resolver/src/did.js` - Test: `packages/lexicon-resolver/test/did.test.js` **Step 1: Write the failing test** Create `packages/lexicon-resolver/test/did.test.js`: ```javascript import { describe, expect, test, vi } from 'vitest'; import { resolveDid, extractPdsEndpoint } from '../src/did.js'; describe('did', () => { describe('extractPdsEndpoint', () => { test('extracts PDS endpoint from DID document', () => { const didDoc = { service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com', }, ], }; expect(extractPdsEndpoint(didDoc)).toBe('https://pds.example.com'); }); test('finds PDS by type if id differs', () => { const didDoc = { service: [ { id: '#pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com', }, ], }; expect(extractPdsEndpoint(didDoc)).toBe('https://pds.example.com'); }); test('returns null if no PDS service', () => { const didDoc = { service: [] }; expect(extractPdsEndpoint(didDoc)).toBeNull(); }); test('returns null if no service array', () => { const didDoc = {}; expect(extractPdsEndpoint(didDoc)).toBeNull(); }); }); describe('resolveDid', () => { test('resolves did:plc via PLC directory', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://bsky.social', }, ], }), }); const endpoint = await resolveDid('did:plc:xyz123', { fetch: mockFetch }); expect(mockFetch).toHaveBeenCalledWith('https://plc.directory/did:plc:xyz123'); expect(endpoint).toBe('https://bsky.social'); }); test('resolves did:web via .well-known', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://example.com', }, ], }), }); const endpoint = await resolveDid('did:web:example.com', { fetch: mockFetch }); expect(mockFetch).toHaveBeenCalledWith('https://example.com/.well-known/did.json'); expect(endpoint).toBe('https://example.com'); }); test('returns null for unknown DID method', async () => { const endpoint = await resolveDid('did:unknown:xyz', { fetch: vi.fn() }); expect(endpoint).toBeNull(); }); test('returns null on fetch error', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); const endpoint = await resolveDid('did:plc:xyz', { fetch: mockFetch }); expect(endpoint).toBeNull(); }); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/did.test.js ``` Expected: FAIL with "Cannot find module '../src/did.js'" **Step 3: Write minimal implementation** Create `packages/lexicon-resolver/src/did.js`: ```javascript const DEFAULT_PLC_URL = 'https://plc.directory'; /** * Extract PDS service endpoint from DID document. * * @param {Object} didDoc - DID document * @returns {string|null} - PDS endpoint URL or null */ export function extractPdsEndpoint(didDoc) { if (!didDoc?.service || !Array.isArray(didDoc.service)) { return null; } const pdsService = didDoc.service.find( (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer', ); return pdsService?.serviceEndpoint ?? null; } /** * Resolve a DID to its PDS service endpoint. * * @param {string} did - DID string (did:plc:... or did:web:...) * @param {Object} [opts] * @param {string} [opts.plcUrl] - PLC directory URL * @param {typeof fetch} [opts.fetch] - Fetch implementation * @returns {Promise} - PDS endpoint URL or null */ export async function resolveDid(did, opts = {}) { const plcUrl = opts.plcUrl ?? DEFAULT_PLC_URL; const fetchFn = opts.fetch ?? globalThis.fetch; try { let didDocUrl; if (did.startsWith('did:plc:')) { didDocUrl = `${plcUrl}/${did}`; } else if (did.startsWith('did:web:')) { const domain = did.slice('did:web:'.length); didDocUrl = `https://${domain}/.well-known/did.json`; } else { return null; } const response = await fetchFn(didDocUrl); if (!response.ok) return null; const didDoc = await response.json(); return extractPdsEndpoint(didDoc); } catch { return null; } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/did.test.js ``` Expected: PASS (8 tests) **Step 5: Commit** ```bash git add packages/lexicon-resolver/src/did.js packages/lexicon-resolver/test/did.test.js git commit -m "feat(lexicon-resolver): add did module for DID resolution Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 4: Update package.json dependencies **Files:** - Modify: `packages/lexicon-resolver/package.json` **Step 1: Update dependencies** Replace contents of `packages/lexicon-resolver/package.json`: ```json { "name": "@pdsjs/lexicon-resolver", "version": "0.1.0", "type": "module", "main": "src/index.js", "exports": { ".": "./src/index.js", "./resolver": "./src/resolver.js", "./dns": "./src/dns.js", "./did": "./src/did.js", "./authority": "./src/authority.js" }, "dependencies": { "@bigmoves/lexicon": "^0.1.2" }, "devDependencies": { "vitest": "^2.1.8" } } ``` **Step 2: Install new dependencies** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm install ``` Expected: Installs `@bigmoves/lexicon`, removes `@atproto/lexicon` and `@atproto/lex-resolver` **Step 3: Commit** ```bash git add packages/lexicon-resolver/package.json pnpm-lock.yaml git commit -m "chore(lexicon-resolver): switch to @bigmoves/lexicon Remove @atproto/lexicon and @atproto/lex-resolver dependencies. Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 5: Rewrite resolver.js **Files:** - Modify: `packages/lexicon-resolver/src/resolver.js` - Modify: `packages/lexicon-resolver/test/resolver.test.js` **Step 1: Rewrite resolver.js** Replace contents of `packages/lexicon-resolver/src/resolver.js`: ```javascript import { validateRecord } from '@bigmoves/lexicon'; import { lookupTxt, parseDidFromTxt } from './dns.js'; import { resolveDid } from './did.js'; import { nsidToDomain, domainToAtprotoDns } from './authority.js'; /** * @typedef {import('@pdsjs/core/ports').ValidationStatus} ValidationStatus */ /** * Lexicon resolver with full AT Protocol spec compliance. * Resolves schemas via DNS authority lookup, DID resolution, and PDS fetch. * Validates records using @bigmoves/lexicon. * * Implements LexiconResolverPort. */ export class LexiconResolver { /** * @param {Object} [opts] * @param {number} [opts.ttl=3600000] - Cache TTL in ms (default 1 hour) * @param {typeof fetch} [opts.fetch] - Fetch implementation * @param {string} [opts.dohUrl] - DNS-over-HTTPS endpoint * @param {string} [opts.plcUrl] - PLC directory URL * @param {Array<{id: string, defs: Object}>} [opts.schemas] - Pre-loaded static schemas */ constructor(opts = {}) { /** @type {Map} */ this.schemas = new Map(); /** @type {Map} */ this.cache = new Map(); this.ttl = opts.ttl ?? 3600000; this.fetch = opts.fetch ?? globalThis.fetch; this.dohUrl = opts.dohUrl; this.plcUrl = opts.plcUrl; // Pre-load static schemas for (const schema of opts.schemas ?? []) { this.schemas.set(schema.id, schema); this.cache.set(schema.id, { fetchedAt: Date.now() }); } } /** * Validate a record against its schema. * @param {string} collection - The collection/NSID (e.g., 'app.bsky.feed.post') * @param {object} record - The record to validate * @returns {Promise} */ async assertValid(collection, record) { const hasSchema = await this.ensureSchema(collection); if (!hasSchema) { return 'unknown'; } const schema = this.schemas.get(collection); const error = validateRecord([schema], collection, record); if (error) { const path = error.path ? `${error.path}: ` : ''; throw new Error(`${path}${error.message}`); } return 'valid'; } /** * Ensure a schema is loaded, fetching if necessary. * @param {string} nsid * @returns {Promise} - true if schema is available */ async ensureSchema(nsid) { // Check if already loaded and not stale const cached = this.cache.get(nsid); if (cached && Date.now() - cached.fetchedAt < this.ttl) { return this.schemas.has(nsid); } // Already have it, just not checking staleness if (this.schemas.has(nsid)) { return true; } // Try to resolve via DNS -> DID -> PDS try { const lexicon = await this.fetchLexicon(nsid); if (lexicon) { this.schemas.set(nsid, lexicon); this.cache.set(nsid, { fetchedAt: Date.now() }); return true; } } catch { // Resolution failed } return false; } /** * Fetch a lexicon via the AT Protocol resolution chain. * @param {string} nsid * @returns {Promise<{id: string, defs: Object}|null>} */ async fetchLexicon(nsid) { // 1. Convert NSID to domain const domain = nsidToDomain(nsid); const dnsName = domainToAtprotoDns(domain); // 2. DNS TXT lookup const txtRecords = await lookupTxt(dnsName, { dohUrl: this.dohUrl, fetch: this.fetch, }); // 3. Parse DID from TXT record let did = null; for (const txt of txtRecords) { did = parseDidFromTxt(txt); if (did) break; } if (!did) return null; // 4. Resolve DID to PDS endpoint const pdsEndpoint = await resolveDid(did, { plcUrl: this.plcUrl, fetch: this.fetch, }); if (!pdsEndpoint) return null; // 5. Fetch lexicon from PDS const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`; const response = await this.fetch(url); if (!response.ok) return null; const data = await response.json(); return data.value ?? null; } } ``` **Step 2: Update test file** Replace contents of `packages/lexicon-resolver/test/resolver.test.js`: ```javascript import { describe, expect, test, vi } from 'vitest'; import { LexiconResolver } from '../src/resolver.js'; /** @type {import('@bigmoves/lexicon').Lexicon} */ const postSchema = { lexicon: 1, id: 'app.bsky.feed.post', defs: { main: { type: 'record', key: 'tid', record: { type: 'object', required: ['text', 'createdAt'], properties: { text: { type: 'string', maxLength: 3000 }, createdAt: { type: 'string', format: 'datetime' }, }, }, }, }, }; describe('LexiconResolver', () => { describe('with static schemas', () => { test('validates record against pre-loaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const status = await resolver.assertValid('app.bsky.feed.post', { $type: 'app.bsky.feed.post', text: 'Hello world', createdAt: new Date().toISOString(), }); expect(status).toBe('valid'); }); test('throws on invalid record', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); await expect( resolver.assertValid('app.bsky.feed.post', { $type: 'app.bsky.feed.post', // missing required fields }), ).rejects.toThrow(); }); test('returns unknown for unloaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const status = await resolver.assertValid('com.example.unknown', { $type: 'com.example.unknown', }); expect(status).toBe('unknown'); }); }); describe('ensureSchema', () => { test('returns true for pre-loaded schema', async () => { const resolver = new LexiconResolver({ schemas: [postSchema], }); const result = await resolver.ensureSchema('app.bsky.feed.post'); expect(result).toBe(true); }); test('returns false for unknown schema when resolution fails', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); const resolver = new LexiconResolver({ fetch: mockFetch }); const result = await resolver.ensureSchema('com.example.unknown'); expect(result).toBe(false); }); }); describe('fetchLexicon', () => { test('resolves lexicon via DNS -> DID -> PDS chain', async () => { const mockFetch = vi .fn() // DNS lookup .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ Answer: [{ type: 16, data: '"did=did:plc:testdid"' }], }), }) // DID resolution .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com', }, ], }), }) // Lexicon fetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: postSchema }), }); const resolver = new LexiconResolver({ fetch: mockFetch }); const lexicon = await resolver.fetchLexicon('app.bsky.feed.post'); expect(lexicon).toEqual(postSchema); expect(mockFetch).toHaveBeenCalledTimes(3); }); }); }); ``` **Step 3: Run tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/ ``` Expected: PASS (all tests in lexicon-resolver) **Step 4: Commit** ```bash git add packages/lexicon-resolver/src/resolver.js packages/lexicon-resolver/test/resolver.test.js git commit -m "feat(lexicon-resolver): rewrite resolver with custom DNS/DID resolution - Use @bigmoves/lexicon for validation - Custom DNS-over-HTTPS authority resolution - Custom DID resolution (PLC + did:web) - Full AT Protocol spec compliance Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 6: Update index.js exports **Files:** - Modify: `packages/lexicon-resolver/src/index.js` **Step 1: Update exports** Replace contents of `packages/lexicon-resolver/src/index.js`: ```javascript export { LexiconResolver } from './resolver.js'; export { lookupTxt, parseDidFromTxt } from './dns.js'; export { resolveDid, extractPdsEndpoint } from './did.js'; export { nsidToDomain, domainToAtprotoDns } from './authority.js'; ``` **Step 2: Commit** ```bash git add packages/lexicon-resolver/src/index.js git commit -m "feat(lexicon-resolver): export utility functions Co-Authored-By: Claude Opus 4.5 " ``` --- ## Task 7: Run full test suite and typecheck **Step 1: Run all tests** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm test ``` Expected: All tests pass **Step 2: Run typecheck** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm typecheck ``` Expected: No errors **Step 3: Run lint** ```bash cd /Users/chadmiller/code/pds-experiment && pnpm lint ``` Expected: No errors --- ## Summary After completing all tasks: 1. **`@pdsjs/lexicon-resolver`** now uses: - `@bigmoves/lexicon` for validation (zero dependencies) - Custom `dns.js` for DNS-over-HTTPS TXT lookups - Custom `did.js` for DID resolution (PLC + did:web) - Custom `authority.js` for NSID to domain conversion 2. **Removed dependencies:** - `@atproto/lexicon` - `@atproto/lex-resolver` 3. **Universal runtime support:** Node, Cloudflare Workers, Deno 4. **Usage unchanged:** ```javascript import { LexiconResolver } from '@pdsjs/lexicon-resolver'; const resolver = new LexiconResolver({ schemas: [/* optional static schemas */], }); // Validates via DNS -> DID -> PDS resolution chain const status = await resolver.assertValid('app.bsky.feed.post', record); ```