From 91f5ee27cfb9eb1c254daef144d97a4cc9e8908a Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 18 Aug 2026 10:24:44 -0400 Subject: [PATCH] docs: fix claudeslopped readme --- README.md | 570 +++--------------------------------------------------- 1 file changed, 25 insertions(+), 545 deletions(-) diff --git a/README.md b/README.md index c5ecb14..4e6c8ff 100644 --- a/README.md +++ b/README.md @@ -12,405 +12,49 @@ substandard shows a badge when the site you're visiting has a standard.site publ - This extension is open source and has no backend, no analytics, and no tracking. -## What it does +## Installing -- Detects publications via the authoritative `/.well-known/site.standard.publication` - endpoint, with `` and - `` head tags as hints. Verification is - bidirectional: the well-known endpoint at the publication record's own `url` - must return the record's at-uri. -- The toolbar icon is always the substandard logo; per-tab status is shown - with Chrome's native action badge. A badge appears only when there is - something worth noticing: green means a verified publication (`+` can - subscribe, `?` subscription state unknown, `✓` subscribed), the same glyph - in amber with a trailing `*` means the same relationship on a page that is - not the publication's verified home, amber `!` means detection could not - run at all, red `X` means you have blocked the account behind the - publication, grey `•` means a check is - running. Pages with no publication, non-http pages, and offline tabs - carry no badge. Each state also sets the icon's tooltip title, so hover - and screen readers get the state in words rather than color alone. See - `docs/status-states.md` for screenshots of every state. -- The popup shows the publication's name, icon, and description, plus the - current article when the page has a document record. -- Above it sits the account the publication lives in: avatar, display name, - handle and bio, read from that account's own DID document and its - `app.bsky.actor.profile` record on its own PDS, and linked to its profile. - The handle is only shown once it has been proven — a DID document's - `alsoKnownAs` is a claim by whoever controls the DID, so it counts only if - resolving the handle returns that same DID, the same bidirectional check - detection makes for publications. An unproven handle is replaced by the - DID rather than shown. This card replaced the older "by @handle" byline, - which showed the claim. See `src/lib/profile.ts`. -- That card says "Following" when the account behind the publication is one - you follow, from the same follow list the subscriber row uses: your own - `app.bsky.graph.follow` records, read from your own repo, so it needs no - extra OAuth scope and no appview sees the question. It is a status and not - a control — this extension can write only its own subscription records, so - following and unfollowing stay on the profile the card links to. An account - you have blocked never gets the line: blocking does not delete the follow - record it supersedes, and a card that is already refusing to show someone - must not announce that you follow them. No line is never "you do not follow - them": it is also what a signed-out popup, a failed read, and a follow list - past its 10,000 cap all look like. -- The popup shows how many accounts subscribe to the publication, and the - faces of the ones you follow. Subscriptions are public records in other - people's repos, so the count comes from Constellation, microcosm's free - backlink index; the follow set is read from your own repo and each face - from its owner's PDS, so no appview sees which publication you are on. - Constellation is a third party: if it cannot answer, the row is hidden - rather than guessed at. See `src/lib/subscribers.ts`. -- Moderation labels from labelers the user listens to appear on the - publication card, and a label that Bluesky's own interpretation puts - behind a click puts the card behind one too, with a "Show anyway" button. - A labeler is any account with an `#atproto_labeler` service, so the - extension holds no opinion of its own: it asks the labelers *you* - subscribe to about the publication's account and records, and shows what - comes back. The list is your own Bluesky labeler subscriptions, read from - `app.bsky.actor.preferences` — subscribe to a labeler in any client and - its labels appear here, with no substandard-specific list to maintain. - Signed out, or with a session that predates the scope, it falls back to - the `labelers` key in `chrome.storage.local` and then to Bluesky's own - moderation service. The - severity, blur and wording all come from Bluesky — `LABELS` and - `interpretLabelValueDefinition` in `@atproto/api`, plus the global label - strings — so a label you recognize from Bluesky reads the same here. See - `src/lib/labels.ts`. -- The popup also carries status pills (`src/lib/status.ts`): every - applicable message shows at once, most severe first — errors, then alerts, - then warnings, then informational lines — so being offline, viewing an - unverified publisher, and an expired sign-in can all be reported - together instead of first-match-wins. Errors and alerts are both red and - keep different glyphs: an error is the extension failing at its job, an - alert is a serious answer it arrived at, like a block you made. -- Subscribe/unsubscribe writes `site.standard.graph.subscription` records to - your own PDS. Nothing is stored server-side by this extension. -- A publication owned by an account you have blocked is called out before you - subscribe: red `X` badge, a red alert pill naming the account, and a - Subscribe button that takes a second, reworded click. The check reads your own - `app.bsky.graph.block` records, which are public records in your repo, so it - needs no extra OAuth scope and nothing about your blocks leaves the browser. - Mutes are not readable this way and are not covered. -- The account card for a blocked account does not show them: no avatar, no - display name, no bio — just "Blocked user" and the handle, which is which - account rather than how it presents itself. A block is a decision not to be - shown someone, and this page is one the reader landed on rather than asked - for. The publication itself is unaffected: its name, icon and description are - the publication's, and the block is on the account. -- "Open in " split button with a dropdown to pick another reader or - set a default. Readers: Standard Reader (standard-reader.app, the default - until you pick one), Leaflet, Anisota Reader, PDSls. -- The reader list comes from Aturi's Waypoints catalog - (`@aturi.to/waypoints`), a maintained inventory of Atmosphere clients, so - a new reader arrives on a dependency bump rather than on somebody here - noticing it. Two things the catalog does not supply stay ours: the icons - (it ships none) and the proof. Its builders are one URL shape per client - and a publication is not a document, so `src/lib/readers.ts` records what - was actually opened against each live service, overrides the shapes that - are wrong, and refuses to offer a catalogued reader nobody has checked — - a test fails on one, rather than shipping a link into the popup. -- "Share on Bluesky" next to the Open button hands the article's title and - URL to Bluesky's compose intent (`https://bsky.app/intent/compose`) in a - new tab. Nothing is posted from the extension: no post scope, no session - needed, and the reader edits and sends it themselves. Bluesky builds the - link card from the page's own Open Graph tags, which is why the text is a - title and a URL rather than a record embed — see `src/lib/share.ts`. -- How long a read keeps depends on whose data it is (`src/lib/cache.ts`). - Your own repo, where this extension also writes — subscriptions, blocks, - labeler subscriptions — keeps for an hour and is dropped the moment it - writes, so subscribing never leaves the popup insisting you have not. - Anything about somebody else's publication, profile or labels keeps for a - day, since nothing you do changes it. Both live in session storage, which - the browser clears when it closes: nothing about which publications you - visited is written to disk. -- Your follow list is the exception, and keeps for a day on disk. Nothing - here creates or deletes a follow, so a stale set cannot hide something you - did; and reading it is one request per hundred follows against your own - PDS, up to a cap of 10,000 — a hundred requests. That is not a walk to - repeat every time the browser restarts. It is the one value allowed on - disk because of what it is not: a - follow list is your own public social graph, and unlike the rest it records - nothing about where you have been. The walk runs in the background worker, - starting when you sign in, so no popup ever waits on it — the subscriber - count draws first and the faces appear when it lands. -- The refresh icon next to the Open button re-fetches everything, since - records can change remotely. Refresh bypasses every cache in the path, - including the well-known probes — a publisher correcting their site sees - it immediately rather than at the next TTL expiry. -- Well-known probe results are cached by outcome: five minutes for a hit - (this origin *is* a publication, so freshness is what matters) and an hour - for a miss (the answer nearly every site gives, where re-asking costs a - request to a stranger and changes almost nothing). -- All outbound requests go through `src/lib/http.ts`: a per-attempt timeout, - `credentials: 'omit'`, a body-size cap, and retries narrow enough that a - 404 — the usual well-known answer — is never retried. Only timeouts, - transport errors and 408/425/429/5xx get a second attempt, with - full-jitter exponential backoff and `Retry-After` honoured up to a minute. -- "Send feedback" in the popup footer opens a report form: a title, optional - details, and the categories the feedback board itself lists. Sending writes - an `app.userinput.discussion` record to your own PDS, pointing at - substandard's space on [userinput.app](https://userinput.app/s/did:plc:jlle5fhgsrzlqybnpfysavg4/3mst6ruen3l2n) - — public, yours, and deletable from there. Still no backend: nothing of - ours receives the text. -- Next to it, "Updates" is a plain link to - [substandard.blog/posts](https://substandard.blog/posts), opened in a new - tab. No JavaScript behind it and no state in the popup. -- The first install opens a welcome page (`welcome.html`, dressed as - substandard.blog), and its whole job is the toolbar pin. Chrome keeps a new - extension's icon behind the puzzle piece, and the badge — the passive half - of what this extension does — is invisible until the icon is out. No - manifest field and no API can ask for a pin; the only thing Chrome offers is - `chrome.action.getUserSettings()` to read whether it happened (no extra - permission). So the page asks in words, draws where the pin is, and polls - that read, which is what makes the line under the picture answer by itself - the moment somebody pins the icon. It opens on `install` only — an update - that reopened it would be a tab spawner, not a nudge — so the dev build's - debug menu is the only way to see it again (see Install). -- That page's picture is a drawing rather than a capture: Chrome's toolbar and - its extensions menu are native UI and do not render into a page screenshot, - the same reason `scripts/capture-status-docs.mjs` draws its badge tiles. Its - words are a human's. `scripts/deploy-ext.sh` still refuses to package a - build carrying the `SNICKERSNEE` placeholder token, so a later rewrite - cannot ship one. +### Chrome Web Store -## Install +You can install substandard from the [Chrome Web Store](https://chromewebstore.google.com/detail/substandard/mecbfognmmefgjekidnddjjlddnfnlki?authuser=0&hl=en). This is the best option for most users. + +### Local + +You can also build the extension locally: ```sh npm install npm run package # build + verify dist/ is loadable ``` -Then in Chrome: `chrome://extensions` → enable Developer mode → Load unpacked → -select the `dist/` directory. - -The manifest declares `minimum_chrome_version: "110"`, so the store does not -offer the extension to browsers where it would half-work. The floor is -`chrome.action.setBadgeTextColor` (Chrome 110), which `setBadge` in -`src/background.ts` calls for every visible badge state inside a `catch`-all, -so on an older Chrome the badge fails silently. Next below it is -`chrome.offscreen` (Chrome 109), without which sign-in cannot run at all. -Everything else the extension calls is older. Raise the floor when a newer API -is adopted. - -One API resisted being pinned to a version, and sign-in no longer depends on -the answer. `chrome.offscreen.hasDocument()` is annotated *Chrome 150+* in the -published reference, far above this floor, but that is when it was documented -rather than when it became callable: it shipped with the offscreen API in 109 -and stayed `[nodoc]` in the Chromium IDL because a per-extension existence -check does not survive multiple offscreen documents -([crbug.com/1339382](https://crbug.com/1339382)), which has also put it up for -removal. Verified callable and correct on Chrome 149. `src/lib/offscreen.ts` -therefore prefers the documented `chrome.runtime.getContexts()` (Chrome 116), -falls back to `hasDocument()` for the 110-115 window, and finally lets -`createDocument`'s own "only a single offscreen document" failure answer the -question — so no Chrome version can break sign-in over it. - -The manifest pins the *unpacked* extension ID to -`degljbilkggdpbobomfbgnellecgbkjj` via the `key` field, so the dev OAuth -redirect URI stays stable across machines. The matching private key -(`key.pem`) is not needed for unpacked loading and stays out of git. The -Chrome Web Store rejects a manifest with a `key`, so the packaged release -drops it and the published extension has a store-assigned ID; both IDs' -`chromiumapp.org` redirect URIs must be listed in -`oauth/client-metadata.json`. - -Both IDs are declared in `oauth/extension-ids.json`, and -`scripts/check-oauth-metadata.mjs` enforces the invariant that broke sign-in -in v1.2.1: the extension *bundles* the client metadata and validates its own -runtime redirect URI against it, so metadata that lists only the unpacked ID -makes every store install throw `Invalid redirect_uri` before the -authorization request is pushed — while the hosted metadata, the OAuth -server, and every other check still look correct. The script derives the -unpacked ID from the manifest `key` rather than trusting the declaration, so -rotating the key without updating the metadata fails too. It runs in three -places: as a `prek` hook on any change to the metadata, the manifest or -`authflow.ts`; against the hosted copy at `client_id` in `deploy-ext.sh`'s -preflight; and against the built `dist/` before packaging, which is the only -check that looks at the artifact actually being uploaded. - -Because the two IDs differ, an unpacked build and the store build can be -installed in the same browser at once. To tell them apart, build the dev -channel: +Or if you want developer mode turned on (inverted color icon): ```sh -npm run package:dev # build:dev + verify dist/ is loadable +npm install +npm run package:dev ``` -`npm run build:dev` is the build on its own, and `npm run dev` is the dev -channel in watch mode. - -It inverts the icon PNGs in `dist/` and appends ` (dev)` to the extension -name, so the toolbar tile, the popup header, and the `chrome://extensions` -row all say which install you are looking at. It also stamps the commit into -`version_name` — `1.4.0+2e9cb0e`, or `1.4.0+2e9cb0e-dirty` when the tree has -uncommitted changes — which is what the popup footer and -`chrome://extensions` then show, so a reloaded install says which build it is -running. `version` itself stays a plain dotted number, the only thing Chrome -accepts there. - -Nothing else changes — same ID, same key, same OAuth redirect URI. `npm run -build` and the release packaging are unaffected: they emit no `version_name`, -so the store popup shows the bare version, and the inverted tiles live in -`brand/icons-dev/` and never reach `public/`. See `scripts/dev-channel.mjs`. - -That name suffix is also what the running extension reads to know which build -it is (`isDevChannel` in `src/lib/channel.ts`, asserted against the build -script's own constant), and a dev build shows one thing a release build does -not: a **Debug** menu at the right of the popup footer. It holds the dev-only -tools, which today means opening the welcome page — Chrome shows that page -once per install and there is otherwise no way back to it short of -reinstalling the extension. - -## Sign in - -atproto OAuth requires the client to be identified by a publicly hosted -metadata file. Ours lives at -`https://substandard.blog/client-metadata.json` (see `oauth/` for the -file and `infra/` for the hosting). The extension asks for -`repo:site.standard.graph.subscription` — its own subscription records — plus -`include:app.userinput.authBasic`, userinput.app's published permission set -covering the feedback posts described above (discussions, replies, votes, and -edits of your own posts; not the moderation collections in its `authFull`). -Nothing else, and never `transition:generic`. - -Changing that list changes what the consent screen asks for, so the site has to -be deployed before a build that asks for it — the guard above compares the -hosted file to the bundled one field by field, scope included, and -`deploy-ext.sh` will not package until they agree. Everyone who signed in under -the old list keeps a session that cannot post feedback until they sign in -again. Whether a session covers the write cannot be checked here: the -authorization server expands `include:` into the set's own permissions -before minting the token, so the granted scope never contains the string that -was requested. The write is attempted, and the PDS is what says no -(`src/lib/feedback.ts`). - -The popup header also shows the running version next to the wordmark, read -from the manifest at runtime (`chrome.runtime.getManifest()`) rather than -baked into the bundle, so it is what Chrome installed and not what some build -step believed. The capture script asserts it matches `dist/manifest.json`, -since the header is in every screenshot in `docs/img/`. - -Account controls live in the popup header: a Sign in button when signed out, -and your avatar and handle when signed in, opening a menu with Switch account -and Sign out. The Sign in button expands into the handle form and then -submits it — it is the form's only button. Switch account opens that same -form without signing you out: the current login stays valid until the -replacement sign-in completes, and only then is it revoked. Abandoning the -switch leaves you signed in as before. A session that ends on its own -(expired or remotely revoked refresh token) is flagged once in that panel, -so being signed out always has an explanation. - -Sign-in starts from that form: enter your handle and a small consent window -for your PDS opens — no other UI. The popup cannot host the flow itself (it -closes when the consent window takes focus) and neither can the worker (the -OAuth client needs DOM storage), so the worker runs the flow through an -invisible offscreen document: it builds the authorization URL there, opens -the consent window, watches its tab for the redirect back to the -`chromiumapp.org` URL (the host never resolves; only the navigation attempt -matters), then hands the redirect back to the offscreen document for the -token exchange and closes both. - -Both halves name the redirect URI of the ID the build is actually running -under (`chrome.runtime.id`), because the OAuth server checks the exchange -against what the authorization request registered. The client's own default -for each half is `redirect_uris[0]` in the metadata — the store ID — so -leaving either to the default breaks sign-in for every install that is not -the store one, and only at the exchange (`src/lib/oauth.ts`, -`src/lib/oauth.test.ts`). - -Sessions persist in IndexedDB with a non-extractable DPoP key — the same model -`@atproto/oauth-client-browser` uses for web apps — so you stay signed in -across browser restarts without re-consenting. - -## Hosting the site and client metadata - -`infra/` contains OpenTofu for a fresh AWS account: a Route53 zone for -`substandard.blog`, an ACM certificate, and a private S3 bucket fronted by -CloudFront. The bucket holds immutable release trees under `releases//` -— the Astro-built frontpage from `web/dist/` plus the metadata JSON served at -`/client-metadata.json` — and CloudFront's `origin_path` points at exactly -one of them, so a deploy is an atomic pointer flip and a rollback is pointing -back at an earlier tree. The frontpage copy is a human's now; the -`PLACEHOLDER-*` strings it once carried are gone. The site also serves -`/privacy`, the privacy policy the Chrome Web Store listing points at; -`web/scripts/verify-dist.mjs` fails a build that drops the page. - -Because the bucket is private behind an origin access control, S3 answers an -address that is not in the release tree with `403 AccessDenied` rather than -404, so an unmapped miss reaches the reader as raw XML. `custom_error_response` -blocks in `infra/main.tf` map both 403 and 404 to the built `404.html` — -`web/src/pages/404.astro`, which Astro emits at the top level even though every -other page builds as a directory — and answer 404. Only a key that is not there -takes that path, so real objects, `/client-metadata.json` included, are -untouched. `verify-dist.mjs` reads `response_page_path` back out of the -terraform and fails a build that does not ship it. That page's copy is still -`PLACEHOLDER-*`, waiting on a human — it is the one page on the site that is. - -The frontpage (and only the frontpage — not the extension) reports analytics -to an EU-hosted PostHog project via `web/src/components/PostHog.astro`. -Tracking is cookieless (`persistence: "memory"`), and the snippet is only -included in production builds, so `astro dev` traffic never reaches PostHog -(`astro preview` serves the production build, so it does). - -Deploy with `scripts/deploy-site.sh` (extra arguments are passed through to -`tofu apply`). It checks AWS credentials, installs `web/`'s dependencies with -`npm ci` from `web/package-lock.json` (same rule as `deploy-ext.sh`), re-syncs -the homepage screenshots from `docs/img/` (`npm run shots`, so a deploy can -never ship a screenshot of a popup the extension no longer has), builds -the frontpage, uploads the build plus `oauth/client-metadata.json` to -`s3://substandard.blog/releases//` — the short sha of `HEAD`, with a -`-dirty` suffix if the tree has uncommitted changes — and patches the sha -into `infra/terraform.tfvars.json` as `release_sha` -(`scripts/patch-tfvars.mjs`). The `tofu apply` then only changes which tree -CloudFront serves; afterwards the script waits for the distribution to -finish deploying and invalidates the cache, so the flip is what makes a -release live. Hashed assets under `_astro/` are uploaded with immutable -long-cache headers. PDSes re-fetch the metadata when clients authenticate. - -CloudFront attaches a response headers policy to every response: HSTS (two -years, subdomains included, not preloaded), `X-Content-Type-Options: nosniff`, -`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, a -`Permissions-Policy` turning off features the site does not use, and a -Content-Security-Policy. The CSP allows the inline PostHog snippet by SHA-256 -hash rather than `unsafe-inline`, so `web/scripts/verify-dist.mjs` recomputes -that hash at build time and fails the build when it no longer matches -`posthog_script_hash` in `infra/main.tf` — otherwise a changed snippet would -be silently blocked in production and analytics would just stop. The policy -was validated by serving `web/dist` with the exact header and loading `/`, -`/privacy` and `/posts` in headless Chrome. - -On a fresh account the first run stops before uploading because the bucket -does not exist yet: run `tofu -chdir=infra init` and `tofu -chdir=infra -apply` to create the infra (`terraform.tfvars.json` is already written), -then re-run the script. The first apply blocks on certificate validation -until you copy the `zone_name_servers` output into the NS records at the -registrar; once delegation propagates, the apply finishes on its own (or -re-run it). - -To roll back, set `release_sha` in `infra/terraform.tfvars.json` to a -previously uploaded sha and run `tofu -chdir=infra apply`, then invalidate -the CloudFront cache. Release trees are never deleted by a deploy; prune old -`releases/` prefixes manually when they are no longer rollback candidates. +Then in Chrome: `chrome://extensions` → enable Developer mode → Load unpacked → +select the `dist/` directory. ## Development +Other local dev commands include: + ```sh npm run check # typecheck -npm run build # popup/offscreen/background build + content-script IIFE build -npm run build:dev # same build, dev channel (inverted icons, " (dev)" name, commit stamp) -npm run verify:dist # check dist/manifest.json and that every referenced file exists +npm run build # build the extension +npm run build:dev # same build, dev channel +npm run verify:dist # verify against manifest npm run package # build + verify:dist in one step npm run package:dev # build:dev + verify:dist in one step -npm run dev # both builds in watch mode, dev channel; keeps dist/ loadable -npm test # vitest unit tests (src/lib/*.test.ts, scripts/*.test.mjs; no network) +npm run dev # dev channel in watch mode +npm test # run unit tests ``` -`npm run dev` watches both build environments (`scripts/dev.mjs`). After an -edit, reload the extension from `chrome://extensions`; the popup and content -script pick up the new build on next open / page load. It builds the dev -channel (see Install); `SUBSTANDARD_CHANNEL=release npm run dev` opts out. +## Extension releases -Cutting a release, in order. Each step is described in full below. +Instructions for the maintainer. 1. `scripts/deploy-site.sh` — step 4 validates against the *hosted* client metadata, so the site has to be current before it runs. @@ -418,177 +62,13 @@ Cutting a release, in order. Each step is described in full below. as `chore(release): v`. 3. `git tag -a v` and `git push --follow-tags` — `deploy-ext.sh` requires the tag at HEAD and creates none itself. -4. `scripts/deploy-ext.sh` — preflight, clean build, and the zip in `release/`. -5. `npm run smoke`, then `npm run rc` — detection over the real network, then - sign-in under the real store id. +4. `scripts/deploy-ext.sh` to build the deployable package under `release` +5. `npm run smoke`, then `npm run rc` to verify the package 6. Upload the zip by hand at the developer console URL step 4 prints. -`scripts/deploy-ext.sh` packages a Chrome Web Store release: it refuses a dirty -tree, installs dependencies with `npm ci` (the artifact is built from -`package-lock.json`, not from whatever is in `node_modules`; the dirty-tree -check runs first because `npm ci` wipes and reinstalls), runs the typecheck and -tests, checks the OAuth client metadata against -the hosted copy and against every declared extension ID, asserts `package.json` and -`public/manifest.json` agree on the version, requires the release tag -`v` to already exist and point at HEAD (the script makes no version -decisions and creates no tags), does a clean build plus `verify:dist`, and -zips the contents of `dist/` (manifest at the zip root, its `key` field -stripped — the store rejects uploads that carry one; the release channel is -forced and the archive is checked for a `(dev)` name) into -`release/substandard-v.zip`. Tagging, pushing the tag, and uploading -the zip are manual steps; the script ends by printing the developer console -URL as a clickable terminal link (a bare URL when its output is piped). - -The release ships sourcemaps, and `deploy-ext.sh` fails if any of the five -entry points' maps are missing. The store reviews minified code more slowly -than readable code, and this extension's all-hosts grant already earns an -in-depth review, so the maps are worth their size: a reviewer reads the -TypeScript rather than the minified output. - -Only the source *text* of our own code ships. `sourcesContent` inlines every -file in a chunk, and the OAuth chunk is mostly `@atproto` — its map alone is -3.7 MB, which would bury ~2 kLOC of ours in megabytes of third-party source -that is public on npm anyway. The `substandard:own-sourcemaps` plugin in -`vite.config.ts` nulls the text of `node_modules` sources while leaving every -mapping intact, so stack traces still resolve. Net effect on the upload: -290 kB → 599 kB, against 974 kB for untrimmed maps. - -Two checks run against a built extension rather than the source, because the -bug that shipped in v1.2.1 was in the wiring between modules that all had -passing unit tests. - -`npm run smoke` (`scripts/smoke-test.mjs`) asks whether detection still works -at all: it loads `dist/` into a headless Chrome and visits real publications -over the real network, one per detection route — `standard.site` (no `` -hint, found by the origin well-known), `atproto.com/blog` (a hint, plus a -path-scoped well-known because the origin 404s), `permadeath.com` (both) — and -`example.com` as the control that catches a build which "detects" everything. -For each it asserts the publication, its verification, its DID and the icon -state a real toolbar would show. - -Because the routes are separated, a failure says which one broke. Pointing the -well-known probe at a path that does not exist fails the well-known-only site -with "no publication detected" while the hint-carrying sites degrade to -`signedout-unverified` — found, but unverified. - -It talks to the live internet, so it is not a `prek` hook and not part of -`npm test`. Run it on the tree about to be packaged; `--headful` watches it -happen. - -`npm run rc` (`scripts/rc-load.mjs`) is the other half, and asks whether -sign-in works under the identity the build will actually run as. It takes the -newest zip in `release/`, or one named on the command line. - -Loading `dist/` unpacked cannot catch a v1.2.1-class bug. Unpacked runs under -the *dev* id pinned by the manifest `key`, and that id was in the bundled -OAuth metadata all along — only the store id was missing, which is why -sign-in was broken for every store user while every local check passed. The -store id is a hash of the publisher's public key, and the store publishes -that key inside every CRX it serves; `oauth/store-key.txt` holds it, checked -against `oauth/extension-ids.json` before use. Stamping it into the zip's -manifest makes Chrome compute the store id for a locally loaded build. - -The script then asks the build's own service worker to start an -authorization with a deliberately unresolvable handle. The `redirect_uri` -check in `@atproto/oauth-client` runs before any identity lookup, so -`Invalid redirect_uri` means the artifact does not cover its own id (fail), -while `Failed to resolve identity` means it got past that check (pass). Run -against the real v1.2.1 artifact it reproduces the outage; against a current -build it passes. - -`--interactive` opens a headful Chrome with the candidate installed and -leaves it running, for a real sign-in against a real PDS. Both modes use a -throwaway profile — the store copy of the extension cannot be installed -alongside it, since they share an id. - -Lint hooks are configured in `prek.toml` ([prek](https://prek.j178.dev)): -whitespace/EOF/JSON hygiene, the typecheck above, and `tofu fmt -check` for -`infra/`. Run them all with `prek run --all-files`. Committing a change to a -brand SVG or a render script also re-renders the derived assets (icon PNGs, -Chrome Web Store tiles, the web favicon) in place; if that rewrites anything, -the commit fails — stage the regenerated files and commit again. - -A `commit-msg` hook (`scripts/lint-commit-msg.mjs`) enforces Conventional -Commit headers and changes nothing. `prek install` installs both stages. - -Version decisions happen at release time, not per commit: `npm run bump` -(`scripts/version-bump.mjs`) scans the commits since the last version change -and stages the highest bump they call for — a breaking change (`!` or a -`BREAKING CHANGE:` footer) bumps major, `feat` minor, `fix`/`perf` patch — -into `package.json`, `package-lock.json`, and `public/manifest.json`. Commit -the staged bump (e.g. `chore(release): v`) and tag it `v` -for `deploy-ext.sh`. Versioning used to run as a commit-msg hook, but bumping -on every `feat`/`fix` rewrote the same three files in every branch and -parallel worktrees kept conflicting on them. - -Layout: +## Privacy policy -- `src/background.ts` — service worker: per-tab detection state, toolbar - badge states, public reads (records, subscription list) -- `src/signin.ts` — worker half of sign-in: consent window, redirect - interception, offscreen document lifecycle -- `src/content.ts` — reads head link tags, reports to the worker -- `src/lib/detection.ts` — well-known probing and verification -- `src/lib/icon.ts` — state and badge mapping -- `src/lib/http.ts` — the one place outbound requests are made: timeouts, - narrow retries with backoff, body-size caps -- `src/lib/atproto.ts` — DID/handle resolution, public record fetches -- `src/lib/feedback.ts` — the userinput.app board (its at-uri, its links, the - discussion record and the lexicon's limits on it) -- `src/lib/session-store.ts` — the session mirror in chrome.storage.local; - the worker writes it at sign-in (the offscreen document has no - chrome.storage) -- `src/lib/oauth.ts` — OAuth client; DOM contexts only, never the worker - (the browser OAuth client needs `window`/`localStorage`) -- `src/lib/authflow.ts` — pure sign-in helpers (redirect URI, redirect - matching) -- `src/popup/` — popup UI; PDS writes happen here via the restored session -- `offscreen.html`, `src/offscreen/` — invisible OAuth host during sign-in -- `oauth/client-metadata.json` — the hosted OAuth client identity -- `web/` — Astro project for the `substandard.blog` frontpage; - `npm --prefix web run dev` to serve it, `npm --prefix web run build` - to emit `web/dist/`. The homepage shows the same popup captures the store - listing screenshot is composed from, framed by the site's own CSS (`.shot` - in `web/src/styles/global.css`) rather than by anything baked into the - images. It is also a standard.site publication: updates are - markdown files in `web/src/content/posts/`, previewed on the homepage, - listed at `/posts`, served in full at `/posts/`, and syndicated at - `/atom.xml`. After a deploy, `web/scripts/publish.sh` writes one - `site.standard.document` record per post (config in `web/sequoia.json`) - and writes each record's at:// URI back into the post's frontmatter, - which renders as a `rel="site.standard.document"` link tag. `/architecture` - is the audit page: the extension's contexts, the calls each one makes, and - what is stored where, around a diagram whose source is - `web/src/diagrams/architecture.mmd`. The site's CSP has no `unsafe-inline`, - so Mermaid cannot run in the browser; `npm --prefix web run diagram` renders - it to `architecture.svg` in a local Chrome and strips the CSS the CSP would - drop, leaving the page's own `.diagram` rules in `global.css` to supply the - colors and the light/dark theme. Editing either the `.mmd` or the render - script re-renders the SVG in place, like the brand assets below. Tests: - `npm --prefix web test`. -- `infra/` — OpenTofu for the frontpage and metadata hosting -- `brand/` — canonical logo SVGs (`mark.svg`, `sticker.svg`, plus - `sticker-round.svg` for avatar slots that crop uploads to a circle); the - icon PNGs in `public/icons/`, `web/public/icons/`, and `brand/` are - rendered from the stickers with `npm run icons` (needs a local Chrome; - set `CHROME` to pick one), the - snapped wordmark (`wordmark.svg` plus PNGs at a few widths under - `brand/wordmark/`) with `npm run wordmark`, the Chrome Web Store - listing tiles in `brand/cws/` with `npm run cws` (the promo tiles, plus a - 1280×800 listing screenshot per `store` capture in `scripts/shots.mjs`, - composed from the committed popup captures in `docs/img/`), and the - frontpage's - social card `web/public/og.png` with `npm run og` (the last two nest the - generated wordmark, so run `wordmark` first when brand sources change); - `npm run shots` then copies the popup captures the store listing and the - homepage gallery share (`scripts/shots.mjs`) into `web/public/shots/` and - writes their sizes to `web/src/shots.json`; - `npm run assets` runs all five in that order. The popup captures those two - read from are not rendered from art: they are screenshots of the built - extension, re-shot by `npm run capture` (build included, needs a local Chrome - and the network), and `npm run recapture` is that followed by `npm run assets` - — one command for new screenshots everywhere they appear. See - [docs/status-states.md](docs/status-states.md). +See [substandard.blog/privacy](https://substandard.blog/privacy). ## License -- 2.51.2