chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
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:
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
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:
/**
* 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
cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/authority.test.js
Expected: PASS (3 tests)
Step 5: Commit
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 <noreply@anthropic.com>"
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:
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
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:
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<string[]>} - 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
cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/dns.test.js
Expected: PASS (6 tests)
Step 5: Commit
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 <noreply@anthropic.com>"
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:
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
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:
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<string|null>} - 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
cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/test/did.test.js
Expected: PASS (8 tests)
Step 5: Commit
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 <noreply@anthropic.com>"
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:
{
"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
cd /Users/chadmiller/code/pds-experiment && pnpm install
Expected: Installs @bigmoves/lexicon, removes @atproto/lexicon and @atproto/lex-resolver
Step 3: Commit
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 <noreply@anthropic.com>"
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:
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<string, {id: string, defs: Object}>} */
this.schemas = new Map();
/** @type {Map<string, {fetchedAt: number}>} */
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<ValidationStatus>}
*/
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<boolean>} - 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:
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
cd /Users/chadmiller/code/pds-experiment && pnpm test packages/lexicon-resolver/
Expected: PASS (all tests in lexicon-resolver)
Step 4: Commit
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 <noreply@anthropic.com>"
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:
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
git add packages/lexicon-resolver/src/index.js
git commit -m "feat(lexicon-resolver): export utility functions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
Task 7: Run full test suite and typecheck
Step 1: Run all tests
cd /Users/chadmiller/code/pds-experiment && pnpm test
Expected: All tests pass
Step 2: Run typecheck
cd /Users/chadmiller/code/pds-experiment && pnpm typecheck
Expected: No errors
Step 3: Run lint
cd /Users/chadmiller/code/pds-experiment && pnpm lint
Expected: No errors
Summary
After completing all tasks:
-
@pdsjs/lexicon-resolvernow uses:@bigmoves/lexiconfor validation (zero dependencies)- Custom
dns.jsfor DNS-over-HTTPS TXT lookups - Custom
did.jsfor DID resolution (PLC + did:web) - Custom
authority.jsfor NSID to domain conversion
-
Removed dependencies:
@atproto/lexicon@atproto/lex-resolver
-
Universal runtime support: Node, Cloudflare Workers, Deno
-
Usage unchanged:
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);