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 -}