diff --git a/package.json b/package.json index 5fe17d40..719407f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.216", + "version": "0.18.217", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/app.js b/src/js/app.js index d70c1668..85a1b958 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -36,7 +36,7 @@ import bookmarksView from "/js/views/bookmarks.view.js"; import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { DraftMediaStore } from "/js/drafts.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; -import { IdentityResolver } from "/js/atproto.js"; +import { identityResolver } from "/js/atproto.js"; import { Router } from "/js/router.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; @@ -98,7 +98,6 @@ export async function main() { chatAppViewServiceDid: appViewConfig.chatServiceDid, }); const preferencesProvider = new PreferencesProvider(api); - const identityResolver = new IdentityResolver(); const draftMediaStore = new DraftMediaStore(); const hiddenFeedItemsStore = new HiddenFeedItemsStore(); const constellation = new Constellation(); diff --git a/src/js/atproto.js b/src/js/atproto.js index 95c7201c..71c896ab 100644 --- a/src/js/atproto.js +++ b/src/js/atproto.js @@ -1,18 +1,35 @@ import { HANDLE_RESOLVER_SERVICE_URL, PLC_DIRECTORY_URL } from "/js/config.js"; +import { fetchWithTimeout, isValidDid } from "/js/utils.js"; +import { Slingshot } from "/js/slingshot.js"; const PDS_SERVICE_ID = "#atproto_pds"; +export class HandleNotFoundError extends Error { + constructor(message) { + super(message); + this.name = "HandleNotFoundError"; + } +} + +export function findServiceEndpointInDidDoc( + didDoc, + serviceId = PDS_SERVICE_ID, +) { + const service = didDoc?.service?.find((s) => s.id === serviceId); + return service?.serviceEndpoint ?? null; +} + export function getServiceEndpointFromDidDoc( didDoc, serviceId = PDS_SERVICE_ID, ) { - const service = didDoc.service?.find((s) => s.id === serviceId); - if (!service) { + const endpoint = findServiceEndpointInDidDoc(didDoc, serviceId); + if (!endpoint) { throw new Error( `No ${serviceId} service found in DID doc ${JSON.stringify(didDoc)}`, ); } - return service.serviceEndpoint; + return endpoint; } export function didDocReferencesHandle(didDoc, handle) { @@ -21,110 +38,217 @@ export function didDocReferencesHandle(didDoc, handle) { return aliases.includes(atHandle); } -const RESOLVE_HANDLE_TIMEOUT_MS = 5000; +const RESOLVE_TIMEOUT_MS = 5000; export async function resolveHandle(handle) { const params = new URLSearchParams({ handle, }); - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - RESOLVE_HANDLE_TIMEOUT_MS, + const res = await fetchWithTimeout( + `${HANDLE_RESOLVER_SERVICE_URL}/xrpc/com.atproto.identity.resolveHandle?` + + params.toString(), + { timeoutMs: RESOLVE_TIMEOUT_MS, label: `resolveHandle "${handle}"` }, ); - let res; - try { - res = await fetch( - `${HANDLE_RESOLVER_SERVICE_URL}/xrpc/com.atproto.identity.resolveHandle?` + - params.toString(), - { signal: controller.signal }, - ); - } catch (error) { - if (controller.signal.aborted) { - throw new Error( - `resolveHandle: timed out after ${RESOLVE_HANDLE_TIMEOUT_MS}ms resolving "${handle}"`, - ); - } - throw error; - } finally { - clearTimeout(timeoutId); + if (res.status === 400) return null; + if (!res.ok) { + throw new Error(`resolveHandle "${handle}": HTTP ${res.status}`); } const data = await res.json(); - return data.did ?? null; + return isValidDid(data?.did) ? data.did : null; } +// Returns null for a DID that isn't registered (or has been tombstoned), +// throws otherwise export async function resolveDid(did) { + let url; if (did.startsWith("did:plc:")) { - const res = await fetch(`${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}`); - const didDoc = await res.json(); - return didDoc; + url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}`; } else if (did.startsWith("did:web:")) { - const website = did.split(":")[2]; - const res = await fetch(`https://${website}/.well-known/did.json`); - const didDoc = await res.json(); - return didDoc; + url = `https://${did.split(":")[2]}/.well-known/did.json`; } else { throw new Error(`Unsupported DID: ${did}`); } + const res = await fetchWithTimeout(url, { + timeoutMs: RESOLVE_TIMEOUT_MS, + label: `resolveDid "${did}"`, + }); + if (res.status === 404 || res.status === 410) return null; + if (!res.ok) { + throw new Error(`resolveDid "${did}": HTTP ${res.status}`); + } + return await res.json(); } export async function resolveIdentity(handle) { const did = await resolveHandle(handle); if (!did) return null; const didDoc = await resolveDid(did); + if (!didDoc) return null; if (!didDocReferencesHandle(didDoc, handle)) { throw new Error(`DID doc for ${did} does not reference handle: ${handle}`); } return { did, didDoc }; } -export async function getServiceEndpointForHandle(handle) { - const result = await resolveIdentity(handle); - if (!result) { - throw new HandleNotFoundError("DID not found for handle: " + handle); +let slingshotClient = null; + +function getSlingshot() { + slingshotClient ??= new Slingshot(); + return slingshotClient; +} + +const DEFAULT_HANDLE_PROVIDERS = [ + { name: "bluesky", resolve: (handle) => resolveHandle(handle) }, + { + name: "slingshot", + resolve: (handle) => getSlingshot().resolveHandle(handle), + }, +]; + +async function resolveHandleWithFallback(handle, providers) { + let lastError = null; + for (const provider of providers) { + try { + return await provider.resolve(handle); + } catch (error) { + lastError = error; + console.debug( + `[IdentityResolver] provider "${provider.name}" could not resolve "${handle}"`, + error, + ); + } } - return getServiceEndpointFromDidDoc(result.didDoc); + throw lastError ?? new Error(`resolveHandle: no providers for "${handle}"`); } +function miniDocMatchesIdentifier(miniDoc, identifier) { + if (isValidDid(identifier)) { + return miniDoc.did === identifier; + } + return miniDoc.handle?.toLowerCase() === identifier.toLowerCase(); +} + +// Resolves a handle or DID to its DID and PDS endpoint. +export async function resolveIdentityEndpoint(handleOrDid) { + try { + const miniDoc = await getSlingshot().resolveMiniDoc(handleOrDid); + if (miniDocMatchesIdentifier(miniDoc, handleOrDid)) { + return { did: miniDoc.did, pds: miniDoc.pds }; + } + console.debug( + `[resolveIdentityEndpoint] slingshot returned a mismatched identity for "${handleOrDid}"`, + miniDoc, + ); + } catch (error) { + console.debug( + `[resolveIdentityEndpoint] slingshot could not resolve "${handleOrDid}"`, + error, + ); + } + if (isValidDid(handleOrDid)) { + const didDoc = await resolveDid(handleOrDid); + const pds = findServiceEndpointInDidDoc(didDoc); + return pds ? { did: handleOrDid, pds } : null; + } + const result = await resolveIdentity(handleOrDid); + if (!result) return null; + const pds = findServiceEndpointInDidDoc(result.didDoc); + return pds ? { did: result.did, pds } : null; +} + +const HANDLE_NOT_FOUND_TTL_MS = 30_000; +const ENDPOINT_TTL_MS = 300_000; + export class IdentityResolver { - constructor() { + constructor({ + providers = DEFAULT_HANDLE_PROVIDERS, + notFoundTtlMs = HANDLE_NOT_FOUND_TTL_MS, + endpointTtlMs = ENDPOINT_TTL_MS, + } = {}) { + this.providers = providers; + this.notFoundTtlMs = notFoundTtlMs; + this.endpointTtlMs = endpointTtlMs; this.handleToDidMap = new Map(); + this.endpointCache = new Map(); + this.notFoundAt = new Map(); + this.inFlight = new Map(); + this.endpointInFlight = new Map(); + } + + _isNotFound(identifier) { + const notFoundAt = this.notFoundAt.get(identifier); + if (notFoundAt == null) return false; + if (Date.now() - notFoundAt < this.notFoundTtlMs) return true; + this.notFoundAt.delete(identifier); + return false; + } + + _dedupe(inFlight, identifier, start) { + const existing = inFlight.get(identifier); + if (existing) return existing; + const resolution = start().finally(() => inFlight.delete(identifier)); + inFlight.set(identifier, resolution); + return resolution; } async resolveHandle(handle) { if (this.handleToDidMap.has(handle)) { return this.handleToDidMap.get(handle); } - console.debug("[IdentityResolver] Resolving handle", handle); - const did = await resolveHandle(handle); - this.handleToDidMap.set(handle, did); - return did; + if (this._isNotFound(handle)) return null; + return this._dedupe(this.inFlight, handle, () => { + console.debug("[IdentityResolver] Resolving handle", handle); + return resolveHandleWithFallback(handle, this.providers).then((did) => { + if (did) { + this.handleToDidMap.set(handle, did); + } else { + this.notFoundAt.set(handle, Date.now()); + } + return did; + }); + }); + } + + async resolveEndpoint(handleOrDid) { + const cached = this.endpointCache.get(handleOrDid); + if (cached && Date.now() - cached.at < this.endpointTtlMs) { + return cached.result; + } + if (this._isNotFound(handleOrDid)) return null; + return this._dedupe(this.endpointInFlight, handleOrDid, () => { + console.debug("[IdentityResolver] Resolving endpoint", handleOrDid); + return resolveIdentityEndpoint(handleOrDid).then((result) => { + if (result) { + this.endpointCache.set(handleOrDid, { at: Date.now(), result }); + if (!isValidDid(handleOrDid)) { + this.handleToDidMap.set(handleOrDid, result.did); + } + } else { + this.notFoundAt.set(handleOrDid, Date.now()); + } + return result; + }); + }); } setDidForHandle(handle, did) { + this.notFoundAt.delete(handle); + this.endpointCache.delete(handle); this.handleToDidMap.set(handle, did); } } -const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; -const NSID_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; -const RKEY_PATTERN = /^[a-zA-Z0-9._~:-]{1,512}$/; - -export function isValidDid(value) { - return typeof value === "string" && DID_PATTERN.test(value); -} +export const identityResolver = new IdentityResolver(); -export function isValidNsid(value) { - return typeof value === "string" && NSID_PATTERN.test(value); -} - -export function isValidRkey(value) { - return ( - typeof value === "string" && - value !== "." && - value !== ".." && - RKEY_PATTERN.test(value) - ); +export async function getServiceEndpointForHandle( + handle, + resolver = identityResolver, +) { + const result = await resolver.resolveEndpoint(handle); + if (!result) { + throw new HandleNotFoundError("DID not found for handle: " + handle); + } + return result.pds; } const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"; diff --git a/src/js/auth.js b/src/js/auth.js index ce2bb704..5fef6627 100644 --- a/src/js/auth.js +++ b/src/js/auth.js @@ -1,4 +1,7 @@ -import { getServiceEndpointForHandle } from "/js/atproto.js"; +import { + getServiceEndpointForHandle, + identityResolver as defaultIdentityResolver, +} from "/js/atproto.js"; import { OauthClient, HandleNotFoundError, @@ -138,9 +141,10 @@ export class BasicAuthSession { } export class BasicAuthProvider { - constructor() { + constructor({ identityResolver = defaultIdentityResolver } = {}) { this.session = null; this._loaded = false; + this.identityResolver = identityResolver; } async getSession(did = null) { @@ -153,7 +157,10 @@ export class BasicAuthProvider { } async login({ handle, password }) { - const serviceEndpoint = await getServiceEndpointForHandle(handle); + const serviceEndpoint = await getServiceEndpointForHandle( + handle, + this.identityResolver, + ); const res = await fetch( serviceEndpoint + "/xrpc/com.atproto.server.createSession", { @@ -201,8 +208,9 @@ export class BasicAuthProvider { } export class OAuthProvider { - constructor() { + constructor({ identityResolver = defaultIdentityResolver } = {}) { this._client = null; + this.identityResolver = identityResolver; } async getClient() { @@ -210,6 +218,7 @@ export class OAuthProvider { this._client = await OauthClient.load({ clientId: `https://${window.env.hostName}/oauth-client-metadata.json`, redirectUri: `https://${window.env.hostName}/callback.html`, + identityResolver: this.identityResolver, }); } return this._client; diff --git a/src/js/components/plugin-blob-image.js b/src/js/components/plugin-blob-image.js index c696344b..d246635f 100644 --- a/src/js/components/plugin-blob-image.js +++ b/src/js/components/plugin-blob-image.js @@ -2,8 +2,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { buildCdnUrl } from "/js/dataHelpers.js"; +import { isValidDid } from "/js/utils.js"; -const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; const CID_PATTERN = /^b[a-z2-7]{20,}$/; function safeBuildCdnUrl(prefix, did, cid) { @@ -14,10 +14,6 @@ function safeBuildCdnUrl(prefix, did, cid) { } } -function isValidDid(did) { - return typeof did === "string" && DID_PATTERN.test(did); -} - function isValidCid(cid) { return typeof cid === "string" && CID_PATTERN.test(cid); } diff --git a/src/js/oauth.js b/src/js/oauth.js index 44aaf98f..78102f85 100644 --- a/src/js/oauth.js +++ b/src/js/oauth.js @@ -1,6 +1,11 @@ -import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js"; +import { + HandleNotFoundError, + identityResolver as defaultIdentityResolver, +} from "/js/atproto.js"; import { KVIndexedDB } from "/js/utils.js"; +export { HandleNotFoundError } from "/js/atproto.js"; + // Inspiration from: // https://www.npmjs.com/package/@atproto/oauth-client-browser // https://www.npmjs.com/package/@atcute/oauth-browser-client @@ -565,13 +570,6 @@ class AuthServer { } } -export class HandleNotFoundError extends Error { - constructor(message) { - super(message); - this.name = "HandleNotFoundError"; - } -} - export class InvalidAuthUrlError extends Error { constructor(message) { super(message); @@ -580,30 +578,36 @@ export class InvalidAuthUrlError extends Error { } export class OauthClient { - constructor({ clientId, redirectUri, dpopKeypair }) { + constructor({ + clientId, + redirectUri, + dpopKeypair, + identityResolver = defaultIdentityResolver, + }) { this.clientId = clientId; this.redirectUri = redirectUri; this.dpopRequests = new DPoPRequests(dpopKeypair); this.sessionsByDid = new Map(); + this.identityResolver = identityResolver; } - static async load({ clientId, redirectUri }) { + static async load({ clientId, redirectUri, identityResolver }) { const dpopKeypair = await loadOrGenerateDPoPKeypair(); migrateLegacySession(); return new OauthClient({ clientId, redirectUri, dpopKeypair, + identityResolver, }); } async getAuthorizationUrl(handle, { scope = "atproto", state = {} } = {}) { - const result = await resolveIdentity(handle); + const result = await this.identityResolver.resolveEndpoint(handle); if (!result) { throw new HandleNotFoundError("DID not found for handle: " + handle); } - const { did, didDoc } = result; - const pdsEndpoint = getServiceEndpointFromDidDoc(didDoc); + const { did, pds: pdsEndpoint } = result; const resourceMetadata = await fetchResourceServerMetadata(pdsEndpoint); if ( !resourceMetadata.authorization_servers || diff --git a/src/js/push/pushNotificationService.js b/src/js/push/pushNotificationService.js index 3395e7cd..bb4be873 100644 --- a/src/js/push/pushNotificationService.js +++ b/src/js/push/pushNotificationService.js @@ -13,6 +13,9 @@ const NOTIF_SERVICE_ID = "#bsky_notif"; async function resolveNotifServiceEndpoint(did) { const doc = await resolveDid(did); + if (!doc) { + throw new Error(`Notification service DID ${did} could not be resolved`); + } const endpoint = getServiceEndpointFromDidDoc(doc, NOTIF_SERVICE_ID); if (!endpoint) { throw new Error( diff --git a/src/js/slingshot.js b/src/js/slingshot.js index 00b678e1..f0ff4082 100644 --- a/src/js/slingshot.js +++ b/src/js/slingshot.js @@ -1,9 +1,26 @@ import { SLINGSHOT_URL } from "/js/config.js"; -import { isValidDid, isValidNsid, isValidRkey } from "/js/atproto.js"; +import { + fetchWithTimeout, + isValidDid, + isValidHandle, + isValidNsid, + isValidRkey, +} from "/js/utils.js"; + +const REQUEST_TIMEOUT_MS = 5000; export class Slingshot { - constructor({ fetchImpl } = {}) { - this.fetchImpl = fetchImpl ?? ((url) => globalThis.fetch(url)); + constructor({ fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS } = {}) { + this.fetchImpl = fetchImpl ?? null; + this.timeoutMs = timeoutMs; + } + + _fetch(url, label) { + return fetchWithTimeout(url, { + timeoutMs: this.timeoutMs, + label, + fetchImpl: this.fetchImpl, + }); } async getRecord({ repo, collection, rkey }) { @@ -18,7 +35,7 @@ export class Slingshot { } const params = new URLSearchParams({ repo, collection, rkey }); const url = `${SLINGSHOT_URL}/xrpc/com.atproto.repo.getRecord?${params.toString()}`; - const res = await this.fetchImpl(url); + const res = await this._fetch(url, "getRecord"); if (res.status === 400) { const data = await res.json().catch(() => null); if (data?.error === "RecordNotFound") return null; @@ -31,4 +48,48 @@ export class Slingshot { } return await res.json(); } + + async resolveHandle(handle) { + if (!isValidHandle(handle)) { + throw new Error(`resolveHandle: invalid handle "${handle}"`); + } + const params = new URLSearchParams({ handle }); + const url = `${SLINGSHOT_URL}/xrpc/com.atproto.identity.resolveHandle?${params.toString()}`; + const data = await this._readJson(url, "resolveHandle"); + if (!isValidDid(data?.did)) { + throw new Error(`resolveHandle: no DID in response for "${handle}"`); + } + return data.did; + } + + async resolveMiniDoc(identifier) { + if (!isValidHandle(identifier) && !isValidDid(identifier)) { + throw new Error(`resolveMiniDoc: invalid identifier "${identifier}"`); + } + const params = new URLSearchParams({ identifier }); + const url = `${SLINGSHOT_URL}/xrpc/blue.microcosm.identity.resolveMiniDoc?${params.toString()}`; + const data = await this._readJson(url, "resolveMiniDoc"); + if (!isValidDid(data?.did) || !data?.pds) { + throw new Error( + `resolveMiniDoc: incomplete document for "${identifier}"`, + ); + } + return { + did: data.did, + handle: data.handle ?? null, + pds: data.pds, + signingKey: data.signing_key ?? null, + }; + } + + async _readJson(url, label) { + const res = await this._fetch(url, label); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error( + `${label}: HTTP ${res.status} ${data?.error ?? ""} ${data?.message ?? ""}`.trim(), + ); + } + return await res.json(); + } } diff --git a/src/js/tangled.js b/src/js/tangled.js index 175e2593..67d32947 100644 --- a/src/js/tangled.js +++ b/src/js/tangled.js @@ -1,4 +1,4 @@ -import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js"; +import { identityResolver as defaultIdentityResolver } from "/js/atproto.js"; import { KVIndexedDB } from "/js/utils.js"; export function decodeTangledBlobContent(data, file) { @@ -56,9 +56,10 @@ const REPO_INFO_REVALIDATE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // Resolves an "/" path to the {knot, repoDid} pair its // blobs are served from. export class TangledResolver { - constructor() { + constructor(identityResolver = defaultIdentityResolver) { this._pending = new Map(); this._store = new KVIndexedDB("tangled-repo-info", "repoInfoByPath"); + this.identityResolver = identityResolver; } async resolveRepoInfo(path) { @@ -104,11 +105,11 @@ export class TangledResolver { const ownerHandle = path.slice(0, slashIndex); const repoName = path.slice(slashIndex + 1); - const identity = await resolveIdentity(ownerHandle); + const identity = await this.identityResolver.resolveEndpoint(ownerHandle); if (!identity) { throw new Error(`Could not resolve tangled repo owner "${ownerHandle}"`); } - const pds = getServiceEndpointFromDidDoc(identity.didDoc); + const pds = identity.pds; const record = await findRepoRecord(pds, identity.did, repoName); if (!record) { diff --git a/src/js/utils.js b/src/js/utils.js index 63437390..c9295bf9 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -726,6 +726,37 @@ export class KVIndexedDB { } } +const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; +const NSID_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; +const HANDLE_PATTERN = + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; +const RKEY_PATTERN = /^[a-zA-Z0-9._~:-]{1,512}$/; + +export function isValidDid(value) { + return typeof value === "string" && DID_PATTERN.test(value); +} + +export function isValidHandle(value) { + return ( + typeof value === "string" && + value.length <= 253 && + HANDLE_PATTERN.test(value) + ); +} + +export function isValidNsid(value) { + return typeof value === "string" && NSID_PATTERN.test(value); +} + +export function isValidRkey(value) { + return ( + typeof value === "string" && + value !== "." && + value !== ".." && + RKEY_PATTERN.test(value) + ); +} + export class TimeoutError extends Error { constructor(message = "Timed out") { super(message); @@ -749,6 +780,30 @@ export async function withTimeout(fn, timeoutMs) { } } +export async function fetchWithTimeout( + url, + { timeoutMs, label = "fetch", fetchImpl = null } = {}, +) { + const doFetch = + fetchImpl ?? ((input, options) => globalThis.fetch(input, options)); + const controller = new AbortController(); + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + try { + return await doFetch(url, { signal: controller.signal }); + } catch (error) { + if (timedOut && error?.name === "AbortError") { + throw new TimeoutError(`${label}: timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + export function pinScrollPosition({ targetY, durationMs = 1000, diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 55c45dd8..79d508aa 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -107,6 +107,46 @@ export class MockServer { this.notificationServiceDid = null; this.registerPushCalls = []; this.unregisterPushCalls = []; + this.slingshotUnreachable = false; + this.pdsEndpoint = "http://localhost:8081"; + } + + // Make slingshot fail to resolve identities, so the app falls back to + // resolving handles and DID docs through standard atproto infrastructure. + failSlingshotLookup() { + this.slingshotUnreachable = true; + } + + // Every actor the mocks know about, in the precedence order the identity + // endpoints resolve them in. + _knownIdentities() { + const identities = []; + const add = (actor) => { + if (actor?.did && actor?.handle) { + identities.push({ did: actor.did, handle: actor.handle }); + } + }; + [ + ...this.timelinePosts, + ...this.bookmarks, + ...this.searchPosts, + ...this.posts, + ].forEach((post) => add(post.author)); + this.feedGenerators.forEach((generator) => add(generator.creator)); + this.lists.forEach((list) => add(list.creator)); + this.starterPacks.forEach((starterPack) => add(starterPack.creator)); + this.profiles.forEach((profile) => add(profile)); + return identities; + } + + _findIdentity(handleOrDid) { + if (!handleOrDid) return null; + return ( + this._knownIdentities().find( + (identity) => + identity.handle === handleOrDid || identity.did === handleOrDid, + ) ?? null + ); } // Make subsequent OAuth token refreshes fail with a non-retryable error, @@ -2188,32 +2228,8 @@ export class MockServer { await page.route("**/xrpc/com.atproto.identity.resolveHandle*", (route) => { const url = new URL(route.request().url()); const handle = url.searchParams.get("handle"); - const allPosts = [ - ...this.timelinePosts, - ...this.bookmarks, - ...this.searchPosts, - ...this.posts, - ]; - const postAuthor = allPosts.find( - (p) => p.author?.handle === handle, - )?.author; - const generator = this.feedGenerators.find( - (g) => g.creator.handle === handle, - ); - const list = this.lists.find((l) => l.creator?.handle === handle); - const starterPack = this.starterPacks.find( - (s) => s.creator?.handle === handle, - ); - const profileEntry = [...this.profiles.values()].find( - (p) => p.handle === handle, - ); - const did = - postAuthor?.did || - generator?.creator?.did || - list?.creator?.did || - starterPack?.creator?.did || - profileEntry?.did; - if (!did) { + const identity = this._findIdentity(handle); + if (!identity) { return route.fulfill({ status: 404, body: JSON.stringify({ error: "NotFound" }), @@ -2222,10 +2238,44 @@ export class MockServer { return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ did }), + body: JSON.stringify({ did: identity.did }), }); }); + // Slingshot is the first identity provider the app tries; set + // slingshotUnreachable to force the atproto fallback path instead. + await page.route( + "**/xrpc/blue.microcosm.identity.resolveMiniDoc*", + (route) => { + const invalidRequest = (message) => + route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: "InvalidRequest", message }), + }); + if (this.slingshotUnreachable) { + return invalidRequest( + "Errored while trying to resolve handle to DID", + ); + } + const url = new URL(route.request().url()); + const identifier = url.searchParams.get("identifier"); + const identity = this._findIdentity(identifier); + if (!identity) { + return invalidRequest("Failed to resolve identity"); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + did: identity.did, + handle: identity.handle, + pds: this.pdsEndpoint, + }), + }); + }, + ); + await page.route("**/xrpc/com.atproto.repo.deleteRecord*", (route) => { const body = route.request().postDataJSON(); const collection = body?.collection; diff --git a/tests/unit/specs/atproto.test.js b/tests/unit/specs/atproto.test.js index d63cb6de..899a368a 100644 --- a/tests/unit/specs/atproto.test.js +++ b/tests/unit/specs/atproto.test.js @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { resolveHandle, resolveIdentity, + resolveIdentityEndpoint, getServiceEndpointForHandle, + HandleNotFoundError, IdentityResolver, computeRecordCid, } from "/js/atproto.js"; @@ -97,8 +99,9 @@ describe("atproto handle resolution", () => { /resolveHandle/, (url, options) => new Promise((resolve, reject) => { + // Rejecting with signal.reason is what a real fetch does options.signal.addEventListener("abort", () => - reject(new Error("aborted")), + reject(options.signal.reason), ); }), ); @@ -123,12 +126,96 @@ describe("atproto handle resolution", () => { }, ], }); - const endpoint = await getServiceEndpointForHandle("alice.example"); + const endpoint = await getServiceEndpointForHandle( + "alice.example", + new IdentityResolver(), + ); assert.deepEqual(endpoint, "https://pds.example.com"); }); }); + describe("identifiers that do not resolve", () => { + function stubMiniDoc(body, status = 200) { + globalThis.fetch.__intercept(/resolveMiniDoc/, async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); + } + + function stubStatus(matcher, status, body = {}) { + globalThis.fetch.__intercept(matcher, async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); + } + + it("reports an unregistered DID as not found", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/plc\.directory/, 404, { message: "DID not registered" }); + assert.deepEqual(await resolveIdentityEndpoint("did:plc:nope"), null); + }); + + it("throws HandleNotFoundError for a DID that does not resolve", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/plc\.directory/, 404, { message: "DID not registered" }); + await assert.rejects( + () => + getServiceEndpointForHandle("did:plc:nope", new IdentityResolver()), + (error) => error instanceof HandleNotFoundError, + ); + }); + + it("reports an identity with no PDS service as not found", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/plc\.directory/, 200, { + alsoKnownAs: ["at://alice.example"], + service: [], + }); + assert.deepEqual(await resolveIdentityEndpoint("did:plc:aaaa"), null); + }); + + it("throws rather than reporting not-found when the directory errors", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/plc\.directory/, 503); + await assert.rejects( + () => resolveIdentityEndpoint("did:plc:aaaa"), + /HTTP 503/, + ); + }); + + it("throws rather than reporting not-found when the handle resolver errors", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/resolveHandle/, 503); + await assert.rejects( + () => resolveIdentityEndpoint("alice.example"), + /HTTP 503/, + ); + }); + + it("reports an unresolvable handle as not found", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubStatus(/resolveHandle/, 400, { error: "InvalidRequest" }); + assert.deepEqual(await resolveIdentityEndpoint("nope.example"), null); + }); + }); + describe("IdentityResolver.resolveHandle", () => { + function makeProvider(name, impl) { + const calls = []; + return { + calls, + provider: { + name, + resolve: (handle) => { + calls.push(handle); + return impl(handle); + }, + }, + }; + } + it("caches the resolved DID", async () => { const did = "did:plc:aaaa"; stubDid(did); @@ -140,6 +227,305 @@ describe("atproto handle resolution", () => { assert.deepEqual(second, did); assert.deepEqual(globalThis.fetch.calls.length, callsAfterFirst); }); + + it("falls back to the next provider when the first one fails", async () => { + const primary = makeProvider("primary", async () => { + throw new Error("slingshot is down"); + }); + const secondary = makeProvider("secondary", async () => "did:plc:bbbb"); + const resolver = new IdentityResolver({ + providers: [primary.provider, secondary.provider], + }); + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:bbbb", + ); + assert.deepEqual(primary.calls, ["alice.example"]); + assert.deepEqual(secondary.calls, ["alice.example"]); + }); + + it("does not consult later providers once one succeeds", async () => { + const primary = makeProvider("primary", async () => "did:plc:aaaa"); + const secondary = makeProvider("secondary", async () => "did:plc:bbbb"); + const resolver = new IdentityResolver({ + providers: [primary.provider, secondary.provider], + }); + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:aaaa", + ); + assert.deepEqual(secondary.calls, []); + }); + + it("throws when every provider fails", async () => { + const primary = makeProvider("primary", async () => { + throw new Error("down"); + }); + const secondary = makeProvider("secondary", async () => { + throw new Error("also down"); + }); + const resolver = new IdentityResolver({ + providers: [primary.provider, secondary.provider], + }); + await assert.rejects( + () => resolver.resolveHandle("alice.example"), + /also down/, + ); + }); + + it("does not cache a failure, so a later attempt can succeed", async () => { + let attempt = 0; + const flaky = makeProvider("flaky", async () => { + attempt++; + if (attempt === 1) throw new Error("transient"); + return "did:plc:aaaa"; + }); + const resolver = new IdentityResolver({ providers: [flaky.provider] }); + await assert.rejects(() => resolver.resolveHandle("alice.example")); + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:aaaa", + ); + }); + + it("re-checks a not-found handle once its TTL has elapsed", async () => { + let attempt = 0; + const provider = makeProvider("provider", async () => { + attempt++; + return attempt === 1 ? null : "did:plc:aaaa"; + }); + const resolver = new IdentityResolver({ + providers: [provider.provider], + notFoundTtlMs: 0, + }); + assert.deepEqual(await resolver.resolveHandle("alice.example"), null); + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:aaaa", + ); + }); + + it("caches a not-found handle for the duration of its TTL", async () => { + const provider = makeProvider("provider", async () => null); + const resolver = new IdentityResolver({ + providers: [provider.provider], + notFoundTtlMs: 60_000, + }); + assert.deepEqual(await resolver.resolveHandle("alice.example"), null); + assert.deepEqual(await resolver.resolveHandle("alice.example"), null); + assert.deepEqual(provider.calls.length, 1); + }); + + it("shares one request between concurrent callers", async () => { + let release = null; + const provider = makeProvider( + "provider", + () => + new Promise((resolve) => { + release = () => resolve("did:plc:aaaa"); + }), + ); + const resolver = new IdentityResolver({ providers: [provider.provider] }); + const both = Promise.all([ + resolver.resolveHandle("alice.example"), + resolver.resolveHandle("alice.example"), + ]); + release(); + assert.deepEqual(await both, ["did:plc:aaaa", "did:plc:aaaa"]); + assert.deepEqual(provider.calls.length, 1); + }); + + it("clears a cached not-found when a DID is supplied directly", async () => { + const provider = makeProvider("provider", async () => null); + const resolver = new IdentityResolver({ + providers: [provider.provider], + notFoundTtlMs: 60_000, + }); + assert.deepEqual(await resolver.resolveHandle("alice.example"), null); + resolver.setDidForHandle("alice.example", "did:plc:aaaa"); + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:aaaa", + ); + }); + }); + + describe("resolveIdentityEndpoint", () => { + function stubMiniDoc(body, status = 200) { + globalThis.fetch.__intercept(/resolveMiniDoc/, async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); + } + + it("uses the slingshot mini doc when it matches the handle", async () => { + stubMiniDoc({ + did: "did:plc:aaaa", + handle: "alice.example", + pds: "https://pds.example.com", + }); + assert.deepEqual(await resolveIdentityEndpoint("alice.example"), { + did: "did:plc:aaaa", + pds: "https://pds.example.com", + }); + assert( + !globalThis.fetch.calls.some((call) => + call.url.startsWith("https://plc.directory/"), + ), + "should not need the protocol fallback", + ); + }); + + it("resolves a DID identifier without a handle check", async () => { + stubMiniDoc({ + did: "did:plc:aaaa", + handle: "alice.example", + pds: "https://pds.example.com", + }); + assert.deepEqual(await resolveIdentityEndpoint("did:plc:aaaa"), { + did: "did:plc:aaaa", + pds: "https://pds.example.com", + }); + }); + + it("resolves did:web identities through slingshot", async () => { + stubMiniDoc({ + did: "did:web:example.com", + handle: "didweb.example.com", + pds: "https://pds.example.com", + }); + assert.deepEqual(await resolveIdentityEndpoint("did:web:example.com"), { + did: "did:web:example.com", + pds: "https://pds.example.com", + }); + assert.deepEqual(await resolveIdentityEndpoint("didweb.example.com"), { + did: "did:web:example.com", + pds: "https://pds.example.com", + }); + }); + + it("falls back to the protocol path when slingshot fails", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubDid("did:plc:aaaa"); + stubPlcDoc("did:plc:aaaa", { + alsoKnownAs: ["at://alice.example"], + service: [ + { id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }, + ], + }); + assert.deepEqual(await resolveIdentityEndpoint("alice.example"), { + did: "did:plc:aaaa", + pds: "https://pds.example.com", + }); + }); + + it("falls back when slingshot answers with a different handle", async () => { + stubMiniDoc({ + did: "did:plc:bbbb", + handle: "someone-else.example", + pds: "https://evil.example.com", + }); + stubDid("did:plc:aaaa"); + stubPlcDoc("did:plc:aaaa", { + alsoKnownAs: ["at://alice.example"], + service: [ + { id: "#atproto_pds", serviceEndpoint: "https://pds.example.com" }, + ], + }); + assert.deepEqual(await resolveIdentityEndpoint("alice.example"), { + did: "did:plc:aaaa", + pds: "https://pds.example.com", + }); + }); + + it("returns null when the handle does not resolve anywhere", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubDid(null); + assert.deepEqual(await resolveIdentityEndpoint("nope.example"), null); + }); + }); + + describe("IdentityResolver.resolveEndpoint", () => { + function stubMiniDoc(body, status = 200) { + globalThis.fetch.__intercept(/resolveMiniDoc/, async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); + } + + const endpoint = { + did: "did:plc:aaaa", + handle: "alice.example", + pds: "https://pds.example.com", + }; + + it("caches the endpoint so a second call makes no request", async () => { + stubMiniDoc(endpoint); + const resolver = new IdentityResolver(); + assert.deepEqual(await resolver.resolveEndpoint("alice.example"), { + did: "did:plc:aaaa", + pds: "https://pds.example.com", + }); + const callsAfterFirst = globalThis.fetch.calls.length; + await resolver.resolveEndpoint("alice.example"); + assert.deepEqual(globalThis.fetch.calls.length, callsAfterFirst); + }); + + it("re-resolves once the endpoint TTL has elapsed", async () => { + stubMiniDoc(endpoint); + const resolver = new IdentityResolver({ endpointTtlMs: 0 }); + await resolver.resolveEndpoint("alice.example"); + const callsAfterFirst = globalThis.fetch.calls.length; + await resolver.resolveEndpoint("alice.example"); + assert(globalThis.fetch.calls.length > callsAfterFirst); + }); + + it("shares one request between concurrent callers", async () => { + stubMiniDoc(endpoint); + const resolver = new IdentityResolver(); + const both = await Promise.all([ + resolver.resolveEndpoint("alice.example"), + resolver.resolveEndpoint("alice.example"), + ]); + assert.deepEqual(both[0], both[1]); + assert.deepEqual( + globalThis.fetch.calls.filter((call) => + call.url.includes("resolveMiniDoc"), + ).length, + 1, + ); + }); + + it("populates the handle cache, so resolveHandle needs no request", async () => { + stubMiniDoc(endpoint); + const resolver = new IdentityResolver(); + await resolver.resolveEndpoint("alice.example"); + const callsAfterFirst = globalThis.fetch.calls.length; + assert.deepEqual( + await resolver.resolveHandle("alice.example"), + "did:plc:aaaa", + ); + assert.deepEqual(globalThis.fetch.calls.length, callsAfterFirst); + }); + + it("does not file a DID identifier under the handle cache", async () => { + stubMiniDoc(endpoint); + const resolver = new IdentityResolver(); + await resolver.resolveEndpoint("did:plc:aaaa"); + assert.deepEqual(resolver.handleToDidMap.has("did:plc:aaaa"), false); + }); + + it("caches a not-found for the duration of the not-found TTL", async () => { + stubMiniDoc({ error: "InvalidRequest" }, 400); + stubDid(null); + const resolver = new IdentityResolver({ notFoundTtlMs: 60_000 }); + assert.deepEqual(await resolver.resolveEndpoint("nope.example"), null); + const callsAfterFirst = globalThis.fetch.calls.length; + assert.deepEqual(await resolver.resolveEndpoint("nope.example"), null); + assert.deepEqual(globalThis.fetch.calls.length, callsAfterFirst); + }); }); }); diff --git a/tests/unit/specs/oauth.test.js b/tests/unit/specs/oauth.test.js index a31aba05..5390224c 100644 --- a/tests/unit/specs/oauth.test.js +++ b/tests/unit/specs/oauth.test.js @@ -8,6 +8,7 @@ import { HandleNotFoundError, InvalidAuthUrlError, } from "/js/oauth.js"; +import { IdentityResolver } from "/js/atproto.js"; describe("oauth", () => { async function generateTestKeypair() { @@ -61,6 +62,8 @@ describe("oauth", () => { clientId: "https://app.example.com/client-metadata.json", redirectUri: "https://app.example.com/callback", dpopKeypair, + // Own resolver per client, so no identity is cached across tests. + identityResolver: new IdentityResolver(), }); } diff --git a/tests/unit/specs/slingshot.test.js b/tests/unit/specs/slingshot.test.js index f505cc1b..bef84610 100644 --- a/tests/unit/specs/slingshot.test.js +++ b/tests/unit/specs/slingshot.test.js @@ -127,4 +127,156 @@ describe("Slingshot", () => { assert.deepEqual(fetched, false); }); }); + + describe("resolveHandle", () => { + it("returns the DID from a successful response", async () => { + const did = uniqueDid(); + const { calls, fetchImpl } = stubFetch(async () => + jsonResponse(200, { did }), + ); + const slingshot = new Slingshot({ fetchImpl }); + assert.deepEqual(await slingshot.resolveHandle("alice.example"), did); + const url = new URL(calls[0]); + assert.deepEqual(url.origin, "https://slingshot.microcosm.blue"); + assert.deepEqual( + url.pathname, + "/xrpc/com.atproto.identity.resolveHandle", + ); + assert.deepEqual(url.searchParams.get("handle"), "alice.example"); + }); + + it("throws rather than reporting not-found on an unresolvable handle", async () => { + const { fetchImpl } = stubFetch(async () => + jsonResponse(500, { + error: "Failed", + message: "Could not resolve handle", + }), + ); + const slingshot = new Slingshot({ fetchImpl }); + await assert.rejects(() => slingshot.resolveHandle("nope.example")); + }); + + it("throws when the response carries no usable DID", async () => { + const { fetchImpl } = stubFetch(async () => + jsonResponse(200, { did: "not-a-did" }), + ); + const slingshot = new Slingshot({ fetchImpl }); + await assert.rejects(() => slingshot.resolveHandle("alice.example")); + }); + + it("rejects invalid handles without hitting the network", async () => { + let fetched = false; + const slingshot = new Slingshot({ + fetchImpl: async () => { + fetched = true; + return jsonResponse(200, {}); + }, + }); + for (const handle of ["", "no-dot", "-bad.example", "did:plc:abc"]) { + await assert.rejects( + () => slingshot.resolveHandle(handle), + `expected rejection for "${handle}"`, + ); + } + assert.deepEqual(fetched, false); + }); + }); + + describe("resolveMiniDoc", () => { + it("normalizes a successful response", async () => { + const did = uniqueDid(); + const { calls, fetchImpl } = stubFetch(async () => + jsonResponse(200, { + did, + handle: "alice.example", + pds: "https://pds.example.com", + signing_key: "zQ3shfake", + }), + ); + const slingshot = new Slingshot({ fetchImpl }); + assert.deepEqual(await slingshot.resolveMiniDoc("alice.example"), { + did, + handle: "alice.example", + pds: "https://pds.example.com", + signingKey: "zQ3shfake", + }); + const url = new URL(calls[0]); + assert.deepEqual( + url.pathname, + "/xrpc/blue.microcosm.identity.resolveMiniDoc", + ); + assert.deepEqual(url.searchParams.get("identifier"), "alice.example"); + }); + + it("accepts a DID as the identifier", async () => { + const did = uniqueDid(); + const { calls, fetchImpl } = stubFetch(async () => + jsonResponse(200, { + did, + handle: "alice.example", + pds: "https://pds.example.com", + }), + ); + const slingshot = new Slingshot({ fetchImpl }); + const result = await slingshot.resolveMiniDoc(did); + assert.deepEqual(result.did, did); + assert.deepEqual(result.signingKey, null); + assert.deepEqual(new URL(calls[0]).searchParams.get("identifier"), did); + }); + + it("throws when the document has no PDS", async () => { + const { fetchImpl } = stubFetch(async () => + jsonResponse(200, { did: uniqueDid(), handle: "alice.example" }), + ); + const slingshot = new Slingshot({ fetchImpl }); + await assert.rejects(() => slingshot.resolveMiniDoc("alice.example")); + }); + + it("throws on an InvalidRequest response", async () => { + const { fetchImpl } = stubFetch(async () => + jsonResponse(400, { + error: "InvalidRequest", + message: "Failed to get DID doc", + }), + ); + const slingshot = new Slingshot({ fetchImpl }); + await assert.rejects(() => slingshot.resolveMiniDoc("alice.example")); + }); + + it("rejects invalid identifiers without hitting the network", async () => { + let fetched = false; + const slingshot = new Slingshot({ + fetchImpl: async () => { + fetched = true; + return jsonResponse(200, {}); + }, + }); + for (const identifier of ["", "no-dot", "did:foo:abc"]) { + await assert.rejects( + () => slingshot.resolveMiniDoc(identifier), + `expected rejection for "${identifier}"`, + ); + } + assert.deepEqual(fetched, false); + }); + }); + + describe("request timeouts", () => { + it("aborts a request that does not respond in time", async () => { + const slingshot = new Slingshot({ + timeoutMs: 10, + fetchImpl: (url, options) => + new Promise((resolve, reject) => { + // Rejecting with signal.reason is what a real fetch does + options.signal.addEventListener("abort", () => + reject(options.signal.reason), + ); + }), + }); + await assert.rejects( + () => slingshot.resolveHandle("slow.example"), + /timed out/, + ); + }); + }); }); diff --git a/tests/unit/specs/sourceProvider.test.js b/tests/unit/specs/sourceProvider.test.js index 32bd30a5..7766c69d 100644 --- a/tests/unit/specs/sourceProvider.test.js +++ b/tests/unit/specs/sourceProvider.test.js @@ -521,12 +521,13 @@ describe("SourceProvider.getCacheUrls with fonts", () => { // tangled.org's own HTTP endpoints don't set CORS headers, so // SourceProvider resolves tangled: repos entirely through standard AT -// Protocol infrastructure instead: resolveHandle -> plc.directory -> -// the owner's PDS (for the repo's own "sh.tangled.repo" record, which -// carries {knot, repoDid}) -> the knot's own CORS-enabled blob endpoint. -// This stubs global fetch to answer those three resolution requests by URL +// Protocol infrastructure instead: owner handle -> the owner's PDS (for the +// repo's own "sh.tangled.repo" record, which carries {knot, repoDid}) -> +// the knot's own CORS-enabled blob endpoint. The handle -> PDS step is +// slingshot's mini doc, falling back to resolveHandle -> plc.directory. +// This stubs global fetch to answer those resolution requests by URL // pattern; the actual file-content request is left to the caller (via -// fakePluginCache, or a 4th branch here for plain-fetch methods). +// fakePluginCache, or a further branch here for plain-fetch methods). // legacyRecordKey simulates repos created before the "rkey = repo name" // scheme existed: the direct getRecord lookup 404s (well, 400s — standard // atproto RecordNotFound), and the record only turns up via listRecords, @@ -546,6 +547,13 @@ function stubTangledResolution({ const fetchImpl = async (url, options) => { calls.push({ url: String(url), options }); const urlStr = String(url); + if (urlStr.includes("blue.microcosm.identity.resolveMiniDoc")) { + return jsonResponse({ + did: ownerDid, + handle: ownerHandle, + pds, + }); + } if (urlStr.includes("com.atproto.identity.resolveHandle")) { return jsonResponse({ did: ownerDid }); } @@ -649,8 +657,8 @@ describe("SourceProvider with tangled.sh-hosted plugins", () => { knotBlobUrl({ ...identity, ref: "1.0.0", path: "manifest.json" }), ); assert.deepEqual(manifest.id, "alpha"); - // resolveHandle, plc.directory, getRecord - assert.deepEqual(stub.calls.length, 3); + // resolveMiniDoc, getRecord + assert.deepEqual(stub.calls.length, 2); } finally { stub.restore(); } @@ -717,9 +725,9 @@ describe("SourceProvider with tangled.sh-hosted plugins", () => { const repo = `tangled:${identity.ownerHandle}/alpha`; await provider.getManifest("alpha", "1.0.0", repo); await provider.getSource("alpha", "1.0.0", repo); - // Still just the 3 resolution calls, not 6 — the second fetch reused + // Still just the 2 resolution calls, not 4 — the second fetch reused // the cached {knot, repoDid}. - assert.deepEqual(stub.calls.length, 3); + assert.deepEqual(stub.calls.length, 2); assert.deepEqual(pluginCache.calls.length, 2); } finally { stub.restore(); @@ -799,8 +807,8 @@ describe("SourceProvider with tangled.sh-hosted plugins", () => { pluginCache.calls[0].url, knotBlobUrl({ ...identity, ref: "1.0.0", path: "manifest.json" }), ); - // resolveHandle, plc.directory, getRecord (404), listRecords - assert.deepEqual(stub.calls.length, 4); + // resolveMiniDoc, getRecord (404), listRecords + assert.deepEqual(stub.calls.length, 3); } finally { stub.restore(); } diff --git a/tests/unit/specs/tangled.test.js b/tests/unit/specs/tangled.test.js index 77962661..d601f4da 100644 --- a/tests/unit/specs/tangled.test.js +++ b/tests/unit/specs/tangled.test.js @@ -1,6 +1,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { TangledResolver, decodeTangledBlobContent } from "/js/tangled.js"; +import { IdentityResolver } from "/js/atproto.js"; import { MockFetch, installFakeIndexedDB } from "../testHelpers.js"; describe("TangledResolver", () => { @@ -11,6 +12,7 @@ describe("TangledResolver", () => { const repoDid = "did:plc:repo"; let identityResolutions; + let recordFetches; let resolver; // A fresh path per test: the persisted binding is keyed by it, so reusing // one would let an earlier test's entry satisfy a later test's first call @@ -37,8 +39,13 @@ describe("TangledResolver", () => { }, ], }); - fetchMock.__interceptJson(/com\.atproto\.repo\.getRecord/, { - value: { knot, repoDid }, + fetchMock.__intercept(/com\.atproto\.repo\.getRecord/, async () => { + recordFetches += 1; + return { + ok: true, + status: 200, + json: async () => ({ value: { knot, repoDid } }), + }; }); } @@ -46,7 +53,9 @@ describe("TangledResolver", () => { installFakeIndexedDB(); globalThis.fetch = new MockFetch(); identityResolutions = 0; - resolver = new TangledResolver(); + recordFetches = 0; + // Own identity resolver per test, so no endpoint is cached across tests + resolver = new TangledResolver(new IdentityResolver()); stubResolutionChain(); }); @@ -118,13 +127,17 @@ describe("TangledResolver", () => { assert.deepEqual(identityResolutions, 1); }); + // The owner's PDS endpoint is cached by the IdentityResolver, independently + // of the knot binding, so re-resolving after invalidate refetches the repo + // record but need not resolve the owner's identity again. it("re-resolves after invalidate", async () => { const path = nextPath(); await resolver.resolveRepoInfo(path); await resolver.invalidate(path); await resolver.resolveRepoInfo(path); - assert.deepEqual(identityResolutions, 2); + assert.deepEqual(recordFetches, 2); + assert.deepEqual(identityResolutions, 1); }); it("drops the persisted binding on invalidate", async () => { @@ -132,10 +145,14 @@ describe("TangledResolver", () => { await resolver.resolveRepoInfo(path); await resolver.invalidate(path); + recordFetches = 0; identityResolutions = 0; - const info = await new TangledResolver().resolveRepoInfo(path); + const info = await new TangledResolver( + new IdentityResolver(), + ).resolveRepoInfo(path); assert.deepEqual(info, { knot, repoDid }); + assert.deepEqual(recordFetches, 1); assert.deepEqual(identityResolutions, 1); });