chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Turning what somebody types into the server this page will talk to.
//
// Everything here runs in the browser, so the two lookups an atproto client
// normally delegates to a backend happen over public HTTPS endpoints instead:
// a DNS-over-HTTPS resolver for a handle's TXT record, and the DID directory
// for the document. Both are build-time settings, because a local stack runs a
// PLC of its own holding DIDs the public directory has never seen.
const PLC_URL = String(
import.meta.env?.VITE_PLC_URL || 'https://plc.directory',
).replace(/\/$/, '');
const DOH_URL = String(
import.meta.env?.VITE_DOH_URL || 'https://cloudflare-dns.com/dns-query',
);
/**
* @typedef {Object} Identity
* @property {string} did
* @property {string} handle - the handle the DID document claims, or the DID
* @property {string} pds - origin of the account's PDS, no trailing slash
* @property {DidDocument} doc
*/
/**
* @typedef {Object} DidDocument
* @property {string} id
* @property {string[]} [alsoKnownAs]
* @property {Array<{id: string, type: string, serviceEndpoint: string}>} [service]
*/
/**
* @param {string} did
* @returns {string}
*/
export function didDocumentUrl(did) {
if (did.startsWith('did:plc:')) return `${PLC_URL}/${did}`;
if (did.startsWith('did:web:')) {
const host = decodeURIComponent(did.slice('did:web:'.length));
return `https://${host}/.well-known/did.json`;
}
throw new Error(`Unsupported DID method: ${did}`);
}
/**
* @param {string} did
* @returns {Promise<DidDocument>}
*/
export async function resolveDid(did) {
const response = await fetch(didDocumentUrl(did));
if (!response.ok) throw new Error(`Could not resolve ${did}`);
return response.json();
}
/**
* The PDS a DID document names.
* @param {DidDocument} doc
* @returns {string}
*/
export function pdsEndpoint(doc) {
const service = (doc.service || []).find((entry) =>
entry.id.endsWith('#atproto_pds'),
);
if (!service) throw new Error('That account names no PDS.');
return String(service.serviceEndpoint).replace(/\/$/, '');
}
/**
* The handle a DID document claims. A DID with no `at://` alias reads as
* itself, which is what an account mid-handle-change looks like.
* @param {DidDocument} doc
* @returns {string}
*/
export function documentHandle(doc) {
const aka = (doc.alsoKnownAs || []).find((name) => name.startsWith('at://'));
return aka ? aka.slice('at://'.length) : doc.id;
}
/**
* Who a DID is, for a page that has only the DID.
*
* The handle is the document's own claim, and a claim is not a fact: a DID
* document may name any handle at all, including one belonging to somebody
* else. So the claim is resolved back, and a page shows an unconfirmed handle
* as unconfirmed rather than as an identity.
*
* @param {string} did
* @returns {Promise<{did: string, handle: string|null, verified: boolean}>}
*/
export async function identityOf(did) {
const doc = await resolveDid(did);
const claimed = documentHandle(doc);
// documentHandle answers the DID itself when the document claims no handle,
// which is an account mid-handle-change.
if (claimed === did) return { did, handle: null, verified: false };
const resolved = await didForHandle(claimed);
return { did, handle: claimed, verified: resolved === did };
}
/**
* The DID a handle resolves to, by whichever of the two routes answers.
* @param {string} handle
* @returns {Promise<string|null>}
*/
async function didForHandle(handle) {
return (await didFromDns(handle)) ?? (await didFromWellKnown(handle));
}
/**
* A handle's DID from its `_atproto` TXT record, or null.
* @param {string} handle
* @returns {Promise<string|null>}
*/
async function didFromDns(handle) {
const url = new URL(DOH_URL);
url.searchParams.set('name', `_atproto.${handle}`);
url.searchParams.set('type', 'TXT');
try {
const response = await fetch(url, {
headers: { accept: 'application/dns-json' },
});
if (!response.ok) return null;
const body = await response.json();
for (const answer of body?.Answer || []) {
// A TXT answer arrives quoted, and a long one arrives as several quoted
// strings that have to be joined before the prefix is readable.
const text = String(answer.data || '').replace(/"\s*"/g, '');
const match = text.match(/^"?did=(did:[^"\s]+)"?$/);
if (match) return match[1];
}
} catch {
// A resolver that is down or blocked leaves the well-known lookup.
}
return null;
}
/**
* A handle's DID from the well-known document it serves, or null. Reachable
* only for a handle whose host allows this origin to read it.
* @param {string} handle
* @returns {Promise<string|null>}
*/
async function didFromWellKnown(handle) {
try {
const response = await fetch(`https://${handle}/.well-known/atproto-did`);
if (!response.ok) return null;
const did = (await response.text()).trim();
return did.startsWith('did:') ? did : null;
} catch {
return null;
}
}
/**
* Resolve a handle or DID to the account it names.
*
* A handle resolved through DNS is checked against the document's own
* `alsoKnownAs`, so a TXT record naming somebody else's DID resolves to
* nothing rather than to them.
*
* @param {string} input - a handle, a DID, or either with an `@` or `at://`
* @returns {Promise<Identity>}
*/
export async function resolveIdentity(input) {
const cleaned = input
.trim()
.replace(/^at:\/\//, '')
.replace(/^@/, '')
.replace(/\/$/, '')
.toLowerCase();
if (!cleaned) throw new Error('Enter a handle or a DID.');
if (cleaned.startsWith('did:')) {
const doc = await resolveDid(cleaned);
return {
did: cleaned,
handle: documentHandle(doc),
pds: pdsEndpoint(doc),
doc,
};
}
const did = await didForHandle(cleaned);
if (!did) throw new Error(`Could not resolve the handle ${cleaned}.`);
const doc = await resolveDid(did);
const claimed = (doc.alsoKnownAs || []).map((name) =>
name.replace(/^at:\/\//, '').toLowerCase(),
);
if (!claimed.includes(cleaned)) {
throw new Error(`${did} does not claim the handle ${cleaned}.`);
}
return { did, handle: cleaned, pds: pdsEndpoint(doc), doc };
}