diff --git a/src/feedref.ts b/src/feedref.ts index 3483f4b..e6f5c4e 100644 --- a/src/feedref.ts +++ b/src/feedref.ts @@ -140,14 +140,14 @@ async function savedCandidates(client: Client): Promise { async function ownCandidates(client: Client, did: string): Promise { const [feeds, lists] = await Promise.all([ - client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }).catch(() => ({ feeds: [] })), - client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }).catch(() => ({ lists: [] })), + client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }), + client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }), ]); return [...feeds.feeds.map((view) => fromView(view, 'feed', 'yours')), ...lists.lists.map((view) => fromView(view, 'list', 'yours'))]; } async function searchCandidates(client: Client, query: string): Promise { - const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }).catch(() => ({ feeds: [] as Json[] })); + const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }); return feeds.map((view) => fromView(view, 'feed', 'search')); } @@ -214,7 +214,10 @@ export async function resolveFeed(client: Client, ref: string, { kinds = ['feed' // Your own circles first: what you saved, then what you made. let mine: Array = []; if (session) { - const [saved, own] = await Promise.all([savedCandidates(client).catch(() => []), ownCandidates(client, session.did)]); + // A failure to look is said, not taken for having nothing: that would + // send the name off to a search, and answer 'not found' for a feed + // that is right there. + const [saved, own] = await Promise.all([savedCandidates(client), ownCandidates(client, session.did)]); const seen = new Set(); mine = scored(text, [...saved, ...own].filter((candidate) => !seen.has(candidate.uri) && seen.add(candidate.uri)), kinds); const best = mine[0]; diff --git a/src/session.ts b/src/session.ts index 15b4710..217620e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -159,15 +159,21 @@ export class Session { if (expiry !== undefined && expiry - 30 < Date.now() / 1000) await this.refresh(); const headers = { ...options.headers, ...(options.proxy ? { 'atproto-proxy': options.proxy } : {}) }; let refreshed = false; + const origin = new URL(this.pds).origin; for (let attempt = 0; ; attempt++) { + // The nonce this request is sent with. Requests in flight together + // are all refused for want of a new one, and the first answer + // teaches it: each of the others is retried because the nonce now + // known is not the one it was sent with, not because it is new. + const used = this.secret.kind === 'oauth' ? this.secret.dpopNonces[origin] : undefined; try { return await xrpcResponse(this.pds, nsid, { ...options, headers, auth: this.authorizer() }); } catch (error) { if (!(error instanceof XrpcError)) throw error; const errorHeaders = (error as XrpcError & { headers?: Headers }).headers; - if (this.secret.kind === 'oauth' && (error.code === 'use_dpop_nonce' || /use_dpop_nonce/.test(errorHeaders?.get('www-authenticate') ?? '')) - && attempt < 2 && learnNonce(this.secret, xrpcUrl(this.pds, nsid), errorHeaders)) { - continue; + if (this.secret.kind === 'oauth' && (error.code === 'use_dpop_nonce' || /use_dpop_nonce/.test(errorHeaders?.get('www-authenticate') ?? ''))) { + learnNonce(this.secret, xrpcUrl(this.pds, nsid), errorHeaders); + if (attempt < 3 && this.secret.dpopNonces[origin] !== used) continue; } if (isExpired(error) && !refreshed) { refreshed = true; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 9604cb0..128e77c 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -451,6 +451,22 @@ test('--new shows what was not shown before, and with -n reads the new ones from timeline.length = 0; }); +test('requests in flight together over OAuth all get the new DPoP nonce', async () => { + const bob = did('bob.test'); + (mock as any).feeds = [{ uri: `at://${bob}/app.bsky.feed.generator/news-2-0`, displayName: '📰 Trending News', likeCount: 1, creator: { did: bob, handle: 'bob.test' } }]; + (mock as any).lists = []; + (mock as any).preferences = [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [{ id: '1', type: 'feed', value: `at://${bob}/app.bsky.feed.generator/news-2-0`, pinned: true }] }]; + // The server rotates its nonce: every request bob's session sends next is refused once. + (mock as any).rotateNonce(); + const { resolveFeed } = await import('../src/feedref.ts'); + const { Client } = await import('../src/api.ts'); + const found = await resolveFeed(new Client('bob.test'), 'news-2-0', { quiet: true }); + assert.equal(found.uri, `at://${bob}/app.bsky.feed.generator/news-2-0`); + assert.equal(found.via, 'saved', 'found among the saved feeds, not by a search after a lost request'); + (mock as any).preferences = []; + (mock as any).feeds = []; +}); + test('records: create, get and delete raw records', async () => { const created = await run('record', 'create', 'com.example.note', '{"text":"hi"}', '--json'); assert.equal(created.code, 0, created.stderr);