Browser extension: detect and subscribe to standard.site publications on ATProto
substandard TODO.md
17 kB

TODO #

Fewer well-known probes, without losing detection #

The extension probes /.well-known/site.standard.publication on every origin the user visits. Two known costs, both measured rather than assumed:

  • Gating the probe on a <link> hint is not safe today. Checked live on 2026-08-12: standard.site itself serves the well-known (200) and emits no link tag at all, so hint-gating would stop detecting the flagship publication. atproto.com/blog is the mirror case — it emits a link tag and 404s the origin well-known, answering only on the path-suffixed probe. permadeath.com has both. Any narrowing has to survive all three shapes. Revisit if the standard.site spec ever makes the link tag mandatory.
  • Path probes multiply on multi-tenant hosts. On an origin with no publication, each distinct first path segment earns its own probe (see the test in src/lib/detection.test.ts), so browsing a large host produces one probe per tenant visited. Capping probes per origin per session would fix it at the cost of false negatives for publications under a rarely-visited path.

Not attempted here: skipping loopback and private hosts. Local dev of a publication depends on those probes working.

What the blocked-publisher warning cannot see #

The warning reads app.bsky.graph.block from the signed-in user's own repo (getBlocks in src/background.ts), which is public data and needs no extra OAuth scope. Three gaps, all of them false negatives:

  • Mutes. Not records at all; they live on the appview behind app.bsky.graph.getMutes, which needs an authenticated appview call this extension's token cannot make (its scope covers repo writes, not service proxying). Nothing to do here without asking for a wider scope.
  • Moderation lists. Blocking via a list is an app.bsky.graph.listblock record pointing at a list; deciding whether a publisher is in one means fetching every subscribed list's members, which is an appview read too.
  • Very large block lists. listRecords stops at 2000 records, so an account with more blocks than that can visit a blocked publisher and see no warning. Fixable with a paged scan that stops at the first match, at the cost of scanning on every miss.

Also unbuilt on purpose: a warning for the reverse direction (the publisher blocked you). Their block records are public, but reading them means listing a stranger's whole block collection on every publication visited.

Nudge users to pin the toolbar icon #

The badge is invisible until the user pins the extension, and Chrome offers no manifest field or API to request pinning — only detection, via chrome.action.getUserSettings() (isOnToolbar, no extra permission). Unpinned, everything still works from the puzzle menu; what's lost is the passive badge, which is the product's pitch.

The onboarding page is built: a bundled welcome.html — not a trip to substandard.blog, so it works before the first page load and can read the pin state — opened by the worker on chrome.runtime.onInstalled with reason === "install". It draws the extensions menu, and polls getUserSettings so the line under the picture answers the moment the icon is pinned. The dev build's popup footer carries a debug menu that reopens it, since Chrome otherwise shows it exactly once per install. Its words have since been written by a human. scripts/deploy-ext.sh still refuses a build carrying the SNICKERSNEE placeholder token, now a guard against a later rewrite rather than a gate on unwritten copy. Two things are still open:

  • Its picture is a drawing, not a capture. Chrome's toolbar and its extensions menu are native UI and never appear in a page screenshot — the same reason scripts/capture-status-docs.mjs draws its badge tiles rather than shooting a toolbar — so the SVG in welcome.html is a hand-drawn stand-in. Replacing it means a capture taken by hand.
  • The popup pill is unbuilt: an informational pill in the existing src/lib/status.ts stack while unpinned ("pin to see the badge as you browse" — wording is a human's call). Zero new UI machinery, but it only reaches users who already found the popup.

Recapture now that substandard.blog is a publication #

The listing is live and the publication record is written, so both halves of what this was waiting on are done:

  • Recapture the popup screenshots, so the store screenshots show our own record. Every capture of a real publication uses a real one (REAL in scripts/capture-status-docs.mjs); the one deliberate fixture is the moderated card (BAD_BLOG), which stays invented — nobody's real publication is going to model "blocked and labeled". The homepage gallery shows the same captures (scripts/shots.mjs), so npm run recapture is the whole pass — shots, store tiles and frontpage copies together.
  • Maybe: wire the CWS Upload API (chrome-webstore-upload-cli) into the release flow — the upload endpoint is the only authoritative validator (it rejects things no local tool checks, like a manifest key).

Sessions granted before the current scope list #

The hosted metadata already carries include:app.userinput.authBasic and rpc:app.bsky.actor.getPreferences?aud=*, so the deploy-before-release ordering this entry used to describe is done and check-oauth-metadata.mjs --hosted passes. What is left is the other half:

  • Sessions granted under the old list cannot post; the panel says so when the PDS refuses the write. Nothing prompts for re-consent on its own — decide whether that is worth a status pill once there are users with old sessions. The labeler list fails more quietly on the same session: it falls back to the local list and logs, because a label is advisory and a wrong-looking set of labels is better than none. If the status pill happens, this is a second reason for it.

Deliberately not built: image attachments. The lexicon takes up to four (app.userinput.discussion #image, 1 MB each), and a screenshot of the badge would be the obvious one, but it needs blob:image/png on top of the permission set, a capture path, and a preview in a 400px popup.

Show a pub's post count in the popup #

When the popup shows a detected publication, also show how many documents it has. ATProto has no count endpoint (listRecords has no total field), so use Constellation, microcosm's free public backlink index, instead of paginating the whole collection:

GET https://constellation.microcosm.blue/links/count
    ?target=<publication at-uri>
    &collection=site.standard.document
    &path=.site

The subscriber row (src/lib/subscribers.ts) already talks to Constellation and already handles the two at-uri forms; the count should reuse it rather than open a second client.

Details that matter:

  • Documents in the wild write the site field in both handle form (at://example.com/...) and DID form (at://did:plc:.../...), and Constellation matches target strings exactly. Query both forms and sum — subscriptionTargets in src/lib/subscribers.ts already builds the pair for the same reason. Spot-checked on permadeath.com's publication (2026-08-12): 10 in DID form, 0 in handle form, which matches its exact listRecords count. A publication whose well-known answers in handle form splits the other way, which is what the sum is for.
  • Responses are instant and unauthenticated. Cache per pub with a TTL like the existing DID cache in src/lib/atproto.ts; counts change slowly.
  • Semantics are network-wide: a document in someone else's repo pointing at the pub counts too. For standard.site pubs today all documents live in the owner's repo, so the numbers agree.
  • Constellation is a third-party dependency. If it is down, hide the count rather than falling back to pagination.

Check the store listing's Privacy practices tab against reality #

The listing is live, and CWS enforces the data-use declarations on it. Confirm the dashboard says what the extension actually does: broad host permissions with a justification, "Website content" handling, and no collection (there is no backend). A mismatch between the declared practices and observable behaviour is a takedown risk, not a paperwork one. The privacy policy itself now describes the per-site check and the ecosystem lookups; this is the other copy of those claims, and the two should not drift apart.

There is now a third copy: /architecture renders web/src/diagrams/architecture.mmd as inline SVG. It used to also print a per-caller, per-host table from the calls and storage arrays in web/src/pages/architecture.astro; that markup was removed, but the arrays are still there, unused — a reviewer checking the diagram against the source has to read the frontmatter directly instead.

Decide what to do about the 843 kB popup chunk #

dist/chunks/oauth-*.js is 843 kB (169 kB gzipped) against a 34 kB popup, and src/popup/popup.ts imports it statically, so it is parsed before first paint even for signed-out users who will never authorize anything. Two independent levers, either worth taking alone:

  • Import it dynamically on the signed-in and subscribe paths, so the signed-out popup never pays for it.
  • core-js ships inside that chunk — it is a dependency of @atproto/oauth-client-browser (still there in 0.5.3). It is dead weight in an MV3 extension, which only ever runs on a modern Chrome. Check whether a vite alias to a stub is safe.

Measure before and after; the numbers above are from npx vite build after the 0.20 / 0.5 upgrade, which took the chunk down from 1,131 kB on its own.

No CI #

Nothing runs npm run check, npm test, verify:dist or check-oauth-metadata except a human at a terminal. The prek hooks cover a committer who has them installed and nothing else. Needs a decision about where CI would run for a Tangled-hosted repo before it is worth wiring.

Subscriber row: two caps that can hide a face #

src/lib/subscribers.ts answers "who that you follow subscribes here" by intersecting two lists, and both are capped:

  • Constellation is paged to SCAN_CAP (500) subscriber DIDs. The count stays exact past that — it comes from the index's own total — but a followed subscriber past the cap is not drawn. truncated records this and the row says so in its tooltip.
  • The follow set stops at FOLLOWS_CAP (10,000 records, so a hundred requests). truncated records this too, and the row's tooltip names whichever cap could be hiding a face.

The first cap binds on a publication and has not bound on any measured so far (the largest had 53 subscribers). The second binds on the reader, and costs more than a face: the owner card's "Following" line reads the same set, so a reader past the cap can be told they do not follow an account they followed early. Both now say when they bit, which is the part that used to be invisible; neither is fixed.

10,000 is bounded by the MV3 worker, not by the PDS. The walk is once a day and off every render path (graph in src/lib/cache.ts), so the cost is fine, but a service worker only stays up while its event is being handled and a walk killed mid-flight caches nothing to show for it. Covering the tail above 10,000 therefore wants a walk that can resume from a stored cursor across worker lifetimes, not a bigger constant.

The other direction is to stop holding the whole set and ask about the one account in question — Constellation, or the reader's own repo by rkey. That answers the owner card directly and makes its cap irrelevant; the subscriber row would need the reverse question, which of a publication's subscribers the reader follows.

Let the user manage their labelers #

src/lib/labels.ts reads the labeler list from chrome.storage.local (labelers, an array of DIDs) and falls back to Bluesky's moderation service. Nothing in the UI writes that key, so today the list is the default or whatever was set from the console. What is missing, cheapest first:

  • A labeler section in the popup: the current list, a handle box to add one (resolveHandleToDid then resolveLabelerEndpoint, which fails for an account that is not a labeler), and a remove control. The popup is 400px wide and already carries an account menu, a reader menu and a feedback panel, so this probably belongs behind its own disclosure rather than in the card.
  • Per-label settings. Bluesky lets a user set each label value to ignore/warn/hide; substandard applies the labeler's default. Honouring an override means storing a per-labeler, per-value map and threading it through viewLabel, which is where hides is decided.
  • A way to add a labeler for substandard alone. The subscription list is now read from the account's Bluesky preferences, which is the right default — but a labeler you want here and not in Bluesky has nowhere to live except the labelers storage key, which nothing writes.

Share to somewhere other than Bluesky #

The share button (src/lib/share.ts) hands the article to bsky.app/intent/compose, which is the only compose intent in the Atmosphere that is documented and stable. Two directions if that stops being enough:

  • Other clients. Aturi's @aturi.to/waypoints catalogs 28 Atmosphere clients with per-client URL builders, but every one of them is a view builder — there is no compose or intent field in the catalog — so "post from the client I actually use" cannot be answered from it today. Worth revisiting when the announced to.aturi.* preference lexicon lands.
  • Posting the record instead of the link. app.bsky.embed.record takes a strongRef to any record, so a post could point at the site.standard.document itself and be countable by Constellation. The Bluesky appview hydrates only record types it knows, so it would render as a not-found quote there; this only becomes attractive if a reader app that renders standard.site quotes gets popular enough to post from.

Waypoints: what to send upstream, and what to watch #

src/lib/readers.ts takes its reader list from @aturi.to/waypoints and overrides the URL shapes that turned out to be wrong. Three follow-ups, none of them ours alone:

  • Report the publication-vs-document gap. The catalog has one URL builder per client keyed on the collection prefix site.standard., so a site.standard.publication gets the article route: Standard Reader renders a blank "Article" page and Leaflet renders the account's profile. Both have a real publication route (/p/{did}/{rkey}, /lish/{did}/{rkey}), which is what the overrides here use. Worth a report at github.com/atpota-to/aturi/issues, after which the overrides can go.
  • Report offprint.app and pckt.blog. Both are catalogued under publications with redirectCompat: ['standard-site'], and both 404 on the URLs the catalog builds, in DID and handle form, for publications and documents (checked 2026-08-12). Either the shape changed or those clients do not render external standard.site records.
  • Watch for the preference lexicon. Aturi has announced a to.aturi.* record for a user's client preferences, readable by any app. Nothing resolves yet (no _lexicon.aturi.to TXT record as of 2026-08-12). If it lands, the popup's stored defaultReader becomes a local mirror of something portable, and "open in the reader I use everywhere else" stops being a per-app setting.

The package is pinned exactly (0.1.2, no caret) because it is a 0.x beta whose author says minors may break, and because it is a new dependency inside the popup. A bump is a review, not a lockfile refresh: catalogGaps() and its test exist to make that review land on a person.

Finish the popup's card split #

src/popup/cards/ holds the three cards that fetch — labels, owner, subscribers — behind the contract in card.ts, and popup.ts is the registry plus everything that has not moved yet: the account menu, the status pills, the subscribe button, the reader split, the share button and the feedback panel. Those are the parts that never conflicted, so they were left alone; moving them is worth doing when one of them next grows, not before.

Two smaller things the split leaves open:

  • Each card still reaches into popup.html by id. A card that owned its own markup (a <template>, or DOM it builds) would stop the shared file from being an edit point at all, which is the last one left.
  • world entries live in chrome.storage.session, so their day-long TTL is really "up to a day, within this browsing session". Moving them to storage.local would make it literal, at the price of a list of the publications you visited persisting on disk. One word in POLICIES (src/lib/cache.ts), and a decision nobody has made.