diff --git a/src/__tests__/__fixtures__/F6-hydratedCID.json b/src/__tests__/__fixtures__/F6-hydratedCID.json new file mode 100644 index 0000000..c35130c --- /dev/null +++ b/src/__tests__/__fixtures__/F6-hydratedCID.json @@ -0,0 +1,31 @@ +{ + "$type": "app.bsky.embed.external", + "external": { + "uri": "https://assistant.kagi.com/share/690e7bea-e677-4bdf-ae1e-b40e23971c66", + "thumb": { + "ref": { + "code": 85, + "version": 1, + "hash": { + "0": 18, "1": 32, "2": 56, "3": 125, "4": 112, "5": 174, "6": 15, "7": 217, + "8": 67, "9": 93, "10": 64, "11": 156, "12": 137, "13": 69, "14": 190, "15": 86, + "16": 10, "17": 243, "18": 185, "19": 95, "20": 202, "21": 213, "22": 196, "23": 102, + "24": 237, "25": 86, "26": 69, "27": 24, "28": 64, "29": 39, "30": 86, "31": 149, + "32": 239, "33": 136 + } + }, + "mimeType": "image/webp", + "size": 65426, + "original": { + "$type": "blob", + "ref": { + "$link": "bafkreibypvyk4d6zinoubhejiw7fmcxtxfp4vvoem3wvmriyiatvnfppra" + }, + "mimeType": "image/webp", + "size": 65426 + } + }, + "title": "Tool for Managing Bluesky App Forks - Kagi Assistant", + "description": "Access multiple AI models without being tracked." + } +} diff --git a/src/__tests__/extract.test.js b/src/__tests__/extract.test.js index 365c5ad..1b32a85 100644 --- a/src/__tests__/extract.test.js +++ b/src/__tests__/extract.test.js @@ -7,6 +7,7 @@ import F2 from './__fixtures__/F2-hydrated.json'; import F3 from './__fixtures__/F3-legacy.json'; import F4 from './__fixtures__/F4-recordWithMedia.json'; import F5 from './__fixtures__/F5-thirdparty.json'; +import F6 from './__fixtures__/F6-hydratedCID.json'; describe('extractBlobRefs — fixture F1 (modern blob ref)', () => { const refs = extractBlobRefs(F1); @@ -72,6 +73,30 @@ describe('extractBlobRefs — fixture F5 (third-party lexicon, arbitrary depth)' }); }); +describe('extractBlobRefs — fixture F6 (client-hydrated CID ref)', () => { + test('finds the blob when ref is a hydrated CID object (no $type on wrapper)', () => { + const refs = extractBlobRefs(F6); + expect(refs).toHaveLength(1); + expect(refs[0].cid).toBe('bafkreibypvyk4d6zinoubhejiw7fmcxtxfp4vvoem3wvmriyiatvnfppra'); + expect(refs[0].mimeType).toBe('image/webp'); + }); + + test('works when the CID object exposes toString() like multiformats', () => { + const cidObj = { + code: 85, + version: 1, + toString: () => 'bafkreibypvyk4d6zinoubhejiw7fmcxtxfp4vvoem3wvmriyiatvnfppra', + }; + const record = { + $type: 'app.bsky.embed.external', + external: { thumb: { ref: cidObj, mimeType: 'image/webp', size: 65426 } }, + }; + const refs = extractBlobRefs(record); + expect(refs).toHaveLength(1); + expect(refs[0].cid).toBe('bafkreibypvyk4d6zinoubhejiw7fmcxtxfp4vvoem3wvmriyiatvnfppra'); + }); +}); + describe('extractBlobRefs — general behavior', () => { test('handles primitives, null, arrays', () => { expect(extractBlobRefs(null)).toEqual([]); diff --git a/src/lib/extract.js b/src/lib/extract.js index bb8159e..332b5db 100644 --- a/src/lib/extract.js +++ b/src/lib/extract.js @@ -12,6 +12,30 @@ // F3 — legacy { "cid": "bafk...", "mimeType": "image/jpeg" } // F4 — deeply nested inside app.bsky embeds // F5 — arbitrary position inside a third-party lexicon record +// +// REAL-WORLD (learned from production): the @atproto/api client hydrates +// `ref` values into multiformats CID objects ({code, version, hash, ...}) +// when it parses listRecords responses. `CID.toString()` yields the +// "bafk..." string. The hydrated blob node is wrapped as +// { ref: CID, mimeType, size, original: { $type: 'blob', ... } } and the +// wrapper itself has NO $type — so we must also recognize objects whose +// `ref` resolves to a CID AND that carry mimeType/size. + +const isCidLike = (s) => typeof s === 'string' && /^baf[a-z0-9]+$/i.test(s); + +/** Resolve a blob ref (string, {$link}, or hydrated CID object) to a CID string or null. */ +function cidFromRef(ref) { + if (ref === null || ref === undefined) return null; + if (typeof ref === 'string') return isCidLike(ref) ? ref : null; + if (typeof ref !== 'object') return null; + if (typeof ref.$link === 'string') return isCidLike(ref.$link) ? ref.$link : null; + // hydrated CID object (multiformats) — toString() gives the bafk... string + if (typeof ref.toString === 'function') { + const s = ref.toString(); + if (isCidLike(s)) return s; + } + return null; +} /** * @param {*} value - a record value (object, array, primitive) @@ -21,7 +45,14 @@ export function extractBlobRefs(value) { const seen = new Set(); const out = []; - const isCidLike = (s) => typeof s === 'string' && /^baf[a-z0-9]+$/i.test(s); + const push = (cid, mimeType, size) => { + if (seen.has(cid)) return; + seen.add(cid); + const entry = { cid }; + if (typeof mimeType === 'string' && mimeType) entry.mimeType = mimeType; + if (typeof size === 'number') entry.size = size; + out.push(entry); + }; const walk = (node) => { if (node === null || node === undefined) return; @@ -32,23 +63,32 @@ export function extractBlobRefs(value) { if (typeof node !== 'object') return; const t = node.$type; + const hasBlobRef = 'ref' in node || 'cid' in node; if (t === 'blob') { - // F1: ref is a link object - if (node.ref && typeof node.ref === 'object' && typeof node.ref.$link === 'string') { - push(node.ref.$link, node.mimeType, node.size); + const cid = cidFromRef(node.ref); + if (cid) { + push(cid, node.mimeType, node.size); return; // do not recurse into ref internals } - // F2: ref already hydrated as a string (some clients) - if (typeof node.ref === 'string' && isCidLike(node.ref)) { - push(node.ref, node.mimeType, node.size); + } else if (hasBlobRef) { + // Hydrated wrapper (no $type): { ref: CID, mimeType, size, original } — + // or legacy F3 { cid, mimeType } — recognize only when it looks like a + // blob (carries mimeType/size), not arbitrary {ref} fields. + if (node.ref !== undefined && cidFromRef(node.ref) && ('mimeType' in node || 'size' in node)) { + push(cidFromRef(node.ref), node.mimeType, node.size); + return; + } + if ( + !t && + typeof node.cid === 'string' && + isCidLike(node.cid) && + ('mimeType' in node || 'size' in node) + ) { + // F3 legacy shape + push(node.cid, node.mimeType, node.size); return; } - // F1 variant: ref is an object with a $link deeper (unusual) — fall through - } else if (!t && typeof node.cid === 'string' && isCidLike(node.cid) && ('mimeType' in node || 'size' in node)) { - // F3: legacy pre-blob-refactor shape - push(node.cid, node.mimeType, node.size); - return; } // F4/F5: generic recursion @@ -57,15 +97,6 @@ export function extractBlobRefs(value) { } }; - const push = (cid, mimeType, size) => { - if (seen.has(cid)) return; - seen.add(cid); - const entry = { cid }; - if (typeof mimeType === 'string' && mimeType) entry.mimeType = mimeType; - if (typeof size === 'number') entry.size = size; - out.push(entry); - }; - walk(value); return out; }