import { isIP } from 'node:net'
import type { Record as WispSettings } from '@wispplace/lexicons/types/place/wisp/settings'
import { isLocalhostFetchAllowed, type SafeFetchOptions, safeFetchJson } from '@wispplace/safe-fetch'
import { getSiteSettingsCache } from './db'
const DEFAULT_HANDLE_RESOLVER_URL = 'https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle'
const DEFAULT_PLC_DIRECTORY_URL = 'https://plc.directory'
// did:plc identifiers are fixed-width lowercase base32 (RFC 4648 without 0/1/8/9).
const PLC_DID_PATTERN = /^did:plc:[a-z2-7]{24}$/
type JsonFetcher = typeof safeFetchJson
interface DidDocument {
alsoKnownAs?: unknown[]
}
interface IdentityResolutionOptions {
/** Test seam. Production requests use SSRF-hardened safeFetchJson. */
fetchJson?: JsonFetcher
fetchOptions?: SafeFetchOptions
handleResolverUrl?: string
plcDirectoryUrl?: string
}
function environment(name: string): string | undefined {
return process.env[name] || (typeof Bun !== 'undefined' ? Bun.env[name] : undefined)
}
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
}
function isDnsHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
if (normalized.length === 0 || normalized.length > 253 || isIP(normalized) !== 0) return false
const withoutTrailingDot = normalized.endsWith('.') ? normalized.slice(0, -1) : normalized
if (!withoutTrailingDot) return false
return withoutTrailingDot.split('.').every((label) => {
return label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)
})
}
function isLoopbackDevelopmentHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized === '127.0.0.1' ||
normalized === '::1'
)
}
function isValidHandle(handle: string): boolean {
if (handle.length < 3 || handle.length > 253 || handle !== handle.toLowerCase()) return false
if (!handle.includes('.') || handle.includes('..') || handle.endsWith('.')) return false
return isDnsHostname(handle)
}
function parseDidWeb(did: string): { authority: string; pathSegments: string[] } | null {
if (!did.startsWith('did:web:') || did.length > 2048) return null
const parts = did.slice('did:web:'.length).split(':')
const encodedAuthority = parts.shift()
if (!encodedAuthority || parts.some((part) => part === '.' || part === '..' || !/^[A-Za-z0-9._~-]+$/.test(part))) {
return null
}
// did:web represents an explicit port as %3A. Reject other escapes so a
// method-specific identifier cannot smuggle a different URL authority.
if (/%(?!3a)/i.test(encodedAuthority)) return null
const authority = encodedAuthority.replace(/%3a/gi, ':')
try {
const url = new URL(`https://${authority}`)
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null
if (!isDnsHostname(url.hostname)) return null
return { authority: url.host, pathSegments: parts }
} catch {
return null
}
}
/** Strictly accepts the DID methods this hosting service resolves. */
function isValidDid(did: string): boolean {
return PLC_DID_PATTERN.test(did) || parseDidWeb(did) !== null
}
/** Strictly accepts a supported DID or canonical AT Protocol handle. */
export function isValidAtprotoIdentifier(identifier: string): boolean {
return isValidDid(identifier) || isValidHandle(identifier)
}
/** Convert a validated did:web identifier to its HTTPS DID document URL. */
export function didWebToHttps(did: string): string {
const parsed = parseDidWeb(did)
if (!parsed) throw new Error('Invalid did:web format')
const base = `https://${parsed.authority}`
if (parsed.pathSegments.length === 0) return `${base}/.well-known/did.json`
return `${base}/${parsed.pathSegments.map((part) => encodeURIComponent(part)).join('/')}/did.json`
}
function parseIdentityEndpoint(value: string, name: string): URL {
let url: URL
try {
url = new URL(value)
} catch {
throw new Error(`Invalid ${name} URL`)
}
if (url.username || url.password || url.search || url.hash || !url.hostname) {
throw new Error(`Invalid ${name} URL`)
}
if (/%(?:2f|5c|00)/i.test(url.pathname)) throw new Error(`Invalid ${name} URL path`)
const hostname = normalizeHostname(url.hostname)
if (!isDnsHostname(hostname) && isIP(hostname) === 0) throw new Error(`Invalid ${name} hostname`)
const localHttp = isLocalhostFetchAllowed() && isLoopbackDevelopmentHostname(hostname)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && localHttp)) {
throw new Error(`${name} must use HTTPS`)
}
return url
}
function configuredIdentityEndpoint(configured: string | undefined, fallback: string, name: string): string {
const url = parseIdentityEndpoint(configured || fallback, name)
url.pathname = url.pathname.replace(/\/+$/, '') || '/'
return url.toString()
}
function identityFetchOptions(options: IdentityResolutionOptions): SafeFetchOptions {
const fetchOptions = { ...(options.fetchOptions || {}) }
if (isLocalhostFetchAllowed()) fetchOptions.allowLocalhost = true
return fetchOptions
}
async function fetchJson<T>(url: string, options: IdentityResolutionOptions): Promise<T> {
const fetcher = options.fetchJson || safeFetchJson
return fetcher<T>(url, identityFetchOptions(options))
}
function parseDidDocument(value: unknown): DidDocument | null {
if (typeof value !== 'object' || value === null) return null
const document = value as DidDocument
if (document.alsoKnownAs !== undefined && !Array.isArray(document.alsoKnownAs)) return null
return document
}
function plcDocumentUrl(directory: string, did: string): string {
const url = new URL(directory)
url.pathname = `${url.pathname.replace(/\/+$/, '')}/${encodeURIComponent(did)}`
return url.toString()
}
/** Fetch a validated DID document with the pinned safe-fetch transport. */
export async function getDidDocument(
did: string,
options: IdentityResolutionOptions = {},
): Promise<DidDocument | null> {
if (!isValidDid(did)) return null
try {
const url = did.startsWith('did:plc:')
? plcDocumentUrl(
configuredIdentityEndpoint(
options.plcDirectoryUrl || environment('WISP_PLC_DIRECTORY_URL'),
DEFAULT_PLC_DIRECTORY_URL,
'PLC directory',
),
did,
)
: didWebToHttps(did)
return parseDidDocument(await fetchJson<unknown>(url, options))
} catch {
return null
}
}
function didDocumentIncludesHandle(document: DidDocument, handle: string): boolean {
const expected = `at://${handle}`
return (document.alsoKnownAs || []).some((value) => typeof value === 'string' && value.toLowerCase() === expected)
}
/**
* Resolve a handle only after strict handle validation, then bind the result to
* the handle declared in the fetched DID document.
*/
export async function resolveDid(identifier: string, options: IdentityResolutionOptions = {}): Promise<string | null> {
if (isValidDid(identifier)) return identifier
if (!isValidHandle(identifier)) return null
try {
const resolverUrl = new URL(
configuredIdentityEndpoint(
options.handleResolverUrl || environment('WISP_HANDLE_RESOLVER_URL'),
DEFAULT_HANDLE_RESOLVER_URL,
'handle resolver',
),
)
resolverUrl.searchParams.set('handle', identifier)
const result = await fetchJson<{ did?: unknown }>(resolverUrl.toString(), options)
if (!result || typeof result.did !== 'string' || !isValidDid(result.did)) return null
const document = await getDidDocument(result.did, options)
return document && didDocumentIncludesHandle(document, identifier) ? result.did : null
} catch {
return null
}
}
export async function getCachedSettings(did: string, rkey: string): Promise<WispSettings | null> {
const cached = await getSiteSettingsCache(did, rkey)
if (!cached) return null
return {
$type: 'place.wisp.settings',
directoryListing: cached.directory_listing,
spaMode: cached.spa_mode ?? undefined,
custom404: cached.custom_404 ?? undefined,
indexFiles: cached.index_files ?? undefined,
cleanUrls: cached.clean_urls,
headers: cached.headers ?? undefined,
}
}