diff --git a/.gitignore b/.gitignore
index c690058..e133339 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,7 @@ node_modules/
dist/
*.pem
.env
+
+# tofu
+infra/.terraform/
+infra/*.tfstate*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ac40e50
--- /dev/null
+++ b/README.md
@@ -0,0 +1,94 @@
+# substandard
+
+A Chromium (MV3) browser extension that notices when a page belongs to a
+[standard.site](https://standard.site) publication on the AT Protocol, and lets
+you subscribe to it with your own atproto account.
+
+## What it does
+
+- 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.
+- Shows a badge when the page is a registered publication (`•`), or when you
+ are already subscribed to it (`✓`).
+- The popup shows the publication's name, icon, and description, plus the
+ current article when the page has a document record.
+- Subscribe/unsubscribe writes `site.standard.graph.subscription` records to
+ your own PDS. Nothing is stored server-side by this extension.
+- "Open in " split button with a dropdown to pick another reader or
+ set a default. Readers: Leaflet, Docs.surf, PDSls.
+- Records and subscription state are cached with short TTLs; the popup's
+ Refresh re-fetches everything, since records can change remotely.
+
+## Install
+
+```sh
+npm install
+npm run build
+```
+
+Then in Chrome: `chrome://extensions` → enable Developer mode → Load unpacked →
+select the `dist/` directory.
+
+The manifest pins the extension ID to `degljbilkggdpbobomfbgnellecgbkjj` via
+the `key` field, so the OAuth redirect URI stays stable across machines. The
+matching private key (`key.pem`) is not needed for unpacked loading and stays
+out of git.
+
+## 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 requests only the
+`repo:site.standard.graph.subscription` scope — it can manage its own
+subscription records and nothing else.
+
+The sign-in flow runs in a dedicated extension tab (the popup would close when
+the consent window opens): enter your handle, approve on your PDS, done.
+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 client metadata
+
+`infra/` contains OpenTofu for a fresh AWS account: a Route53 zone for
+`substandard.blog`, an ACM certificate, a private S3 bucket fronted by CloudFront,
+and the metadata JSON uploaded as an object.
+
+```sh
+cd infra
+tofu init
+tofu apply
+```
+
+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).
+
+If the metadata JSON changes (new redirect URI, different scopes), re-run
+`tofu apply` to upload it, and invalidate the CloudFront cache or wait out the
+TTL. PDSes re-fetch the metadata when clients authenticate.
+
+## Development
+
+```sh
+npm run check # typecheck
+npm run build # popup/auth/background build + content-script IIFE build
+```
+
+Layout:
+
+- `src/background.ts` — service worker: per-tab detection state, badges,
+ public reads (records, subscription list)
+- `src/content.ts` — reads head link tags, reports to the worker
+- `src/lib/detection.ts` — well-known probing and verification
+- `src/lib/atproto.ts` — DID/handle resolution, public record fetches
+- `src/lib/oauth.ts` — OAuth client; extension pages only, never the worker
+ (the browser OAuth client needs `window`/`localStorage`)
+- `src/popup/` — popup UI; PDS writes happen here via the restored session
+- `auth.html`, `src/auth/` — dedicated sign-in tab
+- `oauth/client-metadata.json` — the hosted OAuth client identity
+- `infra/` — OpenTofu for the metadata hosting
diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl
new file mode 100644
index 0000000..e862a37
--- /dev/null
+++ b/infra/.terraform.lock.hcl
@@ -0,0 +1,39 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.58.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:322kzfBZLH+nR2ECIl6NxJ6/WwIpHDYPekek9PryvmM=",
+ "h1:3VChA/3dIR5ktUAzikh8FzYiJak7lbVdzeaJs1nXQzU=",
+ "h1:DYbe2TO0Yt6FDW0+Msxx/UfUJrdGNwyT3m7jdZmi2GA=",
+ "h1:H6eR02ejuaIyGp2Le1h3zyZX/5Q3zsxlWI7JtBUQLRE=",
+ "h1:HgSKNaQnS/M2DRtGTuMYLMQLAbXyPSgzaK+c/LhZFAo=",
+ "h1:IlAaqShDFiaBdU8Bqg77e3EwEZR/Rz2Qta3mQOkS8AA=",
+ "h1:OzSA5aLuwD6QwEq77CS1SLCQEM1qRACom7/raSECF28=",
+ "h1:QMxEmYH3kihgwovfNdLb+i7hQU9MVKZV8dzCy8DvRQ0=",
+ "h1:VO5wJ9plwmdt3ZDUU8VkeHWi5o7RSA8PQBFTG66Qgbw=",
+ "h1:c/iKJrGk2Tc1tXJ/czqv+m9Y8qevK6hRRX/UrTOJE+w=",
+ "h1:j2p13C+/daXvHTnnvPsRg0tuY/WI4aElJxeXycfSgCQ=",
+ "h1:nn5RuBAtvYMKRhdYCcUr0yFcLinj0gwAZNFfUEWYPdY=",
+ "h1:oTIycsi0uR8YT8txfQAOdqSE44fWHdYUmp6otXv7qT0=",
+ "h1:pn0xclgq4YRf8JUmCMnTrSsAHFi/ZORix63pzl2+AM0=",
+ "h1:tadGFdx4nprR0qAscMWgEsM8vYj7HVD6sz1r8k+b0U0=",
+ "zh:22296ce07782104d7c2408f998f5bd92d5f2289303e3be1ff010ef4d4f61332a",
+ "zh:26b8af25762a460f0e41033a39f724e34d5b918f0509b725985ea85863e2ef99",
+ "zh:4d56d3e48e5f5897eb70d9ae9af88f15318c5d74d7c8f05dea6e0faf09d028c3",
+ "zh:55aedc353cb0a935fbac21bfc021c7726dd60b4b8d0f6bf6332bcbdec5c525fd",
+ "zh:66a43b0c27b7bf5ec478016c9408e29454598b23e587cde9957f8716f7800a9d",
+ "zh:6de64a5a0b81f78bda4a02c24a56a24967356e922e19a9866af37596658d1791",
+ "zh:6e68ef008d277b47bc9f0cca99d51609466853c66c7d3285b4a665f709deb356",
+ "zh:83872534561ec3c3bb1be0b9aa6a0c5a107b974aa99cad96b9c9d4e85042f8c1",
+ "zh:8e7c236211bf0bf59582815cb4244f2474482e35bd2432462e9fb405505a0e5d",
+ "zh:a1511fd933cae09b865bd6a341f4a7a3eea404944423f5b2eda14e93460c8fe8",
+ "zh:b6e33db1941468e2166a39a631224134570873dde512a7a97ca1fbcfc52ca583",
+ "zh:be96b0beba098d426f394b5031ee21bd3b48513ed45f63483888203e25a50204",
+ "zh:c6a510f121f840c2e31bcb0baa6554c03f49cc727bfbc3c6146b1c59b687909a",
+ "zh:d40feeea63d74b17db671ea0524489eec182d14769dab0f829e0964bafc4a643",
+ "zh:f7a521e7b751a2df29bbb66b786f4a4dc3e4045b36996183350abb3f836ef1fe",
+ ]
+}
diff --git a/infra/main.tf b/infra/main.tf
new file mode 100644
index 0000000..ab73768
--- /dev/null
+++ b/infra/main.tf
@@ -0,0 +1,149 @@
+# Static hosting for the OAuth client metadata at
+# https://substandard.blog/client-metadata.json
+#
+# After the first apply, set the name servers from the zone_name_servers
+# output at the registrar for substandard.blog; certificate validation and
+# the rest of the apply complete once delegation is live.
+
+locals {
+ domain = "substandard.blog"
+}
+
+resource "aws_route53_zone" "substandard" {
+ name = local.domain
+}
+
+resource "aws_acm_certificate" "substandard" {
+ domain_name = local.domain
+ validation_method = "DNS"
+
+ lifecycle {
+ create_before_destroy = true
+ }
+}
+
+resource "aws_route53_record" "cert_validation" {
+ for_each = {
+ for dvo in aws_acm_certificate.substandard.domain_validation_options : dvo.domain_name => {
+ name = dvo.resource_record_name
+ type = dvo.resource_record_type
+ record = dvo.resource_record_value
+ }
+ }
+
+ zone_id = aws_route53_zone.substandard.zone_id
+ name = each.value.name
+ type = each.value.type
+ records = [each.value.record]
+ ttl = 300
+}
+
+resource "aws_acm_certificate_validation" "substandard" {
+ certificate_arn = aws_acm_certificate.substandard.arn
+ validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
+}
+
+resource "aws_s3_bucket" "substandard" {
+ bucket = local.domain
+}
+
+resource "aws_s3_bucket_public_access_block" "substandard" {
+ bucket = aws_s3_bucket.substandard.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_object" "client_metadata" {
+ bucket = aws_s3_bucket.substandard.id
+ key = "client-metadata.json"
+ source = "${path.module}/../oauth/client-metadata.json"
+ etag = filemd5("${path.module}/../oauth/client-metadata.json")
+ content_type = "application/json"
+}
+
+resource "aws_cloudfront_origin_access_control" "substandard" {
+ name = "substandard"
+ origin_access_control_origin_type = "s3"
+ signing_behavior = "always"
+ signing_protocol = "sigv4"
+}
+
+resource "aws_cloudfront_distribution" "substandard" {
+ enabled = true
+ aliases = [local.domain]
+ price_class = "PriceClass_100"
+ comment = "substandard OAuth client metadata"
+
+ origin {
+ domain_name = aws_s3_bucket.substandard.bucket_regional_domain_name
+ origin_id = "s3"
+ origin_access_control_id = aws_cloudfront_origin_access_control.substandard.id
+ }
+
+ default_cache_behavior {
+ target_origin_id = "s3"
+ viewer_protocol_policy = "redirect-to-https"
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ # AWS managed CachingOptimized policy
+ cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
+ }
+
+ restrictions {
+ geo_restriction {
+ restriction_type = "none"
+ }
+ }
+
+ viewer_certificate {
+ acm_certificate_arn = aws_acm_certificate_validation.substandard.certificate_arn
+ ssl_support_method = "sni-only"
+ minimum_protocol_version = "TLSv1.2_2021"
+ }
+}
+
+resource "aws_s3_bucket_policy" "cloudfront_read" {
+ bucket = aws_s3_bucket.substandard.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Principal = { Service = "cloudfront.amazonaws.com" }
+ Action = "s3:GetObject"
+ Resource = "${aws_s3_bucket.substandard.arn}/*"
+ Condition = {
+ StringEquals = {
+ "AWS:SourceArn" = aws_cloudfront_distribution.substandard.arn
+ }
+ }
+ }
+ ]
+ })
+}
+
+resource "aws_route53_record" "substandard_a" {
+ zone_id = aws_route53_zone.substandard.zone_id
+ name = local.domain
+ type = "A"
+
+ alias {
+ name = aws_cloudfront_distribution.substandard.domain_name
+ zone_id = aws_cloudfront_distribution.substandard.hosted_zone_id
+ evaluate_target_health = false
+ }
+}
+
+resource "aws_route53_record" "substandard_aaaa" {
+ zone_id = aws_route53_zone.substandard.zone_id
+ name = local.domain
+ type = "AAAA"
+
+ alias {
+ name = aws_cloudfront_distribution.substandard.domain_name
+ zone_id = aws_cloudfront_distribution.substandard.hosted_zone_id
+ evaluate_target_health = false
+ }
+}
diff --git a/infra/outputs.tf b/infra/outputs.tf
new file mode 100644
index 0000000..29fa799
--- /dev/null
+++ b/infra/outputs.tf
@@ -0,0 +1,8 @@
+output "zone_name_servers" {
+ description = "Set these as the NS records for substandard.blog at the registrar"
+ value = aws_route53_zone.substandard.name_servers
+}
+
+output "client_metadata_url" {
+ value = "https://substandard.blog/client-metadata.json"
+}
diff --git a/infra/providers.tf b/infra/providers.tf
new file mode 100644
index 0000000..e75e3aa
--- /dev/null
+++ b/infra/providers.tf
@@ -0,0 +1,15 @@
+terraform {
+ required_version = ">= 1.6"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
+
+# Everything lives in us-east-1: CloudFront requires its ACM certificate there,
+# and a single-file bucket has no reason to be anywhere else.
+provider "aws" {
+ region = "us-east-1"
+}
diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts
index 273e435..fd9f6f4 100644
--- a/src/lib/atproto.ts
+++ b/src/lib/atproto.ts
@@ -13,6 +13,37 @@ export function buildAtUri(did: string, collection: string, rkey: string): strin
return `at://${did}/${collection}/${rkey}`
}
+const handleCache = new Map()
+
+export async function resolveHandleToDid(handle: string): Promise {
+ const cached = handleCache.get(handle)
+ if (cached) return cached
+ const u = new URL('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle')
+ u.searchParams.set('handle', handle)
+ const { did } = await fetchJson<{ did: string }>(u.toString())
+ handleCache.set(handle, did)
+ return did
+}
+
+/**
+ * At-uris in the wild may use a handle as authority (e.g.
+ * at://brennan.day/site.standard.publication/self). Normalize to DID form;
+ * returns null if the uri is malformed or the handle doesn't resolve.
+ */
+export async function normalizeAtUri(uri: string): Promise {
+ const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/?#]+)$/.exec(uri.trim())
+ if (!m) return null
+ let authority = m[1]!
+ if (!authority.startsWith('did:')) {
+ try {
+ authority = await resolveHandleToDid(authority)
+ } catch {
+ return null
+ }
+ }
+ return buildAtUri(authority, m[2]!, m[3]!)
+}
+
interface DidDoc {
alsoKnownAs?: string[]
service?: { id: string; type: string; serviceEndpoint: string }[]
diff --git a/src/lib/detection.ts b/src/lib/detection.ts
index cff273f..6ae50a8 100644
--- a/src/lib/detection.ts
+++ b/src/lib/detection.ts
@@ -7,7 +7,7 @@
// hints only. Documents are verified by their
// tag.
-import { blobUrl, getRecord, parseAtUri, resolveDid } from './atproto'
+import { blobUrl, getRecord, normalizeAtUri, parseAtUri, resolveDid } from './atproto'
import type { DocInfo, DocumentRecord, PubInfo, PublicationRecord } from './types'
const WK_SUFFIX = '/.well-known/site.standard.publication'
@@ -34,7 +34,8 @@ async function probeWellKnown(base: string): Promise {
const res = await fetch(wkUrl, { redirect: 'follow' })
if (res.ok) {
const text = (await res.text()).trim()
- if (parseAtUri(text)) uri = text
+ // May be handle-based (e.g. at://brennan.day/...); normalize to DID form.
+ if (text.startsWith('at://')) uri = await normalizeAtUri(text)
}
} catch {
// network errors count as a miss
@@ -106,10 +107,13 @@ export async function detectPage(
let doc: DocInfo | undefined
if (docHint) {
- doc = (await loadDocument(docHint)) ?? undefined
- if (doc?.record.site.startsWith('at://')) push(doc.record.site)
+ const normDoc = await normalizeAtUri(docHint)
+ if (normDoc) doc = (await loadDocument(normDoc)) ?? undefined
+ if (doc?.record.site.startsWith('at://')) {
+ push(await normalizeAtUri(doc.record.site))
+ }
}
- push(pubHint)
+ if (pubHint) push(await normalizeAtUri(pubHint))
// Probe the origin, and one path level deep for non-root publications.
push(await probeWellKnown(url.origin))
diff --git a/src/lib/readers.ts b/src/lib/readers.ts
index 4ddde9b..7cef599 100644
--- a/src/lib/readers.ts
+++ b/src/lib/readers.ts
@@ -1,5 +1,13 @@
// Readers that can display standard.site publications and documents.
-// URL templates are provisional until confirmed against each reader.
+//
+// Confirmed against live services (2026-08-08):
+// - Leaflet renders external standard.site publications at /lish/{did}/{pubRkey}
+// (200 with the publication's title). External documents are not reachable
+// in Leaflet, so document links fall back to the publication page.
+// - pdsls.dev renders any record at /{at-uri}.
+// - Docs.surf is a client-rendered SPA; /profile/{did} and /{at-uri} routes
+// load but could not be verified server-side. Treat as provisional.
+// standard.site itself hosts no reader.
import { parseAtUri } from './atproto'
import type { DocInfo, PubInfo } from './types'
@@ -15,9 +23,18 @@ export const READERS: Reader[] = [
{
id: 'leaflet',
name: 'Leaflet',
- pubUrl: (pub) => `https://leaflet.pub/lish/${pub.handle ?? pub.did}`,
+ pubUrl: (pub) => {
+ const rkey = parseAtUri(pub.uri)?.rkey
+ return rkey ? `https://leaflet.pub/lish/${pub.did}/${rkey}` : null
+ },
docUrl: () => null,
},
+ {
+ id: 'docs-surf',
+ name: 'Docs.surf',
+ pubUrl: (pub) => `https://docs.surf/profile/${pub.did}`,
+ docUrl: (_pub, doc) => `https://docs.surf/${doc.uri}`,
+ },
{
id: 'pdsls',
name: 'PDSls (raw record)',
@@ -33,8 +50,3 @@ export function pubUrl(readerId: string, pub: PubInfo): string | null {
export function docUrl(readerId: string, pub: PubInfo, doc: DocInfo): string | null {
return READERS.find((r) => r.id === readerId)?.docUrl(pub, doc) ?? null
}
-
-/** Rkey helper for readers whose URLs embed record keys. */
-export function rkeyOf(uri: string): string | null {
- return parseAtUri(uri)?.rkey ?? null
-}