Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Add plugin image rendering and slingshot integration

Grace Kind (Jul 15, 2026, 9:47 PM -0500) b0668b26 0be60015

+851 -53
+3
impro-plugin/main.js
··· 147 147 getProfile(did) { 148 148 return hostCall("getProfile", { did }); 149 149 } 150 + getRecord(repo, collection, rkey) { 151 + return hostCall("getRecord", { repo, collection, rkey }); 152 + } 150 153 } 151 154 152 155 class App {
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.9", 3 + "version": "0.0.10", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.173", 3 + "version": "0.17.174", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+17
src/css/style.css
··· 1287 1287 height: 100%; 1288 1288 } 1289 1289 1290 + plugin-blob-image { 1291 + display: inline-block; 1292 + vertical-align: middle; 1293 + } 1294 + 1295 + plugin-blob-image img { 1296 + display: block; 1297 + max-width: 100%; 1298 + max-height: 128px; 1299 + font-size: inherit; 1300 + color: transparent; 1301 + } 1302 + 1303 + plugin-blob-image .blob-image-fallback { 1304 + color: var(--text-color-muted); 1305 + } 1306 + 1290 1307 .sidebar-nav-icon plugin-icon { 1291 1308 width: 25px; 1292 1309 height: 25px;
+19 -8
src/js/atproto.js
··· 18 18 return aliases.includes(atHandle); 19 19 } 20 20 21 - export async function resolveHandle(handle) { 21 + async function resolveHandleUnverified(handle) { 22 22 const params = new URLSearchParams({ 23 23 handle, 24 24 }); ··· 27 27 params.toString(), 28 28 ); 29 29 const data = await res.json(); 30 - return data.did; 30 + return data.did ?? null; 31 31 } 32 32 33 33 export async function resolveDid(did) { ··· 45 45 } 46 46 } 47 47 48 - export async function getServiceEndpointForHandle(handle) { 49 - const did = await resolveHandle(handle); 50 - if (!did) { 51 - throw new HandleNotFoundError("DID not found for handle: " + handle); 52 - } 48 + export async function resolveIdentity(handle) { 49 + const did = await resolveHandleUnverified(handle); 50 + if (!did) return null; 53 51 const didDoc = await resolveDid(did); 54 52 if (!didDocReferencesHandle(didDoc, handle)) { 55 53 throw new Error(`DID doc for ${did} does not reference handle: ${handle}`); 56 54 } 57 - return getServiceEndpointFromDidDoc(didDoc); 55 + return { did, didDoc }; 56 + } 57 + 58 + export async function resolveHandle(handle) { 59 + const result = await resolveIdentity(handle); 60 + return result?.did ?? null; 61 + } 62 + 63 + export async function getServiceEndpointForHandle(handle) { 64 + const result = await resolveIdentity(handle); 65 + if (!result) { 66 + throw new HandleNotFoundError("DID not found for handle: " + handle); 67 + } 68 + return getServiceEndpointFromDidDoc(result.didDoc); 58 69 } 59 70 60 71 export class IdentityResolver {
+91
src/js/components/plugin-blob-image.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 4 + import { BSKY_CDN_URL } from "/js/config.js"; 5 + 6 + const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; 7 + const CID_PATTERN = /^b[a-z2-7]{20,}$/; 8 + 9 + const CDN_PREFIXES = new Set([ 10 + "avatar", 11 + "avatar_thumbnail", 12 + "banner", 13 + "feed_thumbnail", 14 + "feed_fullsize", 15 + ]); 16 + 17 + function isValidDid(did) { 18 + return typeof did === "string" && DID_PATTERN.test(did); 19 + } 20 + 21 + function isValidCid(cid) { 22 + return typeof cid === "string" && CID_PATTERN.test(cid); 23 + } 24 + 25 + function buildCdnUrl(prefix, did, cid) { 26 + return `${BSKY_CDN_URL}/img/${prefix}/plain/${did}/${cid}@jpeg`; 27 + } 28 + 29 + class PluginBlobImage extends Component { 30 + static get observedAttributes() { 31 + return ["did", "cid", "alt", "cdn-prefix"]; 32 + } 33 + 34 + connectedCallback() { 35 + if (this.initialized) return; 36 + this.initialized = true; 37 + this.state = new ReactiveStore("plugin-blob-image"); 38 + this.state.$did = new Signal.State(this.getAttribute("did")); 39 + this.state.$cid = new Signal.State(this.getAttribute("cid")); 40 + this.state.$alt = new Signal.State(this.getAttribute("alt")); 41 + this.state.$cdnPrefix = new Signal.State(this.getAttribute("cdn-prefix")); 42 + this.state.$failed = new Signal.State(false); 43 + this._disposers = [ 44 + effect(() => { 45 + const did = this.state.$did.get(); 46 + const cid = this.state.$cid.get(); 47 + const cdnPrefix = this.state.$cdnPrefix.get(); 48 + const alt = this.state.$alt.get() ?? ""; 49 + const failed = this.state.$failed.get(); 50 + const src = 51 + !failed && 52 + isValidDid(did) && 53 + isValidCid(cid) && 54 + CDN_PREFIXES.has(cdnPrefix) 55 + ? buildCdnUrl(cdnPrefix, did, cid) 56 + : null; 57 + if (src) { 58 + render( 59 + html`<img 60 + src="${src}" 61 + alt="${alt}" 62 + @error="${() => this.state.$failed.set(true)}" 63 + />`, 64 + this, 65 + ); 66 + } else { 67 + render(html`<span class="blob-image-fallback">${alt}</span>`, this); 68 + } 69 + }), 70 + ]; 71 + } 72 + 73 + disconnectedCallback() { 74 + if (!this.initialized) return; 75 + this._disposers?.forEach((dispose) => dispose()); 76 + this._disposers = null; 77 + } 78 + 79 + attributeChangedCallback() { 80 + if (!this.initialized) return; 81 + this.state.$failed.set(false); 82 + this.state.$did.set(this.getAttribute("did")); 83 + this.state.$cid.set(this.getAttribute("cid")); 84 + this.state.$alt.set(this.getAttribute("alt")); 85 + this.state.$cdnPrefix.set(this.getAttribute("cdn-prefix")); 86 + } 87 + } 88 + 89 + PluginBlobImage.register(); 90 + 91 + export { PluginBlobImage };
+3
src/js/config.js
··· 23 23 export const TYPEAHEAD_SERVICE_URL = "https://public.api.bsky.app"; 24 24 export const LINK_CARD_SERVICE_URL = "https://cardyb.bsky.app"; 25 25 export const OG_CARD_SERVICE_URL = "https://ogcard.cdn.bsky.app"; 26 + export const BSKY_CDN_URL = "https://cdn.bsky.app"; 27 + export const CONSTELLATION_URL = "https://constellation.microcosm.blue"; 28 + export const SLINGSHOT_URL = "https://slingshot.microcosm.blue"; 26 29 export const TENOR_GIF_PROXY_URL = "https://t.gifs.bsky.app"; 27 30 export const KLIPY_GIF_PROXY_HOSTNAME = "k.gifs.bsky.app"; 28 31 export const PLC_DIRECTORY_URL = "https://plc.directory";
+2 -1
src/js/constellation.js
··· 1 1 import { buildQueryString } from "/js/utils.js"; 2 + import { CONSTELLATION_URL } from "/js/config.js"; 2 3 3 4 export class Constellation { 4 5 async getLinks({ subject, source, limit = null, timeout = 10000 }) { ··· 18 19 query.cursor = cursor; 19 20 } 20 21 const response = await fetch( 21 - `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?${buildQueryString( 22 + `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks?${buildQueryString( 22 23 query, 23 24 )}`, 24 25 {
+4 -14
src/js/oauth.js
··· 1 - import { 2 - resolveHandle, 3 - didDocReferencesHandle, 4 - resolveDid, 5 - getServiceEndpointFromDidDoc, 6 - } from "/js/atproto.js"; 1 + import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js"; 7 2 8 3 // Inspiration from: 9 4 // https://www.npmjs.com/package/@atproto/oauth-client-browser ··· 549 544 } 550 545 551 546 async getAuthorizationUrl(handle, { scope = "atproto", state = {} } = {}) { 552 - const did = await resolveHandle(handle); 553 - if (!did) { 547 + const result = await resolveIdentity(handle); 548 + if (!result) { 554 549 throw new HandleNotFoundError("DID not found for handle: " + handle); 555 550 } 556 - const didDoc = await resolveDid(did); 557 - if (!didDocReferencesHandle(didDoc, handle)) { 558 - throw new Error( 559 - `DID doc for ${did} does not reference handle: ${handle}`, 560 - ); 561 - } 551 + const { did, didDoc } = result; 562 552 const pdsEndpoint = getServiceEndpointFromDidDoc(didDoc); 563 553 const serviceEndpoint = this.proxyUrl ?? pdsEndpoint; 564 554 const resourceMetadata = await fetchResourceServerMetadata(serviceEndpoint);
+2
src/js/plugins/pluginRendering.js
··· 3 3 import "/js/components/plugin-profiles-list.js"; 4 4 import "/js/components/plugin-posts-feed.js"; 5 5 import "/js/components/plugin-icon.js"; 6 + import "/js/components/plugin-blob-image.js"; 6 7 7 8 function isExternalHref(href) { 8 9 try { ··· 41 42 "plugin-profiles-list", 42 43 "plugin-posts-feed", 43 44 "plugin-icon", 45 + "plugin-blob-image", 44 46 "toggle-switch", 45 47 ]; 46 48
+1 -1
src/js/plugins/pluginRequests.js
··· 5 5 const FORBIDDEN_HEADERS = ["authorization", "cookie"]; 6 6 const MAX_BODY_CHARS = 1_000_000; 7 7 8 - export async function makePluginRequest( 8 + export async function pluginFetch( 9 9 plugin, 10 10 url, 11 11 init,
+8 -2
src/js/plugins/pluginService.js
··· 15 15 import { PluginPreferencesManager } from "/js/plugins/pluginPreferencesManager.js"; 16 16 import { SourceProvider } from "/js/plugins/sourceProvider.js"; 17 17 import { PluginStylesLoader } from "/js/plugins/pluginStylesLoader.js"; 18 - import { makePluginRequest } from "/js/plugins/pluginRequests.js"; 18 + import { pluginFetch } from "/js/plugins/pluginRequests.js"; 19 + import { Slingshot } from "/js/slingshot.js"; 19 20 import { 20 21 getPermissionsFromManifest, 21 22 diffPermissions, ··· 90 91 export class PluginService extends ReactiveStore { 91 92 constructor(preferencesProvider, session) { 92 93 super("pluginService"); 94 + this.slingshot = new Slingshot(); 93 95 this.registries = { 94 96 sidebarItems: new SignalSet(), 95 97 eventListeners: new Map(), ··· 345 347 }); 346 348 347 349 this.pluginBridge.addHostMethod("fetch", (plugin, { url, init }) => { 348 - return makePluginRequest(plugin, url, init); 350 + return pluginFetch(plugin, url, init); 349 351 }); 350 352 351 353 this.pluginBridge.addHostMethod("getPost", (plugin, { uri }) => { ··· 355 357 this.pluginBridge.addHostMethod("getProfile", (plugin, { did }) => { 356 358 return this._dataLayer?.derived.$hydratedProfiles.get(did) ?? null; 357 359 }); 360 + 361 + this.pluginBridge.addHostMethod("getRecord", (plugin, args) => 362 + this.slingshot.getRecord(args), 363 + ); 358 364 359 365 this.pluginBridge.addHostMethod("getCurrentUser", () => { 360 366 if (!this.session) return null;
+54
src/js/slingshot.js
··· 1 + import { SLINGSHOT_URL } from "/js/config.js"; 2 + 3 + const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; 4 + const NSID_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; 5 + const RKEY_PATTERN = /^[a-zA-Z0-9._~:-]{1,512}$/; 6 + 7 + function isValidDid(value) { 8 + return typeof value === "string" && DID_PATTERN.test(value); 9 + } 10 + 11 + function isValidNsid(value) { 12 + return typeof value === "string" && NSID_PATTERN.test(value); 13 + } 14 + 15 + function isValidRkey(value) { 16 + return ( 17 + typeof value === "string" && 18 + value !== "." && 19 + value !== ".." && 20 + RKEY_PATTERN.test(value) 21 + ); 22 + } 23 + 24 + export class Slingshot { 25 + constructor({ fetchImpl } = {}) { 26 + this.fetchImpl = fetchImpl ?? ((url) => globalThis.fetch(url)); 27 + } 28 + 29 + async getRecord({ repo, collection, rkey }) { 30 + if (!isValidDid(repo)) { 31 + throw new Error(`getRecord: invalid repo "${repo}"`); 32 + } 33 + if (!isValidNsid(collection)) { 34 + throw new Error(`getRecord: invalid collection "${collection}"`); 35 + } 36 + if (!isValidRkey(rkey)) { 37 + throw new Error(`getRecord: invalid rkey "${rkey}"`); 38 + } 39 + const params = new URLSearchParams({ repo, collection, rkey }); 40 + const url = `${SLINGSHOT_URL}/xrpc/com.atproto.repo.getRecord?${params.toString()}`; 41 + const res = await this.fetchImpl(url); 42 + if (res.status === 400) { 43 + const data = await res.json().catch(() => null); 44 + if (data?.error === "RecordNotFound") return null; 45 + throw new Error( 46 + `getRecord: ${data?.error ?? "InvalidRequest"} ${data?.message ?? ""}`.trim(), 47 + ); 48 + } 49 + if (!res.ok) { 50 + throw new Error(`getRecord: HTTP ${res.status}`); 51 + } 52 + return await res.json(); 53 + } 54 + }
+4 -1
src/js/templates/richText.template.js
··· 3 3 import { tokenizeRichText } from "/js/richTextHelpers.js"; 4 4 import { linkToHashtag, linkToProfileByDid } from "/js/navigation.js"; 5 5 6 - const KNOWN_UNSUPPORTED_FACET_TYPES = ["blue.poll.post.facet#option"]; 6 + const KNOWN_UNSUPPORTED_FACET_TYPES = [ 7 + "blue.poll.post.facet#option", 8 + "blue.moji.richtext.facet", 9 + ]; 7 10 8 11 // Matches social-app behavior 9 12 export function truncateUrl(url) {
+141
tests/unit/specs/atproto.test.js
··· 1 + import { describe, it, beforeEach, afterEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { 4 + resolveHandle, 5 + resolveIdentity, 6 + getServiceEndpointForHandle, 7 + IdentityResolver, 8 + } from "/js/atproto.js"; 9 + import { MockFetch } from "../testHelpers.js"; 10 + 11 + describe("atproto handle resolution", () => { 12 + const originalFetch = globalThis.fetch; 13 + 14 + beforeEach(() => { 15 + globalThis.fetch = new MockFetch(); 16 + }); 17 + 18 + afterEach(() => { 19 + globalThis.fetch = originalFetch; 20 + }); 21 + 22 + function stubDid(did) { 23 + globalThis.fetch.__interceptJson(/resolveHandle/, { did }); 24 + } 25 + 26 + function stubPlcDoc(did, doc) { 27 + globalThis.fetch.__interceptJson( 28 + `https://plc.directory/${encodeURIComponent(did)}`, 29 + doc, 30 + ); 31 + } 32 + 33 + describe("resolveIdentity", () => { 34 + it("returns did+doc when the DID doc references the handle back", async () => { 35 + const did = "did:plc:aaaa"; 36 + const doc = { 37 + alsoKnownAs: ["at://alice.example"], 38 + service: [ 39 + { 40 + id: "#atproto_pds", 41 + serviceEndpoint: "https://pds.example.com", 42 + }, 43 + ], 44 + }; 45 + stubDid(did); 46 + stubPlcDoc(did, doc); 47 + 48 + const result = await resolveIdentity("alice.example"); 49 + assert.deepEqual(result.did, did); 50 + assert.deepEqual(result.didDoc, doc); 51 + }); 52 + 53 + it("throws when the DID doc does not reference the handle", async () => { 54 + const did = "did:plc:aaaa"; 55 + stubDid(did); 56 + stubPlcDoc(did, { 57 + alsoKnownAs: ["at://someone-else.example"], 58 + service: [], 59 + }); 60 + 61 + await assert.rejects( 62 + () => resolveIdentity("alice.example"), 63 + /does not reference handle/, 64 + ); 65 + }); 66 + 67 + it("returns null when the handle does not resolve", async () => { 68 + stubDid(null); 69 + const result = await resolveIdentity("nope.example"); 70 + assert.deepEqual(result, null); 71 + }); 72 + }); 73 + 74 + describe("resolveHandle", () => { 75 + it("returns the verified did", async () => { 76 + const did = "did:plc:aaaa"; 77 + stubDid(did); 78 + stubPlcDoc(did, { 79 + alsoKnownAs: ["at://alice.example"], 80 + service: [], 81 + }); 82 + assert.deepEqual(await resolveHandle("alice.example"), did); 83 + }); 84 + 85 + it("throws on verification failure — callers cannot silently use a spoofed mapping", async () => { 86 + stubDid("did:plc:aaaa"); 87 + stubPlcDoc("did:plc:aaaa", { 88 + alsoKnownAs: ["at://mallory.example"], 89 + service: [], 90 + }); 91 + await assert.rejects(() => resolveHandle("alice.example")); 92 + }); 93 + }); 94 + 95 + describe("getServiceEndpointForHandle", () => { 96 + it("returns the PDS endpoint after verifying the handle", async () => { 97 + const did = "did:plc:aaaa"; 98 + stubDid(did); 99 + stubPlcDoc(did, { 100 + alsoKnownAs: ["at://alice.example"], 101 + service: [ 102 + { 103 + id: "#atproto_pds", 104 + serviceEndpoint: "https://pds.example.com", 105 + }, 106 + ], 107 + }); 108 + const endpoint = await getServiceEndpointForHandle("alice.example"); 109 + assert.deepEqual(endpoint, "https://pds.example.com"); 110 + }); 111 + }); 112 + 113 + describe("IdentityResolver.resolveHandle", () => { 114 + it("caches the verified DID and does not re-verify", async () => { 115 + const did = "did:plc:aaaa"; 116 + stubDid(did); 117 + stubPlcDoc(did, { 118 + alsoKnownAs: ["at://alice.example"], 119 + service: [], 120 + }); 121 + const resolver = new IdentityResolver(); 122 + const first = await resolver.resolveHandle("alice.example"); 123 + const callsAfterFirst = globalThis.fetch.calls.length; 124 + const second = await resolver.resolveHandle("alice.example"); 125 + assert.deepEqual(first, did); 126 + assert.deepEqual(second, did); 127 + assert.deepEqual(globalThis.fetch.calls.length, callsAfterFirst); 128 + }); 129 + 130 + it("does not cache when verification fails, so retries are possible", async () => { 131 + stubDid("did:plc:aaaa"); 132 + stubPlcDoc("did:plc:aaaa", { 133 + alsoKnownAs: ["at://someone-else.example"], 134 + service: [], 135 + }); 136 + const resolver = new IdentityResolver(); 137 + await assert.rejects(() => resolver.resolveHandle("alice.example")); 138 + assert(!resolver.handleToDidMap.has("alice.example")); 139 + }); 140 + }); 141 + });
+170
tests/unit/specs/components/plugin-blob-image.test.js
··· 1 + import { describe, it, beforeEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import "/js/components/plugin-blob-image.js"; 4 + 5 + describe("plugin-blob-image", () => { 6 + const VALID_CID = 7 + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy"; 8 + let didCounter = 0; 9 + 10 + function uniqueDid() { 11 + didCounter++; 12 + return `did:plc:test${didCounter.toString().padStart(6, "0")}xxxxxxx`; 13 + } 14 + 15 + async function flush() { 16 + await new Promise((resolve) => setTimeout(resolve, 0)); 17 + } 18 + 19 + function makeElement({ did, cid, alt, cdnPrefix }) { 20 + const element = document.createElement("plugin-blob-image"); 21 + if (did !== undefined) element.setAttribute("did", did); 22 + if (cid !== undefined) element.setAttribute("cid", cid); 23 + if (alt !== undefined) element.setAttribute("alt", alt); 24 + if (cdnPrefix !== undefined) element.setAttribute("cdn-prefix", cdnPrefix); 25 + document.body.appendChild(element); 26 + return element; 27 + } 28 + 29 + beforeEach(() => { 30 + document.body.innerHTML = ""; 31 + }); 32 + 33 + describe("input validation", () => { 34 + it("renders the alt fallback when did is missing", async () => { 35 + const element = makeElement({ 36 + cid: VALID_CID, 37 + alt: ":blobcat:", 38 + cdnPrefix: "feed_thumbnail", 39 + }); 40 + await flush(); 41 + assert(element.querySelector("img") === null); 42 + const fallback = element.querySelector(".blob-image-fallback"); 43 + assert(fallback !== null); 44 + assert.deepEqual(fallback.textContent, ":blobcat:"); 45 + }); 46 + 47 + it("renders the alt fallback when cid is malformed", async () => { 48 + const element = makeElement({ 49 + did: uniqueDid(), 50 + cid: "not-a-cid", 51 + alt: ":x:", 52 + cdnPrefix: "feed_thumbnail", 53 + }); 54 + await flush(); 55 + assert(element.querySelector("img") === null); 56 + assert(element.querySelector(".blob-image-fallback") !== null); 57 + }); 58 + 59 + it("renders the alt fallback for a bogus DID method", async () => { 60 + const element = makeElement({ 61 + did: "did:evil:x", 62 + cid: VALID_CID, 63 + alt: ":x:", 64 + cdnPrefix: "feed_thumbnail", 65 + }); 66 + await flush(); 67 + assert(element.querySelector("img") === null); 68 + assert(element.querySelector(".blob-image-fallback") !== null); 69 + }); 70 + 71 + it("falls back when cdn-prefix is missing", async () => { 72 + const element = makeElement({ 73 + did: uniqueDid(), 74 + cid: VALID_CID, 75 + alt: ":x:", 76 + }); 77 + await flush(); 78 + assert(element.querySelector("img") === null); 79 + assert(element.querySelector(".blob-image-fallback") !== null); 80 + }); 81 + 82 + it("falls back when cdn-prefix is not allowlisted", async () => { 83 + const element = makeElement({ 84 + did: uniqueDid(), 85 + cid: VALID_CID, 86 + alt: ":x:", 87 + cdnPrefix: "../secrets", 88 + }); 89 + await flush(); 90 + assert(element.querySelector("img") === null); 91 + assert(element.querySelector(".blob-image-fallback") !== null); 92 + }); 93 + }); 94 + 95 + describe("URL construction", () => { 96 + it("builds a bsky CDN URL from did/cid/cdn-prefix", async () => { 97 + const did = uniqueDid(); 98 + const element = makeElement({ 99 + did, 100 + cid: VALID_CID, 101 + alt: "avatar", 102 + cdnPrefix: "avatar_thumbnail", 103 + }); 104 + await flush(); 105 + const img = element.querySelector("img"); 106 + assert(img !== null); 107 + assert.deepEqual( 108 + img.getAttribute("src"), 109 + `https://cdn.bsky.app/img/avatar_thumbnail/plain/${did}/${VALID_CID}@jpeg`, 110 + ); 111 + assert.deepEqual(img.getAttribute("alt"), "avatar"); 112 + }); 113 + }); 114 + 115 + describe("error handling", () => { 116 + it("shows fallback on image error", async () => { 117 + const element = makeElement({ 118 + did: uniqueDid(), 119 + cid: VALID_CID, 120 + alt: ":x:", 121 + cdnPrefix: "feed_thumbnail", 122 + }); 123 + await flush(); 124 + 125 + const img = element.querySelector("img"); 126 + assert(img !== null); 127 + img.dispatchEvent(new window.Event("error")); 128 + await flush(); 129 + assert(element.querySelector("img") === null); 130 + assert(element.querySelector(".blob-image-fallback") !== null); 131 + }); 132 + }); 133 + 134 + describe("attribute changes", () => { 135 + it("re-renders when did changes", async () => { 136 + const didA = uniqueDid(); 137 + const didB = uniqueDid(); 138 + const element = makeElement({ 139 + did: didA, 140 + cid: VALID_CID, 141 + alt: ":x:", 142 + cdnPrefix: "feed_thumbnail", 143 + }); 144 + await flush(); 145 + assert(element.querySelector("img").getAttribute("src").includes(didA)); 146 + 147 + element.setAttribute("did", didB); 148 + await flush(); 149 + assert(element.querySelector("img").getAttribute("src").includes(didB)); 150 + }); 151 + 152 + it("clears the error state so a fixed cid stops showing the fallback", async () => { 153 + const element = makeElement({ 154 + did: uniqueDid(), 155 + cid: VALID_CID, 156 + alt: ":x:", 157 + cdnPrefix: "feed_thumbnail", 158 + }); 159 + await flush(); 160 + 161 + element.querySelector("img").dispatchEvent(new window.Event("error")); 162 + await flush(); 163 + assert(element.querySelector("img") === null); 164 + 165 + element.setAttribute("cid", VALID_CID.replace("bafk", "bafy")); 166 + await flush(); 167 + assert(element.querySelector("img") !== null); 168 + }); 169 + }); 170 + });
+51
tests/unit/specs/plugins/pluginRendering.test.js
··· 390 390 }); 391 391 }); 392 392 393 + describe("PluginRenderer:plugin-blob-image", () => { 394 + it("renders <plugin-blob-image> and passes did/cid/alt/cdn-prefix attrs", () => { 395 + const { bridge } = makeBridge(); 396 + const renderer = new PluginRenderer(bridge, "demo"); 397 + const element = renderer.createRoot().render({ 398 + tag: "plugin-blob-image", 399 + attrs: { 400 + did: "did:plc:abc", 401 + cid: "bafkreiabcdefghijklmnopqrstuvwxyz234567", 402 + alt: ":blobcat:", 403 + "cdn-prefix": "feed_thumbnail", 404 + }, 405 + }); 406 + assert.deepEqual(element.tagName.toLowerCase(), "plugin-blob-image"); 407 + assert.deepEqual(element.getAttribute("did"), "did:plc:abc"); 408 + assert.deepEqual( 409 + element.getAttribute("cid"), 410 + "bafkreiabcdefghijklmnopqrstuvwxyz234567", 411 + ); 412 + assert.deepEqual(element.getAttribute("alt"), ":blobcat:"); 413 + assert.deepEqual(element.getAttribute("cdn-prefix"), "feed_thumbnail"); 414 + }); 415 + 416 + it("does not accept src or onclick on <plugin-blob-image>", () => { 417 + const { bridge } = makeBridge(); 418 + const renderer = new PluginRenderer(bridge, "demo"); 419 + const element = renderer.createRoot().render({ 420 + tag: "plugin-blob-image", 421 + attrs: { 422 + did: "did:plc:abc", 423 + src: "https://evil.example.com/track.gif", 424 + onclick: "alert(1)", 425 + }, 426 + }); 427 + assert(!element.hasAttribute("src")); 428 + assert(!element.hasAttribute("onclick")); 429 + }); 430 + 431 + it("does not allow <img> tags from plugin trees", () => { 432 + const { bridge } = makeBridge(); 433 + const renderer = new PluginRenderer(bridge, "demo"); 434 + const element = renderer.createRoot().render({ 435 + tag: "img", 436 + attrs: { src: "https://evil.example.com/track.gif" }, 437 + }); 438 + // Disallowed tags fall back to <span> and the src attribute is not on the allowlist. 439 + assert.deepEqual(element.tagName.toLowerCase(), "span"); 440 + assert(!element.hasAttribute("src")); 441 + }); 442 + }); 443 + 393 444 describe("PluginRenderer:custom element observedAttributes", () => { 394 445 it("passes through attrs declared in a custom element's observedAttributes", () => { 395 446 // plugin-icon declares observedAttributes = ["icon"] — verifies the
+24 -24
tests/unit/specs/plugins/pluginRequests.test.js
··· 1 1 import { describe, it } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { makePluginRequest } from "/js/plugins/pluginRequests.js"; 3 + import { pluginFetch } from "/js/plugins/pluginRequests.js"; 4 4 5 5 function makePlugin(patterns) { 6 6 return { pluginId: "demo", permissions: { fetch: patterns } }; ··· 42 42 it("rejects http URLs even if pattern matches host", async () => { 43 43 const { fakeFetch, calls } = makeFakeFetch(); 44 44 await expectRejection(() => 45 - makePluginRequest( 45 + pluginFetch( 46 46 makePlugin(["https://example.com/*"]), 47 47 "http://example.com/foo", 48 48 {}, ··· 55 55 it("rejects http patterns even with https URL", async () => { 56 56 const { fakeFetch } = makeFakeFetch(); 57 57 await expectRejection(() => 58 - makePluginRequest( 58 + pluginFetch( 59 59 makePlugin(["http://example.com/*"]), 60 60 "https://example.com/x", 61 61 {}, ··· 68 68 describe("allowlist - host matching", () => { 69 69 it("allows exact host + path match", async () => { 70 70 const { fakeFetch, calls } = makeFakeFetch(); 71 - await makePluginRequest( 71 + await pluginFetch( 72 72 makePlugin(["https://example.com/things"]), 73 73 "https://example.com/things", 74 74 {}, ··· 80 80 it("rejects a different host", async () => { 81 81 const { fakeFetch } = makeFakeFetch(); 82 82 await expectRejection(() => 83 - makePluginRequest( 83 + pluginFetch( 84 84 makePlugin(["https://example.com/things"]), 85 85 "https://evil.com/things", 86 86 {}, ··· 92 92 it("rejects a subdomain when pattern has no wildcard", async () => { 93 93 const { fakeFetch } = makeFakeFetch(); 94 94 await expectRejection(() => 95 - makePluginRequest( 95 + pluginFetch( 96 96 makePlugin(["https://example.com/*"]), 97 97 "https://api.example.com/things", 98 98 {}, ··· 103 103 104 104 it("is case-insensitive on host", async () => { 105 105 const { fakeFetch, calls } = makeFakeFetch(); 106 - await makePluginRequest( 106 + await pluginFetch( 107 107 makePlugin(["https://example.com/things"]), 108 108 "https://Example.COM/things", 109 109 {}, ··· 114 114 115 115 it("matches *.host on the bare domain", async () => { 116 116 const { fakeFetch, calls } = makeFakeFetch(); 117 - await makePluginRequest( 117 + await pluginFetch( 118 118 makePlugin(["https://*.example.com/*"]), 119 119 "https://example.com/foo", 120 120 {}, ··· 125 125 126 126 it("matches *.host on a subdomain", async () => { 127 127 const { fakeFetch, calls } = makeFakeFetch(); 128 - await makePluginRequest( 128 + await pluginFetch( 129 129 makePlugin(["https://*.example.com/*"]), 130 130 "https://api.example.com/foo", 131 131 {}, ··· 137 137 it("does not match an unrelated suffix that happens to end in the domain", async () => { 138 138 const { fakeFetch } = makeFakeFetch(); 139 139 await expectRejection(() => 140 - makePluginRequest( 140 + pluginFetch( 141 141 makePlugin(["https://*.example.com/*"]), 142 142 "https://notexample.com/foo", 143 143 {}, ··· 149 149 it("is not fooled by userinfo confusion", async () => { 150 150 const { fakeFetch } = makeFakeFetch(); 151 151 await expectRejection(() => 152 - makePluginRequest( 152 + pluginFetch( 153 153 makePlugin(["https://example.com/*"]), 154 154 "https://example.com@evil.com/x", 155 155 {}, ··· 162 162 describe("allowlist - path matching", () => { 163 163 it("matches by prefix when path ends with *", async () => { 164 164 const { fakeFetch, calls } = makeFakeFetch(); 165 - await makePluginRequest( 165 + await pluginFetch( 166 166 makePlugin(["https://example.com/v1/*"]), 167 167 "https://example.com/v1/items/42", 168 168 {}, ··· 174 174 it("requires exact path when no trailing *", async () => { 175 175 const { fakeFetch } = makeFakeFetch(); 176 176 await expectRejection(() => 177 - makePluginRequest( 177 + pluginFetch( 178 178 makePlugin(["https://example.com/v1"]), 179 179 "https://example.com/v1/items", 180 180 {}, ··· 186 186 it("rejects when plugin has no fetch permissions", async () => { 187 187 const { fakeFetch, calls } = makeFakeFetch(); 188 188 await expectRejection(() => 189 - makePluginRequest( 189 + pluginFetch( 190 190 { pluginId: "demo", permissions: {} }, 191 191 "https://api.example.com/x", 192 192 {}, ··· 200 200 describe("safe fetch options", () => { 201 201 it("forces credentials=omit and redirect=error", async () => { 202 202 const { fakeFetch, calls } = makeFakeFetch(); 203 - await makePluginRequest( 203 + await pluginFetch( 204 204 makePlugin(["https://api.example.com/*"]), 205 205 "https://api.example.com/x", 206 206 {}, ··· 213 213 214 214 it("defaults method to GET", async () => { 215 215 const { fakeFetch, calls } = makeFakeFetch(); 216 - await makePluginRequest( 216 + await pluginFetch( 217 217 makePlugin(["https://api.example.com/*"]), 218 218 "https://api.example.com/x", 219 219 {}, ··· 224 224 225 225 it("passes through allowed methods uppercased", async () => { 226 226 const { fakeFetch, calls } = makeFakeFetch(); 227 - await makePluginRequest( 227 + await pluginFetch( 228 228 makePlugin(["https://api.example.com/*"]), 229 229 "https://api.example.com/x", 230 230 { method: "post" }, ··· 237 237 const { fakeFetch, calls } = makeFakeFetch(); 238 238 await expectRejection( 239 239 () => 240 - makePluginRequest( 240 + pluginFetch( 241 241 makePlugin(["https://api.example.com/*"]), 242 242 "https://api.example.com/x", 243 243 { method: "CONNECT" }, ··· 252 252 describe("header handling", () => { 253 253 it("forwards allowed headers", async () => { 254 254 const { fakeFetch, calls } = makeFakeFetch(); 255 - await makePluginRequest( 255 + await pluginFetch( 256 256 makePlugin(["https://api.example.com/*"]), 257 257 "https://api.example.com/x", 258 258 { headers: { "X-Custom": "v" } }, ··· 265 265 const { fakeFetch, calls } = makeFakeFetch(); 266 266 await expectRejection( 267 267 () => 268 - makePluginRequest( 268 + pluginFetch( 269 269 makePlugin(["https://api.example.com/*"]), 270 270 "https://api.example.com/x", 271 271 { headers: { Cookie: "session=abc" } }, ··· 280 280 describe("body handling", () => { 281 281 it("forwards a string body", async () => { 282 282 const { fakeFetch, calls } = makeFakeFetch(); 283 - await makePluginRequest( 283 + await pluginFetch( 284 284 makePlugin(["https://api.example.com/*"]), 285 285 "https://api.example.com/x", 286 286 { method: "POST", body: '{"a":1}' }, ··· 292 292 it("rejects non-string body", async () => { 293 293 const { fakeFetch } = makeFakeFetch(); 294 294 await expectRejection(() => 295 - makePluginRequest( 295 + pluginFetch( 296 296 makePlugin(["https://api.example.com/*"]), 297 297 "https://api.example.com/x", 298 298 { method: "POST", body: { a: 1 } }, ··· 308 308 headers: { "content-type": "application/json", "set-cookie": "x=1" }, 309 309 body: '{"a":1}', 310 310 }); 311 - const result = await makePluginRequest( 311 + const result = await pluginFetch( 312 312 makePlugin(["https://api.example.com/*"]), 313 313 "https://api.example.com/x", 314 314 {}, ··· 320 320 321 321 it("exposes status and ok", async () => { 322 322 const { fakeFetch } = makeFakeFetch({ status: 404, body: "nope" }); 323 - const result = await makePluginRequest( 323 + const result = await pluginFetch( 324 324 makePlugin(["https://api.example.com/*"]), 325 325 "https://api.example.com/x", 326 326 {},
+125
tests/unit/specs/plugins/pluginService.test.js
··· 1318 1318 }); 1319 1319 }); 1320 1320 1321 + describe("getRecord host method", () => { 1322 + function makeServiceWithRealBridge() { 1323 + const { provider } = makeProvider(); 1324 + return new PluginService(provider, null); 1325 + } 1326 + 1327 + function jsonResponse(status, body) { 1328 + return { 1329 + ok: status >= 200 && status < 300, 1330 + status, 1331 + json: async () => body, 1332 + }; 1333 + } 1334 + 1335 + const VALID_COLLECTION = "blue.moji.collection.item"; 1336 + const VALID_RKEY = "blobcat"; 1337 + 1338 + let didCounter = 0; 1339 + function uniqueDid() { 1340 + didCounter++; 1341 + return `did:plc:test${didCounter.toString().padStart(6, "0")}`; 1342 + } 1343 + 1344 + const originalFetch = globalThis.fetch; 1345 + afterEach(() => { 1346 + globalThis.fetch = originalFetch; 1347 + }); 1348 + 1349 + function stubFetch(handler) { 1350 + const calls = []; 1351 + globalThis.fetch = async (input) => { 1352 + const url = typeof input === "string" ? input : input.toString(); 1353 + calls.push(url); 1354 + return handler(url); 1355 + }; 1356 + return { calls }; 1357 + } 1358 + 1359 + it("fetches the record from Slingshot", async () => { 1360 + const service = makeServiceWithRealBridge(); 1361 + const did = uniqueDid(); 1362 + const record = { 1363 + uri: `at://${did}/${VALID_COLLECTION}/${VALID_RKEY}`, 1364 + cid: "bafyfake", 1365 + value: { name: "blobcat" }, 1366 + }; 1367 + const { calls } = stubFetch(async () => jsonResponse(200, record)); 1368 + const handler = service.pluginBridge._hostCallHandlers.get("getRecord"); 1369 + const result = await handler(null, { 1370 + repo: did, 1371 + collection: VALID_COLLECTION, 1372 + rkey: VALID_RKEY, 1373 + }); 1374 + assert.deepEqual(result, record); 1375 + const url = new URL(calls[0]); 1376 + assert.deepEqual(url.origin, "https://slingshot.microcosm.blue"); 1377 + assert.deepEqual(url.pathname, "/xrpc/com.atproto.repo.getRecord"); 1378 + assert.deepEqual(url.searchParams.get("repo"), did); 1379 + assert.deepEqual(url.searchParams.get("collection"), VALID_COLLECTION); 1380 + assert.deepEqual(url.searchParams.get("rkey"), VALID_RKEY); 1381 + }); 1382 + 1383 + it("returns null on RecordNotFound", async () => { 1384 + const service = makeServiceWithRealBridge(); 1385 + const did = uniqueDid(); 1386 + stubFetch(async () => 1387 + jsonResponse(400, { error: "RecordNotFound", message: "gone" }), 1388 + ); 1389 + const handler = service.pluginBridge._hostCallHandlers.get("getRecord"); 1390 + const result = await handler(null, { 1391 + repo: did, 1392 + collection: VALID_COLLECTION, 1393 + rkey: VALID_RKEY, 1394 + }); 1395 + assert.deepEqual(result, null); 1396 + }); 1397 + 1398 + it("rejects on other errors so the plugin can retry", async () => { 1399 + const service = makeServiceWithRealBridge(); 1400 + const did = uniqueDid(); 1401 + stubFetch(async () => jsonResponse(502, null)); 1402 + const handler = service.pluginBridge._hostCallHandlers.get("getRecord"); 1403 + let caught = null; 1404 + try { 1405 + await handler(null, { 1406 + repo: did, 1407 + collection: VALID_COLLECTION, 1408 + rkey: VALID_RKEY, 1409 + }); 1410 + } catch (e) { 1411 + caught = e; 1412 + } 1413 + assert(caught !== null); 1414 + }); 1415 + 1416 + it("rejects invalid repo/collection/rkey inputs without hitting the network", async () => { 1417 + const service = makeServiceWithRealBridge(); 1418 + let fetched = false; 1419 + globalThis.fetch = async () => { 1420 + fetched = true; 1421 + return jsonResponse(200, {}); 1422 + }; 1423 + const handler = service.pluginBridge._hostCallHandlers.get("getRecord"); 1424 + const invalidInputs = [ 1425 + { repo: "not-a-did", collection: VALID_COLLECTION, rkey: VALID_RKEY }, 1426 + { repo: "did:plc:abc", collection: "not.enough", rkey: VALID_RKEY }, 1427 + { repo: "did:plc:abc", collection: VALID_COLLECTION, rkey: "" }, 1428 + { repo: "did:plc:abc", collection: VALID_COLLECTION, rkey: "has/slash" }, 1429 + ]; 1430 + for (const inputs of invalidInputs) { 1431 + let caught = null; 1432 + try { 1433 + await handler(null, inputs); 1434 + } catch (e) { 1435 + caught = e; 1436 + } 1437 + assert( 1438 + caught !== null, 1439 + `expected rejection for ${JSON.stringify(inputs)}`, 1440 + ); 1441 + } 1442 + assert.deepEqual(fetched, false); 1443 + }); 1444 + }); 1445 + 1321 1446 describe("getPostComposerInit", () => { 1322 1447 function addListener(service, pluginId, handler) { 1323 1448 let listeners = service.registries.eventListeners.get("post-composer-open");
+130
tests/unit/specs/slingshot.test.js
··· 1 + import { describe, it, afterEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { Slingshot } from "/js/slingshot.js"; 4 + 5 + describe("Slingshot", () => { 6 + const VALID_COLLECTION = "blue.moji.collection.item"; 7 + const VALID_RKEY = "blobcat"; 8 + 9 + let didCounter = 0; 10 + function uniqueDid() { 11 + didCounter++; 12 + return `did:plc:test${didCounter.toString().padStart(6, "0")}`; 13 + } 14 + 15 + const originalFetch = globalThis.fetch; 16 + afterEach(() => { 17 + globalThis.fetch = originalFetch; 18 + }); 19 + 20 + function jsonResponse(status, body) { 21 + return { 22 + ok: status >= 200 && status < 300, 23 + status, 24 + json: async () => body, 25 + }; 26 + } 27 + 28 + function stubFetch(handler) { 29 + const calls = []; 30 + const fetchImpl = async (input) => { 31 + const url = typeof input === "string" ? input : input.toString(); 32 + calls.push(url); 33 + return handler(url); 34 + }; 35 + return { calls, fetchImpl }; 36 + } 37 + 38 + describe("getRecord", () => { 39 + it("fetches from slingshot with the expected query params", async () => { 40 + const did = uniqueDid(); 41 + const record = { 42 + uri: `at://${did}/${VALID_COLLECTION}/${VALID_RKEY}`, 43 + cid: "bafyfake", 44 + value: { name: "blobcat" }, 45 + }; 46 + const { calls, fetchImpl } = stubFetch(async () => 47 + jsonResponse(200, record), 48 + ); 49 + const slingshot = new Slingshot({ fetchImpl }); 50 + const result = await slingshot.getRecord({ 51 + repo: did, 52 + collection: VALID_COLLECTION, 53 + rkey: VALID_RKEY, 54 + }); 55 + assert.deepEqual(result, record); 56 + const url = new URL(calls[0]); 57 + assert.deepEqual(url.origin, "https://slingshot.microcosm.blue"); 58 + assert.deepEqual(url.pathname, "/xrpc/com.atproto.repo.getRecord"); 59 + assert.deepEqual(url.searchParams.get("repo"), did); 60 + assert.deepEqual(url.searchParams.get("collection"), VALID_COLLECTION); 61 + assert.deepEqual(url.searchParams.get("rkey"), VALID_RKEY); 62 + }); 63 + 64 + it("returns null on RecordNotFound", async () => { 65 + const { fetchImpl } = stubFetch(async () => 66 + jsonResponse(400, { error: "RecordNotFound", message: "gone" }), 67 + ); 68 + const slingshot = new Slingshot({ fetchImpl }); 69 + const result = await slingshot.getRecord({ 70 + repo: uniqueDid(), 71 + collection: VALID_COLLECTION, 72 + rkey: VALID_RKEY, 73 + }); 74 + assert.deepEqual(result, null); 75 + }); 76 + 77 + it("throws on non-RecordNotFound 400s", async () => { 78 + const { fetchImpl } = stubFetch(async () => 79 + jsonResponse(400, { error: "InvalidRequest", message: "bad rkey" }), 80 + ); 81 + const slingshot = new Slingshot({ fetchImpl }); 82 + await assert.rejects(() => 83 + slingshot.getRecord({ 84 + repo: uniqueDid(), 85 + collection: VALID_COLLECTION, 86 + rkey: VALID_RKEY, 87 + }), 88 + ); 89 + }); 90 + 91 + it("throws on server errors", async () => { 92 + const { fetchImpl } = stubFetch(async () => jsonResponse(502, null)); 93 + const slingshot = new Slingshot({ fetchImpl }); 94 + await assert.rejects(() => 95 + slingshot.getRecord({ 96 + repo: uniqueDid(), 97 + collection: VALID_COLLECTION, 98 + rkey: VALID_RKEY, 99 + }), 100 + ); 101 + }); 102 + 103 + it("rejects invalid repo/collection/rkey without hitting the network", async () => { 104 + let fetched = false; 105 + const slingshot = new Slingshot({ 106 + fetchImpl: async () => { 107 + fetched = true; 108 + return jsonResponse(200, {}); 109 + }, 110 + }); 111 + const invalidInputs = [ 112 + { repo: "not-a-did", collection: VALID_COLLECTION, rkey: VALID_RKEY }, 113 + { repo: "did:plc:abc", collection: "not.enough", rkey: VALID_RKEY }, 114 + { repo: "did:plc:abc", collection: VALID_COLLECTION, rkey: "" }, 115 + { 116 + repo: "did:plc:abc", 117 + collection: VALID_COLLECTION, 118 + rkey: "has/slash", 119 + }, 120 + ]; 121 + for (const inputs of invalidInputs) { 122 + await assert.rejects( 123 + () => slingshot.getRecord(inputs), 124 + `expected rejection for ${JSON.stringify(inputs)}`, 125 + ); 126 + } 127 + assert.deepEqual(fetched, false); 128 + }); 129 + }); 130 + });