chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
const DEFAULT_PLC_URL = 'https://plc.directory';
/**
* @typedef {{id: string, type: string, serviceEndpoint: string}} DidService
* @typedef {{service?: DidService[]}} DidDocument
*/
/**
* Extract PDS service endpoint from DID document.
*
* @param {DidDocument} 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(
(/** @type {DidService} */ 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 didDoc = await resolveDidDocument(did, opts);
return didDoc ? extractPdsEndpoint(didDoc) : null;
}
/**
* Resolve a DID to its full DID document.
*
* Distinct from {@link resolveDid}, which reduces the document to its PDS
* endpoint: consumers verifying signatures (service auth, space credentials)
* need the verification methods, not just the service entry.
*
* @param {string} did - The DID to resolve (did:plc or did:web)
* @param {{plcUrl?: string, fetch?: typeof globalThis.fetch}} [opts]
* @returns {Promise<Object|null>} The DID document, or null if unresolvable
*/
export async function resolveDidDocument(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;
return await response.json();
} catch {
return null;
}
}
/**
* A DID document resolver that holds its answers.
*
* Every space credential names its issuer, and verifying one reads that
* issuer's key, so an uncached resolver reads the directory on every space
* request. plc.directory sends no `Cache-Control`, so no layer below this one
* holds the answer either.
*
* A held document can name a key the issuer has rotated away from. Callers
* that verify a signature must pass `forceRefresh` and try again when the
* signature fails, which is what tells this resolver the entry is wrong.
*
* @param {Object} [opts]
* @param {number} [opts.ttl=3600000] - How long an answer serves, in ms
* (default 1 hour, the interval the reference implementation holds one for)
* @param {number} [opts.maxTtl=86400000] - How long a held answer may serve
* when the directory stops answering (default 24 hours)
* @param {number} [opts.negativeTtl=60000] - How long an unresolvable DID
* stays unresolvable (default 1 minute), so a DID registered moments ago
* is not pinned for the full TTL
* @param {string} [opts.plcUrl]
* @param {typeof fetch} [opts.fetch]
* @returns {(did: string, opts?: {forceRefresh?: boolean}) => Promise<Object|null>}
*/
export function createCachedDidResolver(opts = {}) {
const ttl = opts.ttl ?? 3600000;
const maxTtl = opts.maxTtl ?? 86400000;
const negativeTtl = opts.negativeTtl ?? 60000;
/** @type {Map<string, {doc: Object|null, at: number}>} */
const cache = new Map();
/** @type {Map<string, Promise<Object|null>>} */
const reading = new Map();
/** @param {string} did */
async function read(did) {
const held = cache.get(did);
const doc = await resolveDidDocument(did, {
plcUrl: opts.plcUrl,
fetch: opts.fetch,
});
// resolveDidDocument answers null for an unreachable directory and for a
// DID that does not exist alike. Serving the held document covers the
// first case; maxTtl bounds how long it covers the second.
if (!doc && held?.doc && Date.now() - held.at < maxTtl) {
return held.doc;
}
cache.set(did, { doc, at: Date.now() });
return doc;
}
return async function resolve(did, { forceRefresh = false } = {}) {
const held = cache.get(did);
if (held && !forceRefresh) {
const age = Date.now() - held.at;
if (age < (held.doc ? ttl : negativeTtl)) return held.doc;
}
// Requests arrive together and name the same authority, so a cold entry
// would otherwise start one read each. A read already running is a live
// one, which answers a forced caller as well as an ordinary one.
const running = reading.get(did);
if (running) return running;
const pending = read(did).finally(() => reading.delete(did));
reading.set(did, pending);
return pending;
};
}