Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175/** * The DID document: minted for `did:web`, fetched and checked for `did:plc`. * * The shape resolvers accept is narrow (docs/RESOLUTION.md, "Serving details that fail * confusingly"), and a document that is subtly wrong fails on somebody else's login rather than * on this machine. So whatever we emit is round-tripped back through `@atproto/identity`'s own * parser before the build is allowed to finish. */
import { didDocument, parseToAtprotoDocument } from '@atproto/identity'import { message } from '../message.js'import { BuildError } from './errors.js'
const PLC_URL = 'https://plc.directory'
/** * The host a `did:web` names, with its port decoded. Rejects the path form, which atproto does * not support, and a port on anything but localhost, which the spec does not allow and the two * halves of the ecosystem disagree about. * * @param {string} did * @returns {string} */export function didWebHost(did) { const rest = did.slice('did:web:'.length) if (rest.includes(':')) { throw new BuildError(`${did} has a path; atproto supports only did:web:<host> (a port must be percent-encoded)`) } const host = decodeURIComponent(rest) if (!host || host.startsWith('.') || host.includes('/')) throw new BuildError(`${did} does not name a host`)
const url = parseHost(did, host) if (url.port && url.hostname !== 'localhost') { throw new BuildError(`${did} names a port, which is allowed only for localhost`, [ 'The atproto DID specification (https://atproto.com/specs/did) says:', '', ' "Port numbers (with separating colon hex-encoded) are only allowed for `localhost`,', ' and only in testing and development."', '', 'This is not our rule, and implementations disagree about it in the dangerous direction:', '@atproto/identity accepts a port on any host, while goat rejects any port at all. A', `port-bearing DID would therefore deploy cleanly, resolve for TypeScript clients, and be`, 'invisible to the Go half of the ecosystem. Serve the authority on 443 and drop the port.', ]) } return host}
/** * Whether a DID is the local development form: `did:web:localhost%3A<port>`, which the spec * permits for testing and nothing else. * * @param {string} did * @returns {boolean} */export function isDevelopmentDid(did) { if (!did.startsWith('did:web:')) return false const host = decodeURIComponent(did.slice('did:web:'.length)) return host.split(':')[0].toLowerCase() === 'localhost'}
/** * @param {string} did * @param {string} host */function parseHost(did, host) { try { return new URL(`https://${host}`) } catch { throw new BuildError(`${did} does not name a host`) }}
/** * The default service endpoint for a `did:web`: the same origin the document is served from. * Mirrors `DidWebResolver`, which downgrades localhost to http and uses https for everything else. * * @param {string} did * @returns {string} */export function defaultServiceEndpoint(did) { const url = new URL(`https://${didWebHost(did)}`) if (url.hostname === 'localhost') url.protocol = 'http' return url.origin}
/** * @param {{ did: string, signingKey: string, service?: string }} opts signingKey is a did:key * @returns {Record<string, unknown>} */export function buildDidWebDocument({ did, signingKey, service }) { return { '@context': [ 'https://www.w3.org/ns/did/v1', 'https://w3id.org/security/multikey/v1', 'https://w3id.org/security/suites/secp256k1-2019/v1', ], id: did, // A lexicon authority has no handle and needs none; stream.place serves the same. alsoKnownAs: [], verificationMethod: [ { id: `${did}#atproto`, type: 'Multikey', controller: did, // A did:key is the multikey with a prefix, and a Multikey verificationMethod wants the // multikey alone. publicKeyMultibase: signingKey.slice('did:key:'.length), }, ], service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: service ?? defaultServiceEndpoint(did), }, ], }}
/** * @param {string} did * @param {{ fetch?: typeof globalThis.fetch, plcUrl?: string }} [opts] * @returns {Promise<Record<string, unknown>>} */export async function fetchPlcDocument(did, opts = {}) { const url = new URL(`/${encodeURIComponent(did)}`, opts.plcUrl ?? PLC_URL) const res = await (opts.fetch ?? globalThis.fetch)(url, { redirect: 'error', headers: { accept: 'application/did+ld+json,application/json' }, }).catch((err) => { throw new BuildError(`could not reach ${url}: ${message(err)}`) }) if (!res.ok) throw new BuildError(`${url} returned HTTP ${res.status}; is ${did} registered?`) return /** @type {Record<string, unknown>} */ (await res.json())}
/** * Self-check: parse the document exactly as a resolver would and confirm it says what we meant. * * @param {Record<string, unknown>} doc * @param {{ did: string, signingKey: string, service?: string }} expect signingKey is a did:key * @returns {string} the service endpoint the document actually publishes */export function checkDidDocument(doc, expect) { const parsed = didDocument.safeParse(doc) if (!parsed.success) { const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) throw new BuildError('the DID document is malformed', issues) }
const atproto = parseToAtprotoDocument(parsed.data) /** @type {string[]} */ const problems = [] if (atproto.did !== expect.did) problems.push(`id is ${atproto.did}, expected ${expect.did}`) if (!atproto.signingKey) { problems.push( 'no usable verificationMethod: it needs id `<did>#atproto` or `#atproto`, and a publicKeyMultibase', ) } else if (atproto.signingKey !== expect.signingKey) { problems.push(`names signing key ${atproto.signingKey}, but the build signed with ${expect.signingKey}`) } if (!atproto.pds) { problems.push( 'no usable service: it needs id `#atproto_pds`, type `AtprotoPersonalDataServer` and an ' + 'http(s) serviceEndpoint', ) } else if (expect.service && atproto.pds !== expect.service) { problems.push(`publishes service endpoint ${atproto.pds}, expected ${expect.service}`) } if (problems.length) throw new BuildError('the DID document would not resolve as an atproto identity', problems)
return /** @type {string} */ (atproto.pds)}