a minimal blog cms for your pds
13
fork

Configure Feed

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

fair: a Go CLI that publishes markdown to ATProto as standard.site

A single-author static-site publisher built around two ideas:

1. The PDS is the source of truth. Posts live as
site.standard.publication + site.standard.document records on your
ATProto PDS; the rendered site mirrors them. Markdown files on disk
are working copies. External standard.site readers (Leaflet, pckt,
indexers) see the same records your own site does.

2. The output is plain HTML. No CSS, no JavaScript, no client-side
rendering. Native browser widgets carry the load — <details>,
<progress>, <meter>, <figure>, semantic <article>/<aside>/<time>,
GFM tables, footnotes, definition lists. A demo article in
blog-posts/html-feature-demo/ exercises every feature.

Architecture (see docs/design-plans/2026-04-29-blog-v2026.md):

- Profile mechanism: --profile required (sandbox vs prod), no default,
Levenshtein typo suggestion, profile-isolated state + sessions.
- ATProto OAuth (loopback + DPoP + PAR) via haileyok/atproto-oauth-golang.
Auto-refresh wired into every authenticated subcommand.
- Idempotent publish: SHA-256 over canonical inputs (markdown source,
not goldmark output) gates re-puts; --dry-run / --force escapes.
- ULID identity in frontmatter — written back on first publish, drives
rename detection.
- Build flow has a cold-start contract: works with no auth, no records,
emits well-known + a stub site so the OAuth metadata document is
reachable before first login.
- Outputs: index, per-post, archive, tag pages, Atom + RSS + JSON Feed
+ index.json, sitemap, robots.txt, .well-known/oauth-client-metadata
+ .well-known/site.standard.publication, favicon from publication.icon
blob. Atomic dist.tmp -> dist promote.
- HTML enhancements: OG/Twitter cards, canonical, rel=me, prev/next
link headers, atproto+json alternate, bsky discuss link from
bskyPostRef, image-paragraph -> figure rewrite, Bluesky blockquote
-> "Discuss on Bluesky" footer link.

Sandbox: bluesky-social/pds Docker container behind Caddy + mkcert at
pds.localtest.me (workaround for the upstream PDS rejecting loopback
hostnames in OAuth issuer URLs). Profile-isolated session storage
prevents cross-contamination with prod.

Subcommands: auth login/logout/status, init publication,
emit-client-metadata, publish (--dry-run/--force), build, ls (PDS-vs-
local diff), doctor (5-check health report), unpublish,
migrate cleanup-whtwnd (post-cutover legacy lexicon cleanup),
config show.

Operator runbook for the WhtWnd -> standard.site cutover lives at
docs/runbook-cutover.md. CLAUDE.md captures the non-obvious rules.

Replaces the Next.js + WhiteWind blog at ~/code/pds-blog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Austin Parker 3331e793

+9361
+31
.claude/launch.json
··· 1 + { 2 + "version": "0.0.1", 3 + "configurations": [ 4 + { 5 + "name": "sandbox-pds", 6 + "runtimeExecutable": "docker", 7 + "runtimeArgs": [ 8 + "compose", 9 + "-f", 10 + "sandbox/compose.yaml", 11 + "--env-file", 12 + "sandbox/.env", 13 + "up" 14 + ], 15 + "port": 443, 16 + "autoPort": false 17 + }, 18 + { 19 + "name": "dist-preview", 20 + "runtimeExecutable": "python3", 21 + "runtimeArgs": [ 22 + "-m", 23 + "http.server", 24 + "8000", 25 + "--directory", 26 + "dist.sandbox" 27 + ], 28 + "port": 8000 29 + } 30 + ] 31 + }
+26
.gitignore
··· 1 + # Build output 2 + /dist/ 3 + /dist.*/ 4 + /fair 5 + /cmd/fair/fair 6 + 7 + # Local state 8 + /.fair/ 9 + 10 + # Sandbox runtime 11 + /sandbox/.env 12 + /sandbox/sandbox.pid 13 + /sandbox/public/ 14 + /sandbox/caddy/certs/ 15 + /images-mirror.*/ 16 + 17 + # Editor / OS 18 + .DS_Store 19 + *.swp 20 + .idea/ 21 + .vscode/ 22 + 23 + # Go 24 + *.test 25 + *.out 26 + coverage.txt
+207
CLAUDE.md
··· 1 + # fair 2 + 3 + Last reviewed: 2026-04-30 4 + 5 + A Go CLI that publishes markdown posts to an ATProto PDS as `standard.site` 6 + lexicon records, then renders them as plain HTML for static deploy to 7 + `aparker.io`. Replaces the Next.js + WhiteWind stack at `~/code/pds-blog`. 8 + 9 + **Source of truth for architecture:** `docs/design-plans/2026-04-29-blog-v2026.md`. 10 + **Operator/user surface:** `README.md`. This file captures only the 11 + non-obvious rules and the things that bite when forgotten. 12 + 13 + **Status:** Phases 1–7 of the design plan are complete; the codebase is 14 + feature-complete for single-author personal use. The actual prod cutover 15 + (domain DNS flip, first push to Tangled) is the operator's to drive. 16 + 17 + ## Stack 18 + 19 + - Go 1.26+, single static binary (`go build -o fair ./cmd/fair`) 20 + - Module path: `aparker.io/fair` 21 + - CLI: `spf13/cobra` 22 + - Markdown: `github.com/yuin/goldmark` (extensions are HASH-SAFE — see 23 + "Hash determinism" — but they DO change rendered HTML for future builds) 24 + - Frontmatter: `gopkg.in/yaml.v3` 25 + - Config: `github.com/BurntSushi/toml` 26 + - ATProto OAuth: `github.com/haileyok/atproto-oauth-golang@v0.0.3` (pinned — 27 + upstream pre-1.0; mind the version when bumping) 28 + - License: MIT (see `LICENSE`) 29 + 30 + ## Domain 31 + 32 + - Site domain is **`aparker.io`** (not austinparker.me — common autocomplete trap). 33 + - Lexicons: `site.standard.publication`, `site.standard.document`, 34 + with `site.standard.content.markdown` content variant. 35 + - Records are canonical. Markdown files in `~/code/blog-posts` are working 36 + copies; `fair publish` is idempotent (content-hash skip; `--force` overrides). 37 + 38 + ## Subcommands 39 + 40 + `fair --profile <name> <verb>` — see README for full usage. One-line reminder: 41 + `auth login|logout|status`, `init publication`, `publish <path>`, 42 + `unpublish <rkey>`, `build`, `ls`, `doctor`, `migrate cleanup-whtwnd`, 43 + `config show`, `emit-client-metadata <out>`. 44 + 45 + ## Profile mechanism (mandatory, no default) 46 + 47 + - Every invocation needs `--profile <name>` or `FAIR_PROFILE`. There is no default. 48 + - Profiles are filesystem-discovered: `config.<name>.toml` in `--config-dir` 49 + (default `~/.config/fair`). Unknown profile → error with Levenshtein-≤2 50 + "did you mean" suggestion. 51 + - Profile-scoped state, dist, images-mirror, and keychain entries prevent any 52 + cross-contamination between sandbox and prod. Keep that isolation when adding 53 + new state. 54 + 55 + ## Auto-token-refresh (every authenticated subcommand) 56 + 57 + `cmd/fair/profile.go:newAuthedClient` is the single entry point for any 58 + subcommand that needs authenticated XRPC. It loads the persisted session, 59 + the persistent ECDSA client signing key, computes `client_id` (loopback for 60 + sandbox, metadata-URL for prod), and calls `Client.RefreshIfNeeded` — no-op 61 + when fresh, silent token rotation otherwise. Failures surface as 62 + "please re-login" errors that name the exact command to run. 63 + 64 + **Do not call `atproto.NewClient` directly from a subcommand** — go through 65 + `newAuthedClient` so refresh stays automatic. `cmd/fair/auth.go` is the only 66 + caller of `atproto.Login` (it *creates* the session rather than using one). 67 + 68 + ## Hash determinism (do not break casually) 69 + 70 + `internal/markdown/Hash` drives `fair publish` idempotency. Any change to 71 + its inputs invalidates every stored record's hash, forcing full republish. 72 + 73 + **Hash is over the markdown SOURCE, not goldmark's rendered HTML.** 74 + Specifically: `Hash(NormalizedBody, Canonical(Frontmatter), CoverSourceHash)`, 75 + sections separated by `\x00`. This is the load-bearing correction — 76 + adding/removing goldmark extensions or rendering options does NOT 77 + change stored hashes. It only changes future rendered HTML. 78 + 79 + - Body normalized: LF endings, BOM stripped, exactly one trailing newline. 80 + - Frontmatter canonicalized via `Canonical()`: sorted keys, no comments, 81 + `id`/`updated`/`updatedAt` excluded. Encoded as **rigid line-per-key bytes**, 82 + not YAML, so `yaml.v3` upgrades don't shift the hash. 83 + - Tags lowercased, trimmed, deduped, sorted. 84 + - Cover hashed by **source-file SHA-256** (not blob CID) so we can short-circuit 85 + before any upload. 86 + 87 + ## Markdown rendering (goldmark) 88 + 89 + `internal/markdown/Parser()` returns a singleton goldmark configured with: 90 + 91 + - GFM (tables, strikethrough, task lists, autolinks) 92 + - Footnote (`[^1]` syntax → `<sup>` + footnote list) 93 + - DefinitionList (`Term\n: Definition` → `<dl>`) 94 + - `WithAutoHeadingID` (stable anchor IDs) 95 + - `WithUnsafe` (HTML passthrough — required for hand-authored 96 + `<blockquote data-bluesky-uri>` markers the build flow rewrites) 97 + 98 + Extension changes are hash-safe (see above) but will alter rendered HTML on 99 + the next `fair build`. Consumers like Leaflet/pckt re-render from source 100 + with their own goldmark config and may produce different HTML than us — that 101 + is expected. 102 + 103 + ## HTML post-processing (kept out of goldmark deliberately) 104 + 105 + `internal/build/render.go` runs three text passes on goldmark's output, in 106 + order. They live outside the parser specifically so hash stays stable: 107 + 108 + 1. `resolveRelativeURLs` — joins `publication.url` with any `/...` href/src 109 + so rendered HTML works on any origin. 110 + 2. `transformBlueskyEmbeds` — rewrites `<blockquote data-bluesky-uri>` into 111 + `<blockquote>...<footer>Discuss on Bluesky</footer></blockquote>` (no JS). 112 + 3. `wrapStandaloneImagesAsFigures` — promotes `<p><img></p>` blocks to 113 + `<figure><img><figcaption>alt</figcaption></figure>`. Inline images 114 + are left alone. 115 + 116 + Each pass is a regex on rendered HTML, not a goldmark parser/renderer 117 + extension. Adding new transforms here is safe; modifying the goldmark parser 118 + config to do the same thing is not. 119 + 120 + ## Domain-portable records 121 + 122 + Inline image URLs in PDS record bodies are **relative** (`/images/<slug>/foo.png`). 123 + The build flow joins them with `publication.url` to produce absolute URLs in 124 + rendered HTML. This is a deliberate design choice — keep it relative or 125 + readers like Leaflet/pckt break. 126 + 127 + ## Build flow (cold-start contract) 128 + 129 + `internal/build.Build` is the orchestrator. Key invariant: it works with no 130 + auth, no records, even before any publication exists. It always emits 131 + `.well-known/oauth-client-metadata.json` first (auth needs it on virgin 132 + deploys); on missing publication it emits a minimal cold-start site 133 + (well-known + empty feed/sitemap + stub index/404), atomic-promotes, 134 + returns. Otherwise it paginates `listRecords` (unauthenticated), renders 135 + per-post pages plus index, feed.xml, rss.xml, sitemap.xml, robots.txt, 136 + JSON Feed, `index.json`, tag pages, archive, and copies `ImagesMirror` 137 + into `dist.tmp/images/` before atomic-promoting `dist.tmp` → `dist`. 138 + 139 + Templates live in `internal/build/templates/`, embedded via `embed.FS`. 140 + HTML templates use `html/template` (auto-escaping); XML/JSON templates use 141 + `text/template` so XML declarations and JSON braces survive intact. 142 + 143 + ## OAuth scopes (changing this list invalidates sessions) 144 + 145 + `internal/atproto.BlogScopes` is the single source of truth: 146 + `atproto repo:site.standard.publication repo:site.standard.document repo:com.whtwnd.blog.entry blob`. 147 + Adding/removing collections here means existing sessions are missing the 148 + new scope and need re-login. The `repo:com.whtwnd.blog.entry` grant is for 149 + the one-time `migrate cleanup-whtwnd` flow — drop it once no PDS holds 150 + legacy whitewind records. 151 + 152 + ## Cobra wiring pattern 153 + 154 + Subcommands use `newXxxCmd()` constructors registered via `root.AddCommand` 155 + in `newRootCmd()`. **Do NOT** use `init()` blocks — `cmd/fair/main_test.go` 156 + relies on building a fresh tree per test (`runCLI` calls `newRootCmd()` each 157 + invocation). New subcommands follow the same pattern. 158 + 159 + ## Sandbox: pds.localtest.me + mkcert + Caddy 160 + 161 + The `bluesky-social/pds` image hardcodes `https://` for OAuth issuer URLs and 162 + rejects loopback hostnames. Workaround: `pds.localtest.me` (public DNS that 163 + always resolves to 127.0.0.1, non-loopback per the PDS validator), mkcert 164 + (OS-trusted local CA — run `sudo mkcert -install` once for browser trust; 165 + the Go CLI trusts the CA explicitly regardless), and Caddy (terminates TLS 166 + in front of the PDS). `PDS_SERVICE_HANDLE_DOMAINS=".pds.localtest.me"` 167 + enables handle creation; the seed handle is `aparker.pds.localtest.me` 168 + because `test`/`admin`/etc. are on the PDS's reserved-handle list. 169 + 170 + Don't try to "simplify" sandbox to bare localhost — it's been tried and the 171 + PDS rejects the resulting OAuth issuer URL. 172 + 173 + ## Local preview 174 + 175 + `.claude/launch.json` defines two run configs: `sandbox-pds` (docker compose 176 + up) and `dist-preview` (python `http.server` on port 8000 serving 177 + `dist.sandbox/`). Use the latter to browse the rendered site locally 178 + without a webserver setup. 179 + 180 + ## Test layout 181 + 182 + - Unit tests live next to the code (`*_test.go` in the same package). 183 + - Integration tests against the sandbox PDS are gated behind `//go:build sandbox`. 184 + They require `sandbox/seed.sh` to have run first. 185 + - Cobra tests build a fresh root via `newRootCmd()` per invocation; preserve that. 186 + 187 + ## Boundaries 188 + 189 + - Safe to edit: `cmd/`, `internal/`, `sandbox/`, `docs/`. 190 + - `internal/markdown/testdata/` posts are golden inputs — changing them 191 + invalidates hash tests; do it deliberately. 192 + - `~/code/blog-posts/` is the authoring repo, separate from this one. 193 + 194 + ## Gotchas 195 + 196 + - `FAIR_PROFILE=""` (empty string) is treated as "unset" by `ResolveProfile`, 197 + so test setup that does `t.Setenv("FAIR_PROFILE", "")` to clear inherited 198 + env works as intended. 199 + - `FileStore.Save` is atomic via temp-file + rename in the same directory. 200 + Don't change the temp location without preserving same-fs invariant. 201 + - `EmitClientMetadata` derives `client_id` from the profile's `domain` field; 202 + half-configured (empty domain) returns an error rather than silently writing 203 + a bad doc. 204 + - `publish.EnsureID` writes the ULID `id` back into the source markdown 205 + frontmatter. The source IS mutated in `--dry-run` for that one field — 206 + rkeys must be stable across runs even when nothing else gets uploaded. 207 + - Windows is unsupported for sandbox; prod-side commands are fine. Use WSL2.
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Austin Parker 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+104
Makefile
··· 1 + # Convenience targets for fair. The actual CLI is `fair`; targets 2 + # below are sugar for the common flows. 3 + # 4 + # Profile is required by every fair command. Default is sandbox; override 5 + # with `make publish-post POST=path/to/index.md PROFILE=prod`. 6 + 7 + PROFILE ?= sandbox 8 + CA_BUNDLE := $(shell mkcert -CAROOT 2>/dev/null)/rootCA.pem 9 + CA_FLAG := $(if $(wildcard $(CA_BUNDLE)),--ca-bundle $(CA_BUNDLE),) 10 + 11 + .PHONY: help 12 + help: 13 + @echo "Usage: make <target> [PROFILE=<name>] [POST=<path>]" 14 + @echo 15 + @echo "Targets:" 16 + @echo " build Compile ./fair" 17 + @echo " test Run go test ./..." 18 + @echo " vet Run go vet ./..." 19 + @echo " install Build and install to \$$GOBIN" 20 + @echo 21 + @echo " sandbox-up Bootstrap the local PDS + create test account" 22 + @echo " sandbox-down Stop the sandbox containers (keeps volume)" 23 + @echo " sandbox-reset Wipe sandbox data (volume + state + sessions)" 24 + @echo 25 + @echo " ls fair ls" 26 + @echo " doctor fair doctor" 27 + @echo " publish-post fair publish POST=<path>" 28 + @echo " build-site fair build" 29 + @echo " preview Run a static server over dist.\$$(PROFILE)/" 30 + @echo 31 + @echo "Current PROFILE=$(PROFILE)" 32 + 33 + .PHONY: build 34 + build: fair 35 + 36 + fair: $(shell find cmd internal -name '*.go' 2>/dev/null) go.mod go.sum 37 + go build -o fair ./cmd/fair 38 + 39 + .PHONY: test 40 + test: 41 + go test ./... 42 + 43 + .PHONY: vet 44 + vet: 45 + go vet ./... 46 + 47 + .PHONY: install 48 + install: 49 + go install ./cmd/fair 50 + 51 + # --- Sandbox lifecycle ------------------------------------------------------- 52 + 53 + .PHONY: sandbox-up 54 + sandbox-up: 55 + bash sandbox/seed.sh 56 + 57 + .PHONY: sandbox-down 58 + sandbox-down: 59 + docker compose -f sandbox/compose.yaml --env-file sandbox/.env down 60 + 61 + .PHONY: sandbox-reset 62 + sandbox-reset: 63 + bash sandbox/reset.sh 64 + 65 + # --- Blog operations -------------------------------------------------------- 66 + 67 + .PHONY: ls 68 + ls: fair 69 + ./fair --profile $(PROFILE) ls $(CA_FLAG) 70 + 71 + .PHONY: doctor 72 + doctor: fair 73 + ./fair --profile $(PROFILE) doctor $(CA_FLAG) 74 + 75 + .PHONY: publish-post 76 + publish-post: fair 77 + @if [ -z "$(POST)" ]; then \ 78 + echo "Usage: make publish-post POST=path/to/index.md"; exit 1; \ 79 + fi 80 + ./fair --profile $(PROFILE) publish $(POST) $(CA_FLAG) 81 + 82 + .PHONY: build-site 83 + build-site: fair 84 + ./fair --profile $(PROFILE) build $(CA_FLAG) 85 + 86 + .PHONY: preview 87 + preview: build-site 88 + @echo "Serving dist.$(PROFILE)/ at http://localhost:8000/" 89 + python3 -m http.server 8000 --directory dist.$(PROFILE) 90 + 91 + # --- Tangled push (after the remote is configured) ------------------------- 92 + 93 + .PHONY: push-tangled 94 + push-tangled: 95 + git push tangled --all 96 + git push tangled --tags 97 + 98 + # --- Cleanup --------------------------------------------------------------- 99 + 100 + .PHONY: clean 101 + clean: 102 + rm -f fair 103 + rm -rf dist dist.tmp dist.sandbox dist.sandbox.tmp 104 + rm -rf images-mirror images-mirror.sandbox images-mirror.prod
+164
README.md
··· 1 + # fair 2 + 3 + A Go CLI that publishes markdown posts to an [ATProto](https://atproto.com) 4 + PDS as [`standard.site`](https://standard.site) lexicon records, then 5 + renders them as plain HTML — no CSS, no JavaScript — for static deploy. 6 + 7 + The PDS is the source of truth for what the site shows. Markdown files 8 + on disk are working copies. Re-running `fair publish` is idempotent 9 + (short-circuits when the content hash hasn't changed). External 10 + standard.site readers (Leaflet, pckt, indexers) see the same records 11 + your site does. 12 + 13 + ## Features 14 + 15 + - **Single static binary.** `go build` and you're done. 16 + - **PDS canonical.** Site renders from `site.standard.document` records, 17 + not from your local markdown. 18 + - **OAuth (loopback + DPoP).** Native ATProto OAuth flow against your 19 + PDS. No app passwords. 20 + - **Idempotent publish.** SHA-256 over canonical inputs (markdown body + 21 + sorted/deduped tags + cover-source-hash); `--dry-run` and `--force` 22 + available. 23 + - **Pure HTML output.** Semantic markup, native widgets (`<details>`, 24 + `<progress>`, `<meter>`, `<figure>`, etc.), browser default styling. 25 + - **Native widgets:** 26 + [demo article](https://aparker.io/html-feature-demo/) shows 27 + what's possible with no CSS or JS. 28 + - **Discovery & sharing:** OpenGraph, Twitter cards, canonical links, 29 + `rel=me` social verification, favicon from `publication.icon` blob. 30 + - **Alternate formats:** Atom + RSS 2.0 + JSON Feed + `index.json` + 31 + `sitemap.xml` + `robots.txt`, all generated. 32 + - **Tag pages, archive page, prev/next navigation** between posts. 33 + - **Local sandbox** for end-to-end testing without touching prod 34 + (Caddy + mkcert + `pds.localtest.me` + a real PDS Docker container). 35 + - **Operational tools:** `ls` (PDS-vs-local diff), `doctor` (health 36 + checks), `unpublish`, `migrate cleanup-whtwnd`. 37 + 38 + ## Status 39 + 40 + Alpha but feature-complete for single-author personal use. Replaces 41 + the Next.js + WhiteWind stack at `~/code/pds-blog`. See 42 + [`docs/design-plans/2026-04-29-blog-v2026.md`](docs/design-plans/2026-04-29-blog-v2026.md) 43 + for the validated architecture and 44 + [`docs/runbook-cutover.md`](docs/runbook-cutover.md) for the 45 + production cutover steps. 46 + 47 + ## Quick start 48 + 49 + ```bash 50 + # Build 51 + go build -o fair ./cmd/fair 52 + 53 + # Configure your profile 54 + mkdir -p ~/.config/fair 55 + cat > ~/.config/fair/config.prod.toml <<'EOF' 56 + pds_url = "https://bsky.social" 57 + did = "did:plc:..." # your DID 58 + domain = "https://aparker.io" 59 + publication_name = "Your Blog" 60 + description = "Tagline" 61 + blog_posts = "/path/to/blog-posts" 62 + state_file = ".fair/state.prod.json" 63 + images_mirror = "images-mirror.prod/" 64 + dist_dir = "dist/" 65 + social_links = ["https://bsky.app/profile/handle.bsky.social"] 66 + EOF 67 + 68 + # Cold-start build + deploy (publishes the OAuth client metadata 69 + # document the auth flow needs to fetch) 70 + ./fair --profile prod build 71 + wrangler pages deploy dist/ 72 + 73 + # Auth + create publication record 74 + ./fair --profile prod auth login --handle handle.bsky.social 75 + ./fair --profile prod init publication 76 + 77 + # Publish a post 78 + ./fair --profile prod publish blog-posts/my-post/index.md 79 + 80 + # Render & deploy 81 + ./fair --profile prod build 82 + wrangler pages deploy dist/ 83 + ``` 84 + 85 + ## Subcommand reference 86 + 87 + | Command | What it does | 88 + |---|---| 89 + | `auth login` | OAuth loopback flow against the active profile's PDS | 90 + | `auth status` | Show the persisted session | 91 + | `auth logout` | Clear the session | 92 + | `init publication` | Create/update the singleton `site.standard.publication/self` record | 93 + | `emit-client-metadata --out <dir>` | Write OAuth client metadata JSON | 94 + | `publish <path>` | Publish a markdown post (`--dry-run`, `--force` available) | 95 + | `build` | Render all PDS records to `dist/` (no auth required) | 96 + | `ls` | Diff PDS records against local markdown | 97 + | `doctor` | Five-check health report | 98 + | `unpublish <rkey>` | Delete a `site.standard.document` record | 99 + | `migrate cleanup-whtwnd --yes` | Delete legacy WhiteWind records (one-time post-cutover) | 100 + | `config show` | Print loaded config for the active profile | 101 + 102 + Profile is **mandatory** — pass `--profile <name>` or set 103 + `FAIR_PROFILE`. There is no default. Configs live at 104 + `~/.config/fair/config.<profile>.toml`. 105 + 106 + ## Sandbox 107 + 108 + The repo includes a fully-functional local sandbox PDS for end-to-end 109 + testing without touching production: 110 + 111 + ```bash 112 + sandbox/seed.sh # one-time bootstrap (Docker compose + mkcert + test account) 113 + ./fair --profile sandbox auth login --ca-bundle "$(mkcert -CAROOT)/rootCA.pem" \ 114 + --handle aparker.pds.localtest.me 115 + # … publish, build, eyeball at http://localhost:8000/ via dist-preview … 116 + sandbox/reset.sh # full teardown 117 + ``` 118 + 119 + See [`sandbox/README.md`](sandbox/README.md) for why we use 120 + `pds.localtest.me` + Caddy + mkcert (the upstream PDS image rejects 121 + loopback hostnames in OAuth issuer URLs, so we work around it with a 122 + public-DNS-but-resolves-to-127.0.0.1 hostname). 123 + 124 + ## Building from source 125 + 126 + Requirements: 127 + 128 + - Go 1.26+ 129 + - Docker (for the sandbox) 130 + - `mkcert` (for the sandbox; `brew install mkcert`) 131 + - macOS or Linux (Windows works for prod commands but not sandbox) 132 + 133 + ```bash 134 + git clone https://tangled.sh/aparker.io/fair 135 + cd fair 136 + go build -o fair ./cmd/fair 137 + go test ./... 138 + ``` 139 + 140 + ## Project layout 141 + 142 + ``` 143 + cmd/fair/ # CLI entrypoints (one file per subcommand) 144 + internal/atproto/ # OAuth + XRPC client + session storage 145 + internal/build/ # PDS records → dist/ HTML rendering 146 + internal/config/ # TOML config + profile resolution 147 + internal/markdown/ # Normalize + frontmatter + hash + goldmark parser 148 + internal/ops/ # ls, doctor, unpublish, migrate 149 + internal/publish/ # 13-step publish orchestrator + building blocks 150 + sandbox/ # Local PDS Docker compose + Caddy + scripts 151 + docs/design-plans/ # Validated architecture 152 + docs/runbook-cutover.md # Production migration steps 153 + ``` 154 + 155 + ## License 156 + 157 + MIT — see [`LICENSE`](LICENSE). 158 + 159 + ## Acknowledgments 160 + 161 + - [standard.site](https://standard.site) — the lexicon being implemented 162 + - [haileyok/atproto-oauth-golang](https://github.com/haileyok/atproto-oauth-golang) — the OAuth client library 163 + - [bluesky-social/indigo](https://github.com/bluesky-social/indigo) — XRPC plumbing 164 + - [yuin/goldmark](https://github.com/yuin/goldmark) — markdown rendering
+145
cmd/fair/auth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + "path/filepath" 9 + "strings" 10 + 11 + "aparker.io/fair/internal/atproto" 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + // newAuthCmd builds `fair auth ...`: login, logout, status. Each 16 + // subcommand is profile-aware via the inherited persistent flags. 17 + func newAuthCmd() *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "auth", 20 + Short: "Manage OAuth sessions for the active profile", 21 + } 22 + cmd.AddCommand(newAuthLoginCmd()) 23 + cmd.AddCommand(newAuthLogoutCmd()) 24 + cmd.AddCommand(newAuthStatusCmd()) 25 + return cmd 26 + } 27 + 28 + func newAuthLoginCmd() *cobra.Command { 29 + cmd := &cobra.Command{ 30 + Use: "login", 31 + Short: "Run the OAuth loopback flow against the profile's PDS", 32 + Args: cobra.NoArgs, 33 + RunE: func(cmd *cobra.Command, _ []string) error { 34 + profile, cfg, err := resolveProfileAndLoad(cmd) 35 + if err != nil { 36 + return err 37 + } 38 + 39 + handle, _ := cmd.Flags().GetString("handle") 40 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 41 + 42 + configDir, _ := cmd.Flags().GetString("config-dir") 43 + keyPath := filepath.Join(configDir, "client-key."+profile+".json") 44 + clientKey, err := atproto.LoadOrCreateClientKey(keyPath) 45 + if err != nil { 46 + return err 47 + } 48 + 49 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 50 + if err != nil { 51 + return err 52 + } 53 + 54 + // Loopback mode (sandbox): use the http://localhost client_id 55 + // form so the PDS auto-generates client metadata without 56 + // trying to fetch a document. Prod uses the URL of the 57 + // metadata document itself (deployed under the profile's 58 + // domain by `fair emit-client-metadata` + the build flow). 59 + var clientID string 60 + if cfg.LoopbackClient { 61 + clientID = atproto.LoopbackClientID(atproto.LoopbackRedirectPath) 62 + } else { 63 + clientID = strings.TrimRight(cfg.Domain, "/") + "/.well-known/oauth-client-metadata.json" 64 + } 65 + 66 + // SIGINT-aware context so Ctrl-C during the browser hop 67 + // closes the loopback and exits cleanly. 68 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 69 + defer stop() 70 + 71 + session, err := atproto.Login(ctx, atproto.LoginConfig{ 72 + PDSURL: cfg.PDSURL, 73 + Handle: handle, 74 + ClientID: clientID, 75 + ClientKey: clientKey, 76 + RootCAs: rootCAs, 77 + }) 78 + if err != nil { 79 + return err 80 + } 81 + 82 + store := atproto.NewFileStore(filepath.Join(configDir, "sessions")) 83 + if err := store.Save(profile, session); err != nil { 84 + return fmt.Errorf("save session: %w", err) 85 + } 86 + 87 + out := cmd.OutOrStdout() 88 + fmt.Fprintf(out, "logged in: %s\n", session.DID) 89 + fmt.Fprintf(out, " PDS: %s\n", session.PDSURL) 90 + fmt.Fprintf(out, " expires at: %s\n", session.ExpiresAt.Format("2006-01-02 15:04:05 MST")) 91 + return nil 92 + }, 93 + } 94 + cmd.Flags().String("handle", "", "optional login_hint for the OAuth authorize page") 95 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 96 + return cmd 97 + } 98 + 99 + func newAuthLogoutCmd() *cobra.Command { 100 + return &cobra.Command{ 101 + Use: "logout", 102 + Short: "Forget the OAuth session for the active profile", 103 + Args: cobra.NoArgs, 104 + RunE: func(cmd *cobra.Command, _ []string) error { 105 + profile, _, err := resolveProfileAndLoad(cmd) 106 + if err != nil { 107 + return err 108 + } 109 + configDir, _ := cmd.Flags().GetString("config-dir") 110 + store := atproto.NewFileStore(filepath.Join(configDir, "sessions")) 111 + if err := store.Delete(profile); err != nil { 112 + return err 113 + } 114 + fmt.Fprintf(cmd.OutOrStdout(), "logged out of profile %q\n", profile) 115 + return nil 116 + }, 117 + } 118 + } 119 + 120 + func newAuthStatusCmd() *cobra.Command { 121 + return &cobra.Command{ 122 + Use: "status", 123 + Short: "Show the current OAuth session for the active profile", 124 + Args: cobra.NoArgs, 125 + RunE: func(cmd *cobra.Command, _ []string) error { 126 + profile, _, err := resolveProfileAndLoad(cmd) 127 + if err != nil { 128 + return err 129 + } 130 + configDir, _ := cmd.Flags().GetString("config-dir") 131 + store := atproto.NewFileStore(filepath.Join(configDir, "sessions")) 132 + session, err := store.Load(profile) 133 + if err != nil { 134 + return err 135 + } 136 + out := cmd.OutOrStdout() 137 + fmt.Fprintf(out, "profile: %s\n", profile) 138 + fmt.Fprintf(out, "did: %s\n", session.DID) 139 + fmt.Fprintf(out, "pds: %s\n", session.PDSURL) 140 + fmt.Fprintf(out, "issuer: %s\n", session.AuthServerIssuer) 141 + fmt.Fprintf(out, "expires: %s\n", session.ExpiresAt.Format("2006-01-02 15:04:05 MST")) 142 + return nil 143 + }, 144 + } 145 + }
+62
cmd/fair/build.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + "aparker.io/fair/internal/build" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + // newBuildCmd builds `fair build`. Read-only — no auth required. 15 + // Reads PDS records, renders to <DistDir>.tmp/, atomically promotes 16 + // to <DistDir>/. 17 + func newBuildCmd() *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "build", 20 + Short: "Render the active profile's PDS records into a static HTML site", 21 + Args: cobra.NoArgs, 22 + RunE: func(cmd *cobra.Command, _ []string) error { 23 + _, cfg, err := resolveProfileAndLoad(cmd) 24 + if err != nil { 25 + return err 26 + } 27 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 28 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 29 + if err != nil { 30 + return err 31 + } 32 + 33 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 34 + defer stop() 35 + 36 + result, err := build.Build(ctx, build.Config{ 37 + PDSURL: cfg.PDSURL, 38 + DID: cfg.DID, 39 + PublicationURL: cfg.Domain, 40 + PublicationName: cfg.PublicationName, 41 + Description: cfg.Description, 42 + SocialLinks: cfg.SocialLinks, 43 + ImagesMirror: cfg.ImagesMirror, 44 + DistDir: cfg.DistDir, 45 + CAPool: rootCAs, 46 + }) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + out := cmd.OutOrStdout() 52 + if result.ColdStart { 53 + fmt.Fprintf(out, "cold-start build: %s (no publication record yet)\n", result.OutDir) 54 + return nil 55 + } 56 + fmt.Fprintf(out, "built %d posts in %s\n", result.PostCount, result.OutDir) 57 + return nil 58 + }, 59 + } 60 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 61 + return cmd 62 + }
+49
cmd/fair/config_cmd.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + // newConfigCmd builds `fair config` and its subcommands. Currently only 10 + // `config show` is wired; future additions (e.g., `config set`) hang here. 11 + func newConfigCmd() *cobra.Command { 12 + cmd := &cobra.Command{ 13 + Use: "config", 14 + Short: "Inspect or modify CLI configuration", 15 + } 16 + cmd.AddCommand(newConfigShowCmd()) 17 + return cmd 18 + } 19 + 20 + // newConfigShowCmd builds `fair config show`, which resolves the active 21 + // profile and prints the loaded config in TOML-like form. Useful for 22 + // answering "which PDS would `fair publish` actually talk to?" 23 + func newConfigShowCmd() *cobra.Command { 24 + return &cobra.Command{ 25 + Use: "show", 26 + Short: "Print the loaded configuration for the active profile", 27 + Args: cobra.NoArgs, 28 + RunE: func(cmd *cobra.Command, _ []string) error { 29 + profile, cfg, err := resolveProfileAndLoad(cmd) 30 + if err != nil { 31 + return err 32 + } 33 + out := cmd.OutOrStdout() 34 + fmt.Fprintf(out, "# profile: %s\n", profile) 35 + fmt.Fprintf(out, "pds_url = %q\n", cfg.PDSURL) 36 + fmt.Fprintf(out, "did = %q\n", cfg.DID) 37 + fmt.Fprintf(out, "domain = %q\n", cfg.Domain) 38 + fmt.Fprintf(out, "publication_name = %q\n", cfg.PublicationName) 39 + fmt.Fprintf(out, "blog_posts = %q\n", cfg.BlogPosts) 40 + fmt.Fprintf(out, "state_file = %q\n", cfg.StateFile) 41 + fmt.Fprintf(out, "images_mirror = %q\n", cfg.ImagesMirror) 42 + fmt.Fprintf(out, "dist_dir = %q\n", cfg.DistDir) 43 + if cfg.CloudflareProject != "" { 44 + fmt.Fprintf(out, "cloudflare_project = %q\n", cfg.CloudflareProject) 45 + } 46 + return nil 47 + }, 48 + } 49 + }
+65
cmd/fair/doctor.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + "path/filepath" 9 + 10 + "aparker.io/fair/internal/atproto" 11 + "aparker.io/fair/internal/ops" 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + // newDoctorCmd builds `fair doctor`. Health-check report against the 16 + // active profile. Exits non-zero when any check fails. 17 + func newDoctorCmd() *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "doctor", 20 + Short: "Run health checks against the active profile", 21 + Args: cobra.NoArgs, 22 + RunE: func(cmd *cobra.Command, _ []string) error { 23 + profile, cfg, err := resolveProfileAndLoad(cmd) 24 + if err != nil { 25 + return err 26 + } 27 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 28 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 29 + if err != nil { 30 + return err 31 + } 32 + 33 + configDir, _ := cmd.Flags().GetString("config-dir") 34 + store := atproto.NewFileStore(filepath.Join(configDir, "sessions")) 35 + 36 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 37 + defer stop() 38 + 39 + checks := ops.Doctor(ctx, ops.DoctorOptions{ 40 + Profile: profile, 41 + Cfg: cfg, 42 + Store: store, 43 + RootCAs: rootCAs, 44 + StateFile: cfg.StateFile, 45 + }) 46 + 47 + out := cmd.OutOrStdout() 48 + anyFailed := false 49 + for _, c := range checks { 50 + glyph := "✓" 51 + if !c.OK { 52 + glyph = "✗" 53 + anyFailed = true 54 + } 55 + fmt.Fprintf(out, "%s %-30s %s\n", glyph, c.Name, c.Detail) 56 + } 57 + if anyFailed { 58 + return fmt.Errorf("one or more checks failed") 59 + } 60 + return nil 61 + }, 62 + } 63 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 64 + return cmd 65 + }
+42
cmd/fair/emit_client_metadata.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "aparker.io/fair/internal/atproto" 7 + "github.com/spf13/cobra" 8 + ) 9 + 10 + // newEmitClientMetadataCmd builds `fair emit-client-metadata --out <dir>`, 11 + // which writes the OAuth client metadata document referenced by the 12 + // auth flow's `client_id`. The document is part of the cold-start build 13 + // — it must be deployed under the active profile's domain before any 14 + // `auth login` against that domain can succeed. 15 + // 16 + // The flow is local-only: no PDS reads, no auth required, just config 17 + // + filesystem writes. Native loopback clients use auth_method=none, so 18 + // no client signing key needs to land in the metadata. 19 + func newEmitClientMetadataCmd() *cobra.Command { 20 + cmd := &cobra.Command{ 21 + Use: "emit-client-metadata", 22 + Short: "Write OAuth client metadata to <out>/.well-known/oauth-client-metadata.json", 23 + Args: cobra.NoArgs, 24 + RunE: func(cmd *cobra.Command, _ []string) error { 25 + out, _ := cmd.Flags().GetString("out") 26 + if out == "" { 27 + return fmt.Errorf("--out is required") 28 + } 29 + _, cfg, err := resolveProfileAndLoad(cmd) 30 + if err != nil { 31 + return err 32 + } 33 + if err := atproto.EmitClientMetadata(out, cfg.Domain); err != nil { 34 + return err 35 + } 36 + fmt.Fprintf(cmd.OutOrStdout(), "wrote %s/.well-known/oauth-client-metadata.json\n", out) 37 + return nil 38 + }, 39 + } 40 + cmd.Flags().String("out", "", "directory to write into (creates <out>/.well-known/...)") 41 + return cmd 42 + }
+71
cmd/fair/init.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + // newInitCmd builds `fair init` and its subcommands. Currently only 14 + // `init publication` is wired; `init local` (config scaffolder) is a 15 + // future addition once we have something to scaffold beyond the TOML. 16 + func newInitCmd() *cobra.Command { 17 + cmd := &cobra.Command{ 18 + Use: "init", 19 + Short: "First-run setup helpers", 20 + } 21 + cmd.AddCommand(newInitPublicationCmd()) 22 + return cmd 23 + } 24 + 25 + // newInitPublicationCmd builds `fair init publication`, which creates 26 + // the singleton site.standard.publication record at rkey "self". 27 + // 28 + // Idempotent: re-running updates the existing record (putRecord 29 + // replaces). Safe to use to update url/name/description after a domain 30 + // switch. 31 + func newInitPublicationCmd() *cobra.Command { 32 + cmd := &cobra.Command{ 33 + Use: "publication", 34 + Short: "Create or update the site.standard.publication record at rkey=self", 35 + Args: cobra.NoArgs, 36 + RunE: func(cmd *cobra.Command, _ []string) error { 37 + profile, cfg, err := resolveProfileAndLoad(cmd) 38 + if err != nil { 39 + return err 40 + } 41 + 42 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 43 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 44 + if err != nil { 45 + return err 46 + } 47 + 48 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 49 + defer stop() 50 + 51 + client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 52 + if err != nil { 53 + return err 54 + } 55 + 56 + record := map[string]any{ 57 + "$type": "site.standard.publication", 58 + "url": cfg.Domain, 59 + "name": cfg.PublicationName, 60 + } 61 + ref, err := client.PutRecord(ctx, "site.standard.publication", "self", record) 62 + if err != nil { 63 + return err 64 + } 65 + fmt.Fprintf(cmd.OutOrStdout(), "publication record at %s\n", ref.URI) 66 + return nil 67 + }, 68 + } 69 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 70 + return cmd 71 + }
+68
cmd/fair/ls.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + "text/tabwriter" 9 + 10 + "aparker.io/fair/internal/atproto" 11 + "aparker.io/fair/internal/ops" 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + // newLsCmd builds `fair ls`. Diffs PDS records against local 16 + // blog-posts/ and prints a tabular status report. 17 + func newLsCmd() *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "ls", 20 + Short: "Show PDS records vs local markdown drift status", 21 + Args: cobra.NoArgs, 22 + RunE: func(cmd *cobra.Command, _ []string) error { 23 + profile, cfg, err := resolveProfileAndLoad(cmd) 24 + if err != nil { 25 + return err 26 + } 27 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 28 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 29 + if err != nil { 30 + return err 31 + } 32 + 33 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 34 + defer stop() 35 + 36 + client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 37 + if err != nil { 38 + return err 39 + } 40 + 41 + rows, err := ops.Ls(ctx, client, cfg.BlogPosts, cfg.StateFile) 42 + if err != nil { 43 + return err 44 + } 45 + 46 + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) 47 + fmt.Fprintln(tw, "STATUS\tRKEY\tID\tTITLE\tNOTE") 48 + for _, r := range rows { 49 + note := r.Detail 50 + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", 51 + r.Status, r.Rkey, truncate(r.ID, 12), truncate(r.Title, 50), note) 52 + } 53 + return tw.Flush() 54 + }, 55 + } 56 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 57 + return cmd 58 + } 59 + 60 + func truncate(s string, max int) string { 61 + if len(s) <= max { 62 + return s 63 + } 64 + if max < 4 { 65 + return s[:max] 66 + } 67 + return s[:max-3] + "..." 68 + }
+44
cmd/fair/main.go
··· 1 + // Command fair is the standard.site publishing CLI. 2 + // 3 + // Subcommands are wired in their own files; this file owns only the root. 4 + package main 5 + 6 + import ( 7 + "fmt" 8 + "os" 9 + 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + func main() { 14 + if err := newRootCmd().Execute(); err != nil { 15 + fmt.Fprintln(os.Stderr, "fair:", err) 16 + os.Exit(1) 17 + } 18 + } 19 + 20 + // newRootCmd returns the top-level cobra command with persistent flags 21 + // shared by all subcommands. Subcommands attach via newConfigCmd, etc., 22 + // rather than init() blocks so tests can build a fresh tree per run. 23 + func newRootCmd() *cobra.Command { 24 + root := &cobra.Command{ 25 + Use: "fair", 26 + Short: "Publish standard.site lexicon records and render plain HTML", 27 + SilenceUsage: true, 28 + SilenceErrors: true, 29 + } 30 + root.PersistentFlags().String("profile", "", "named profile (or set FAIR_PROFILE)") 31 + root.PersistentFlags().String("config-dir", defaultConfigDir(), "directory containing config.<profile>.toml files") 32 + 33 + root.AddCommand(newConfigCmd()) 34 + root.AddCommand(newEmitClientMetadataCmd()) 35 + root.AddCommand(newAuthCmd()) 36 + root.AddCommand(newInitCmd()) 37 + root.AddCommand(newPublishCmd()) 38 + root.AddCommand(newBuildCmd()) 39 + root.AddCommand(newLsCmd()) 40 + root.AddCommand(newUnpublishCmd()) 41 + root.AddCommand(newDoctorCmd()) 42 + root.AddCommand(newMigrateCmd()) 43 + return root 44 + }
+163
cmd/fair/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + ) 10 + 11 + // runCLI constructs the root command and executes it with the given args 12 + // and env, returning combined stdout+stderr and exit error. It does not 13 + // actually call os.Exit. 14 + func runCLI(t *testing.T, args []string, env map[string]string) (string, error) { 15 + t.Helper() 16 + for k, v := range env { 17 + t.Setenv(k, v) 18 + } 19 + root := newRootCmd() 20 + var buf bytes.Buffer 21 + root.SetOut(&buf) 22 + root.SetErr(&buf) 23 + root.SetArgs(args) 24 + err := root.Execute() 25 + return buf.String(), err 26 + } 27 + 28 + // writeProfileConfig creates ~/.config/fair/config.<profile>.toml fixtures 29 + // and returns the directory containing them. 30 + func writeProfileConfig(t *testing.T, profiles map[string]string) string { 31 + t.Helper() 32 + dir := t.TempDir() 33 + for name, body := range profiles { 34 + path := filepath.Join(dir, "config."+name+".toml") 35 + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { 36 + t.Fatal(err) 37 + } 38 + } 39 + return dir 40 + } 41 + 42 + const minimalProd = `pds_url = "https://prod.example/pds" 43 + did = "did:plc:prod" 44 + domain = "https://aparker.io" 45 + publication_name = "Prod" 46 + blog_posts = "/tmp/posts" 47 + state_file = ".fair/state.prod.json" 48 + images_mirror = "images-mirror.prod/" 49 + dist_dir = "dist/" 50 + ` 51 + 52 + const minimalSandbox = `pds_url = "http://localhost:2583" 53 + did = "did:plc:sandbox" 54 + domain = "http://localhost:8000" 55 + publication_name = "Sandbox" 56 + blog_posts = "/tmp/posts" 57 + state_file = ".fair/state.sandbox.json" 58 + images_mirror = "images-mirror.sandbox/" 59 + dist_dir = "dist.sandbox/" 60 + ` 61 + 62 + func TestConfigShow_PicksProfileFromFlag(t *testing.T) { 63 + dir := writeProfileConfig(t, map[string]string{ 64 + "prod": minimalProd, 65 + "sandbox": minimalSandbox, 66 + }) 67 + 68 + out, err := runCLI(t, []string{"--profile", "prod", "--config-dir", dir, "config", "show"}, nil) 69 + if err != nil { 70 + t.Fatalf("unexpected error: %v\noutput: %s", err, out) 71 + } 72 + if !strings.Contains(out, "https://prod.example/pds") { 73 + t.Errorf("expected prod PDS URL in output, got:\n%s", out) 74 + } 75 + } 76 + 77 + func TestConfigShow_PicksProfileFromEnv(t *testing.T) { 78 + dir := writeProfileConfig(t, map[string]string{ 79 + "prod": minimalProd, 80 + "sandbox": minimalSandbox, 81 + }) 82 + 83 + out, err := runCLI(t, 84 + []string{"--config-dir", dir, "config", "show"}, 85 + map[string]string{"FAIR_PROFILE": "sandbox"}, 86 + ) 87 + if err != nil { 88 + t.Fatalf("unexpected error: %v\noutput: %s", err, out) 89 + } 90 + if !strings.Contains(out, "http://localhost:2583") { 91 + t.Errorf("expected sandbox PDS URL in output, got:\n%s", out) 92 + } 93 + } 94 + 95 + func TestConfigShow_NoProfile_Errors(t *testing.T) { 96 + dir := writeProfileConfig(t, map[string]string{ 97 + "prod": minimalProd, 98 + }) 99 + // explicit clear of env so a developer's shell doesn't pollute the test 100 + t.Setenv("FAIR_PROFILE", "") 101 + 102 + out, err := runCLI(t, []string{"--config-dir", dir, "config", "show"}, nil) 103 + if err == nil { 104 + t.Fatalf("expected error, got success with output:\n%s", out) 105 + } 106 + if !strings.Contains(err.Error(), "profile") { 107 + t.Errorf("error %q should mention profile", err.Error()) 108 + } 109 + } 110 + 111 + func TestConfigShow_TypoSuggestsCloseProfile(t *testing.T) { 112 + dir := writeProfileConfig(t, map[string]string{ 113 + "prod": minimalProd, 114 + "sandbox": minimalSandbox, 115 + }) 116 + 117 + _, err := runCLI(t, []string{"--profile", "sandbo", "--config-dir", dir, "config", "show"}, nil) 118 + if err == nil { 119 + t.Fatal("expected typo error") 120 + } 121 + if !strings.Contains(err.Error(), "sandbox") { 122 + t.Errorf("error %q should suggest %q", err.Error(), "sandbox") 123 + } 124 + if !strings.Contains(err.Error(), "did you mean") { 125 + t.Errorf("error %q should say 'did you mean'", err.Error()) 126 + } 127 + } 128 + 129 + func TestEmitClientMetadata_WritesUnderProfileDomain(t *testing.T) { 130 + cfgDir := writeProfileConfig(t, map[string]string{"prod": minimalProd}) 131 + outDir := t.TempDir() 132 + 133 + out, err := runCLI(t, 134 + []string{"--profile", "prod", "--config-dir", cfgDir, "emit-client-metadata", "--out", outDir}, 135 + nil, 136 + ) 137 + if err != nil { 138 + t.Fatalf("unexpected error: %v\noutput: %s", err, out) 139 + } 140 + 141 + wantPath := filepath.Join(outDir, ".well-known", "oauth-client-metadata.json") 142 + data, err := os.ReadFile(wantPath) 143 + if err != nil { 144 + t.Fatalf("expected metadata at %s: %v", wantPath, err) 145 + } 146 + if !strings.Contains(string(data), `"client_id": "https://aparker.io/.well-known/oauth-client-metadata.json"`) { 147 + t.Errorf("client_id not derived from prod domain:\n%s", string(data)) 148 + } 149 + } 150 + 151 + func TestEmitClientMetadata_RequiresOutFlag(t *testing.T) { 152 + cfgDir := writeProfileConfig(t, map[string]string{"prod": minimalProd}) 153 + _, err := runCLI(t, 154 + []string{"--profile", "prod", "--config-dir", cfgDir, "emit-client-metadata"}, 155 + nil, 156 + ) 157 + if err == nil { 158 + t.Fatal("expected error: --out is required") 159 + } 160 + if !strings.Contains(err.Error(), "out") { 161 + t.Errorf("error %q should mention --out", err.Error()) 162 + } 163 + }
+68
cmd/fair/migrate.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + "aparker.io/fair/internal/ops" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + // newMigrateCmd builds `fair migrate` and its cleanup-whtwnd 15 + // subcommand. Lives separately from `init` because migrate is 16 + // inherently destructive (deletes records). 17 + func newMigrateCmd() *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "migrate", 20 + Short: "One-time migration helpers (legacy lexicon cleanup, etc.)", 21 + } 22 + cmd.AddCommand(newMigrateCleanupWhtwndCmd()) 23 + return cmd 24 + } 25 + 26 + func newMigrateCleanupWhtwndCmd() *cobra.Command { 27 + cmd := &cobra.Command{ 28 + Use: "cleanup-whtwnd", 29 + Short: "Delete all com.whtwnd.blog.entry records (post-cutover cleanup)", 30 + Args: cobra.NoArgs, 31 + RunE: func(cmd *cobra.Command, _ []string) error { 32 + confirm, _ := cmd.Flags().GetBool("yes") 33 + if !confirm { 34 + fmt.Fprintln(cmd.OutOrStderr(), "destructive: deletes every com.whtwnd.blog.entry record in your repo.") 35 + fmt.Fprintln(cmd.OutOrStderr(), "re-run with --yes to proceed.") 36 + return fmt.Errorf("confirmation required") 37 + } 38 + 39 + profile, cfg, err := resolveProfileAndLoad(cmd) 40 + if err != nil { 41 + return err 42 + } 43 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 44 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 45 + if err != nil { 46 + return err 47 + } 48 + 49 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 50 + defer stop() 51 + 52 + client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 53 + if err != nil { 54 + return err 55 + } 56 + 57 + count, err := ops.CleanupWhtwnd(ctx, client) 58 + if err != nil { 59 + return err 60 + } 61 + fmt.Fprintf(cmd.OutOrStdout(), "deleted %d com.whtwnd.blog.entry record(s)\n", count) 62 + return nil 63 + }, 64 + } 65 + cmd.Flags().Bool("yes", false, "confirm deletion") 66 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 67 + return cmd 68 + }
+94
cmd/fair/profile.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "crypto/x509" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + 11 + "aparker.io/fair/internal/atproto" 12 + "aparker.io/fair/internal/config" 13 + "github.com/spf13/cobra" 14 + ) 15 + 16 + // defaultConfigDir returns ~/.config/fair when HOME is set, falling back 17 + // to "." when it isn't (unusual; only happens in pathological test envs). 18 + // The returned value is used as the default for --config-dir; subcommands 19 + // can read it back from the cobra flag. 20 + func defaultConfigDir() string { 21 + home, err := os.UserHomeDir() 22 + if err != nil || home == "" { 23 + return "." 24 + } 25 + return filepath.Join(home, ".config", "fair") 26 + } 27 + 28 + // resolveProfileAndLoad reads --profile and --config-dir from the command, 29 + // falls back to FAIR_PROFILE for the profile, validates the chosen profile 30 + // against the discovered allowlist, and loads the matching config file. 31 + // 32 + // All commands that touch config use this helper so error wording, env 33 + // fallback, and the typo-suggestion behavior stay consistent. 34 + func resolveProfileAndLoad(cmd *cobra.Command) (string, *config.Config, error) { 35 + flagProfile, _ := cmd.Flags().GetString("profile") 36 + configDir, _ := cmd.Flags().GetString("config-dir") 37 + envProfile := os.Getenv("FAIR_PROFILE") 38 + 39 + known, err := config.DiscoverProfiles(configDir) 40 + if err != nil { 41 + return "", nil, fmt.Errorf("discover profiles in %s: %w", configDir, err) 42 + } 43 + 44 + profile, err := config.ResolveProfile(flagProfile, envProfile, known) 45 + if err != nil { 46 + return "", nil, err 47 + } 48 + 49 + cfgPath := filepath.Join(configDir, "config."+profile+".toml") 50 + cfg, err := config.Load(cfgPath) 51 + if err != nil { 52 + return "", nil, err 53 + } 54 + return profile, cfg, nil 55 + } 56 + 57 + // newAuthedClient builds an atproto.Client from the persisted session 58 + // for profile and refreshes the access token if it's expired. Used by 59 + // every subcommand that needs to make authenticated XRPC calls. 60 + // 61 + // The refresh dance: 62 + // - load client signing key (used for client_assertion JWTs in PAR/refresh) 63 + // - compute client_id (loopback form for sandbox, metadata-URL for prod) 64 + // - call Client.RefreshIfNeeded which is a no-op when the session 65 + // hasn't expired 66 + // 67 + // Failures here surface as clear "please re-login" errors so the user 68 + // knows what to do. 69 + func newAuthedClient(ctx context.Context, cmd *cobra.Command, cfg *config.Config, profile string, rootCAs *x509.CertPool) (*atproto.Client, error) { 70 + configDir, _ := cmd.Flags().GetString("config-dir") 71 + store := atproto.NewFileStore(filepath.Join(configDir, "sessions")) 72 + client, err := atproto.NewClient(profile, store, rootCAs) 73 + if err != nil { 74 + return nil, fmt.Errorf("%w (run `fair --profile %s auth login`)", err, profile) 75 + } 76 + 77 + keyPath := filepath.Join(configDir, "client-key."+profile+".json") 78 + clientKey, err := atproto.LoadOrCreateClientKey(keyPath) 79 + if err != nil { 80 + return nil, err 81 + } 82 + 83 + var clientID string 84 + if cfg.LoopbackClient { 85 + clientID = atproto.LoopbackClientID(atproto.LoopbackRedirectPath) 86 + } else { 87 + clientID = strings.TrimRight(cfg.Domain, "/") + "/.well-known/oauth-client-metadata.json" 88 + } 89 + 90 + if err := client.RefreshIfNeeded(ctx, clientID, clientKey, rootCAs); err != nil { 91 + return nil, fmt.Errorf("refresh session: %w (try `fair --profile %s auth login`)", err, profile) 92 + } 93 + return client, nil 94 + }
+130
cmd/fair/publish.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + "path/filepath" 9 + "strings" 10 + 11 + "aparker.io/fair/internal/atproto" 12 + "aparker.io/fair/internal/publish" 13 + "github.com/spf13/cobra" 14 + ) 15 + 16 + // resolveSourcePath finds a markdown file by trying the user-supplied 17 + // path as-given first, then as a path relative to blog-posts. 18 + // 19 + // Cases handled: 20 + // - Absolute path: used as-is. 21 + // - Relative path that exists in CWD: used. 22 + // - Relative path that doesn't exist in CWD but does under blogPosts: used. 23 + // - Relative path with redundant blog-posts prefix: stripped and tried 24 + // under blogPosts (e.g., user types "blog-posts/foo/index.md" from a 25 + // different cwd). 26 + func resolveSourcePath(arg, blogPosts string) (string, error) { 27 + abs, err := filepath.Abs(arg) 28 + if err != nil { 29 + return "", err 30 + } 31 + if _, err := os.Stat(abs); err == nil { 32 + return abs, nil 33 + } 34 + if blogPosts == "" { 35 + return "", fmt.Errorf("file not found: %s (and no blog_posts configured to search)", arg) 36 + } 37 + // Try arg verbatim under blogPosts. 38 + candidate := filepath.Join(blogPosts, arg) 39 + if _, err := os.Stat(candidate); err == nil { 40 + return candidate, nil 41 + } 42 + // Strip a leading "blog-posts/" if present (user copy-pasted from 43 + // a different cwd) and retry. 44 + if stripped := strings.TrimPrefix(arg, filepath.Base(blogPosts)+string(filepath.Separator)); stripped != arg { 45 + candidate = filepath.Join(blogPosts, stripped) 46 + if _, err := os.Stat(candidate); err == nil { 47 + return candidate, nil 48 + } 49 + } 50 + return "", fmt.Errorf("file not found: %q (tried %s and %s/%s)", arg, abs, blogPosts, arg) 51 + } 52 + 53 + // newPublishCmd builds `fair publish <path/to/post.md>`. Drives the 54 + // 13-step orchestrator in internal/publish. 55 + func newPublishCmd() *cobra.Command { 56 + cmd := &cobra.Command{ 57 + Use: "publish <path-to-post.md>", 58 + Short: "Publish a markdown post as a site.standard.document record", 59 + Args: cobra.ExactArgs(1), 60 + RunE: func(cmd *cobra.Command, args []string) error { 61 + profile, cfg, err := resolveProfileAndLoad(cmd) 62 + if err != nil { 63 + return err 64 + } 65 + 66 + dryRun, _ := cmd.Flags().GetBool("dry-run") 67 + force, _ := cmd.Flags().GetBool("force") 68 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 69 + 70 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 71 + if err != nil { 72 + return err 73 + } 74 + 75 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 76 + defer stop() 77 + 78 + client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + sourcePath, err := resolveSourcePath(args[0], cfg.BlogPosts) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + pubURI := fmt.Sprintf("at://%s/site.standard.publication/self", cfg.DID) 89 + 90 + result, err := publish.Publish(ctx, client, sourcePath, publish.Config{ 91 + BlogPosts: cfg.BlogPosts, 92 + StateFile: cfg.StateFile, 93 + ImagesMirror: cfg.ImagesMirror, 94 + PublicationURI: pubURI, 95 + MaxCoverSize: 1 << 20, // 1 MiB 96 + }, publish.Options{ 97 + DryRun: dryRun, 98 + Force: force, 99 + Logger: func(format string, a ...any) { 100 + fmt.Fprintf(cmd.OutOrStderr(), " "+format+"\n", a...) 101 + }, 102 + }) 103 + if err != nil { 104 + return err 105 + } 106 + 107 + out := cmd.OutOrStdout() 108 + if result.Skipped { 109 + fmt.Fprintf(out, "skipped %s (%s)\n", result.Rkey, result.Reason) 110 + return nil 111 + } 112 + if dryRun { 113 + fmt.Fprintf(out, "dry-run: %s would be put at site.standard.document/%s\n", result.Rkey, result.Rkey) 114 + return nil 115 + } 116 + fmt.Fprintf(out, "published %s\n", result.URI) 117 + if result.BlobUploaded { 118 + fmt.Fprintf(out, " cover blob uploaded\n") 119 + } 120 + if result.UpdatedAt { 121 + fmt.Fprintf(out, " updatedAt advanced\n") 122 + } 123 + return nil 124 + }, 125 + } 126 + cmd.Flags().Bool("dry-run", false, "report what would happen without uploading or putting") 127 + cmd.Flags().Bool("force", false, "re-put even when content hash is unchanged") 128 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 129 + return cmd 130 + }
+50
cmd/fair/unpublish.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/signal" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + "aparker.io/fair/internal/ops" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + // newUnpublishCmd builds `fair unpublish <rkey>`. Deletes the record 15 + // and updates state.json. Local source is untouched. 16 + func newUnpublishCmd() *cobra.Command { 17 + cmd := &cobra.Command{ 18 + Use: "unpublish <rkey>", 19 + Short: "Delete a site.standard.document record from the PDS", 20 + Args: cobra.ExactArgs(1), 21 + RunE: func(cmd *cobra.Command, args []string) error { 22 + profile, cfg, err := resolveProfileAndLoad(cmd) 23 + if err != nil { 24 + return err 25 + } 26 + caBundle, _ := cmd.Flags().GetString("ca-bundle") 27 + rootCAs, err := atproto.LoadExtraRoots(caBundle) 28 + if err != nil { 29 + return err 30 + } 31 + 32 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 33 + defer stop() 34 + 35 + client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 36 + if err != nil { 37 + return err 38 + } 39 + 40 + rkey := args[0] 41 + if err := ops.Unpublish(ctx, client, rkey, cfg.StateFile); err != nil { 42 + return err 43 + } 44 + fmt.Fprintf(cmd.OutOrStdout(), "deleted site.standard.document/%s\n", rkey) 45 + return nil 46 + }, 47 + } 48 + cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 49 + return cmd 50 + }
+399
docs/design-plans/2026-04-29-blog-v2026.md
··· 1 + # blog-v2026 Design 2 + 3 + ## Overview 4 + 5 + Replace the current Next.js + WhiteWind blog (`~/code/pds-blog`) with a Go CLI that publishes markdown posts as `standard.site` lexicon records to an ATProto PDS, then renders them as plain HTML — no CSS, no JS, browser defaults only — for static deploy to `aparker.io`. 6 + 7 + **Goals:** 8 + - PDS canonical: rendered site mirrors what's on the PDS, never the local markdown 9 + - Pure HTML output: semantic elements, no stylesheets, no scripts, no client-side rendering 10 + - ATProto-native: standard.site lexicons (`site.standard.publication`, `site.standard.document`), OAuth (loopback + DPoP + PAR), records portable to other standard.site readers (Leaflet, pckt) 11 + - Domain-portable records: relative image paths in PDS records, resolved at build via `publication.url` 12 + - End-to-end testable locally against a sandbox PDS before any prod write 13 + 14 + **Non-goals:** 15 + - Drafts (markdown sits unpublished if not ready; standard.site has no draft state) 16 + - Comments (federate via Bluesky reply if ever needed) 17 + - Analytics (no JS; server-side log analysis only) 18 + - Syntax highlighting (`<pre><code>` + browser monospace; the no-CSS constraint stands) 19 + 20 + ## Architecture 21 + 22 + ### The three pieces 23 + 24 + ``` 25 + ~/code/blog-posts/ (existing authoring repo, untouched in shape) 26 + └─ wide-events/ 27 + ├─ index.md (gray-matter frontmatter; CLI writes id back here) 28 + └─ images/foo.png 29 + 30 + │ fair publish path/to/index.md 31 + 32 + 33 + PDS (existing DID/handle, lexicon swap only) 34 + site.standard.publication/self (one record per blog) 35 + site.standard.document/<rkey> (one per post; rkey derived from path) 36 + blobs/ (cover images only) 37 + 38 + │ fair build 39 + 40 + 41 + dist/ (gitignored, atomic-swapped) 42 + ├─ index.html (post listing) 43 + ├─ wide-events/index.html (path-based routing per document) 44 + ├─ images/<slug>/foo.png (inline-image mirror) 45 + ├─ feed.xml (Atom 1.0) 46 + ├─ sitemap.xml 47 + ├─ 404.html 48 + └─ .well-known/ 49 + ├─ site.standard.publication (domain ownership) 50 + └─ oauth-client-metadata.json (OAuth client_id is this URL) 51 + 52 + │ wrangler pages deploy dist/ 53 + 54 + 55 + aparker.io 56 + ``` 57 + 58 + ### Identity rule 59 + 60 + Once a markdown file is published, the PDS record is canonical. Markdown files are working copies. Re-running `fair publish` produces the same record (stable rkey derived from frontmatter `id` ULID). Deleting a markdown file does not delete the PDS record — use `fair unpublish` or `fair ls` to spot drift. 61 + 62 + ### Stack & libraries 63 + 64 + - **Language:** Go (single static binary, `cmd/blog/main.go`) 65 + - **CLI:** `spf13/cobra` for subcommand structure 66 + - **Markdown:** `github.com/yuin/goldmark` (CommonMark + GFM extensions); pinned version, golden-file CI test for round-trip stability 67 + - **Frontmatter:** `github.com/adrg/frontmatter` (YAML) 68 + - **ATProto:** `github.com/bluesky-social/indigo/atproto` (XRPC client) + `github.com/haileyok/atproto-oauth-golang` (OAuth + DPoP + PAR + nonce rotation) 69 + - **Token storage:** `github.com/99designs/keyring` (OS keychain) with `~/.config/fair/session.<profile>.json` 0600 fallback 70 + - **ULID:** `github.com/oklog/ulid/v2` 71 + - **Grapheme-correct truncation:** `github.com/rivo/uniseg` 72 + - **TOML config:** `github.com/BurntSushi/toml` 73 + 74 + ### Auth 75 + 76 + ATProto OAuth loopback flow. CLI binds an ephemeral port at `http://127.0.0.1/callback`, opens browser to PDS authorization endpoint, intercepts redirect, exchanges code for tokens. DPoP and PAR are mandatory and handled by `haileyok/atproto-oauth-golang`. Client metadata hosted at `https://aparker.io/.well-known/oauth-client-metadata.json` (deployed as part of `dist/`); `client_id` is that URL. Bootstrap requires deploying an empty/cold-start `dist/` once before first auth — handled by the cold-start contract on `fair build`. 77 + 78 + ### Profile mechanism 79 + 80 + `--profile <name>` flag (also `FAIR_PROFILE` env). No default — required always. Allowlist of known profiles (typo rejection with Levenshtein-≤2 suggestion). Each profile has separate config file, state file, images-mirror dir, dist dir, and keychain entry (service name `blog-<profile>`, key `oauth-tokens-<profile>`). 81 + 82 + ```toml 83 + # ~/.config/fair/config.prod.toml 84 + pds_url = "https://bsky.social" # or your PDS endpoint 85 + did = "did:plc:..." 86 + domain = "https://aparker.io" 87 + publication_name = "Austin Parker" 88 + blog_posts = "/Users/austinparker/code/blog-posts" 89 + state_file = ".fair/state.prod.json" 90 + images_mirror = "images-mirror.prod/" 91 + dist_dir = "dist/" 92 + cloudflare_project = "aparker-blog" 93 + 94 + # ~/.config/fair/config.sandbox.toml 95 + pds_url = "http://localhost:2583" 96 + domain = "http://localhost:8000" 97 + # (no cloudflare_* — deploy command errors on sandbox) 98 + ``` 99 + 100 + ### Data shape 101 + 102 + **Publication record** — singleton at `at://<did>/site.standard.publication/self`: 103 + 104 + ```json 105 + { 106 + "$type": "site.standard.publication", 107 + "url": "https://aparker.io", 108 + "name": "Austin Parker", 109 + "description": "(optional)", 110 + "icon": "(optional blob)" 111 + } 112 + ``` 113 + 114 + **Document record** — one per post at `at://<did>/site.standard.document/<rkey>`: 115 + 116 + ```json 117 + { 118 + "$type": "site.standard.document", 119 + "site": "at://<did>/site.standard.publication/self", 120 + "title": "Wide Events and Personal Software", 121 + "publishedAt": "2025-08-14T00:00:00.000Z", 122 + "path": "/wide-events-and-personal-software", 123 + "description": "(frontmatter or first-paragraph extraction, ≤3000 graphemes)", 124 + "coverImage": "(optional typed blob, ≤1MB)", 125 + "tags": ["observability"], 126 + "content": { 127 + "$type": "site.standard.content.markdown", 128 + "text": "...full markdown body, inline image URLs are RELATIVE (/images/<slug>/foo.png)...", 129 + "version": "1.0" 130 + }, 131 + "textContent": "(plaintext, ≤30000 graphemes, code fences stripped)", 132 + "updatedAt": "(set on republish if content hash changed)" 133 + } 134 + ``` 135 + 136 + **Frontmatter mapping:** 137 + 138 + | Frontmatter | Record field | Notes | 139 + |---|---|---| 140 + | `id` (ULID) | (used for rkey derivation, not a record field) | Written back by CLI on first publish | 141 + | `title` | `title` | Required | 142 + | `date` | `publishedAt` | Required, normalized to RFC3339 | 143 + | `description` | `description` | Optional; fallback = first AST paragraph in first 5 nodes; truncated grapheme-aware to ≤3000 | 144 + | `cover` | `coverImage` (blob) | Optional; relative path; uploaded via `com.atproto.repo.uploadBlob`; ≤1MB enforced client-side | 145 + | `tags` | `tags` | Optional YAML list; sorted/lowercased/deduped before record write | 146 + | `slug` | `path` and rkey | Optional override; default = directory name; grammar `[a-z0-9-]+`; reserved names rejected (self/index/feed/sitemap/.well-known) | 147 + | `updated` | `updatedAt` | Optional; auto-set if content hash changed | 148 + 149 + **Inline images:** stored as RELATIVE paths (`/images/<slug>/foo.png`) in `content.text`. Build resolves against `publication.url` to emit absolute URLs in the static HTML. Records are domain-portable. 150 + 151 + ### Identity & rename detection 152 + 153 + Each post has a stable ULID `id` written back to its frontmatter on first publish (the CLI's only mutation of `blog-posts/`). Identity follows the `id`, not the path. Rename detection is local-first via `state.json` (O(1) lookup); falls back to paginated PDS scan only if `id` not in local state. `fair rename old new` deletes the old record and republishes under the new rkey atomically. 154 + 155 + ### Hash determinism (idempotency) 156 + 157 + `fair publish` skips re-putting a record when the content hash hasn't changed. Hash inputs: 158 + 159 + - Markdown body re-serialized via pinned goldmark (LF line endings, BOM stripped) 160 + - Frontmatter canonicalized (sorted keys, no comments, no trailing whitespace, sans `id`/`updated`/`updatedAt`) 161 + - Cover image **source file** SHA-256 (not blob CID — known pre-upload, no wasted upload on no-op) 162 + - Tags: sorted, lowercased, deduped 163 + 164 + Cached at `.blog/state.<profile>.json` per rkey: `{id, lastHash, lastCoverHash, lastPublishedAt}`. State file written via temp+rename for atomicity. `--force` flag bypasses hash check. 165 + 166 + Golden-file CI test asserts byte-stable goldmark round-trip on the 11 existing posts. 167 + 168 + ### Atomic build 169 + 170 + Build writes to `dist.tmp/`. On success: `mv dist/ dist.old.<pid>/; mv dist.tmp/ dist/; rm -rf dist.old.<pid>/` (skip first mv if no `dist/` yet). Build start sweeps any leftover `dist.old.*` from a prior crash. Any `listRecords` pagination error during build aborts hard with no swap — partial site never deployed. 171 + 172 + ### Cold-start contract 173 + 174 + `fair build` MUST succeed with no auth, no publication record, and zero documents. On cold start it emits: 175 + 176 + - `.well-known/oauth-client-metadata.json` (always) 177 + - `.well-known/site.standard.publication` (stub or echo of publication record if present) 178 + - Empty but well-formed `feed.xml`, `sitemap.xml` 179 + - Stub `index.html`, `404.html` 180 + 181 + This unblocks the bootstrap chicken-and-egg: deploy empty `dist/` → OAuth metadata reachable → `auth login` works → `init publication` works → first publish. 182 + 183 + ### Local sandbox 184 + 185 + `bluesky-social/pds` Docker image at `localhost:2583`. Account creation via `goat` CLI (host prerequisite, not in container). Custom lexicons rely on PDS optimistic validation default. CLI's `fair sandbox up/down/reset/serve` orchestrate compose lifecycle and embed an in-process HTTP server for client metadata + dist preview (replaces the Python `http.server` first considered). Profile-isolated state — sandbox cannot touch prod records, prod cannot touch sandbox records. 186 + 187 + Integration tests use `--project-name=blogtest_<random>` + ephemeral host port for parallelism, gated behind `//go:build sandbox`. 188 + 189 + ## Existing Patterns 190 + 191 + ### From `~/code/pds-blog` (current Next.js + WhiteWind) 192 + - **Reuse the markdown→PDS shape** of `blog-posts/migrate/index.ts` — frontmatter parsing, image URL rewriting, `putRecord` flow. Lexicon swap from `com.whtwnd.blog.entry` to `site.standard.document`. 193 + - **Reuse markdown plugin intent** (GFM, sanitize) from `post/[rkey]/page.tsx:192-200`, but reimplemented in goldmark. 194 + - **Reuse Cloudflare Pages deploy** (`wrangler pages deploy`) — same project, same domain, swap the asset source from Next.js output to `dist/`. 195 + - **Discard:** Tailwind, theme toggle, BlueskyPostEmbed iframe component, react-markdown, bright syntax highlighter, lucide icons, `next-themes`, all `"use client"` boundaries. None survive the no-CSS/no-JS constraint. 196 + 197 + ### New patterns introduced 198 + - **Profile-isolated CLI state** (config + state.json + keychain entry per profile). New for this project; no precedent in `pds-blog`. 199 + - **Hash-driven idempotent publish** with local state cache. New. 200 + - **Cold-start build contract** (works with zero PDS reachable). New. 201 + 202 + ### Identifiers 203 + - **DID/handle:** unchanged from existing PDS account 204 + - **Domain:** `aparker.io` (per user memory; *not* austinparker.me) 205 + - **Cloudflare Pages project:** existing project, source swap 206 + 207 + ## Implementation Phases 208 + 209 + ### Phase 1: Project skeleton + config + profile mechanism 210 + **Goal:** Repo scaffolded, single Go binary builds, profile loading works, no PDS interaction yet. 211 + 212 + **Components:** 213 + - Create: `go.mod`, `go.sum` 214 + - Create: `cmd/blog/main.go` (cobra root) 215 + - Create: `internal/config/config.go` (TOML loader, profile resolution, allowlist + Levenshtein typo rejection) 216 + - Create: `internal/config/config_test.go` 217 + - Create: `.gitignore` (dist/, dist.*, .blog/, *.session.json, sandbox/.env, sandbox/sandbox.pid) 218 + - Create: `README.md` (skeleton) 219 + 220 + **Dependencies:** None (first phase) 221 + 222 + **Done when:** 223 + - `go build ./...` succeeds 224 + - `blog --help` prints subcommand stub list 225 + - `fair --profile sandbox foo` and `fair --profile prod foo` resolve different config files; `fair --profile sandbo foo` errors with "did you mean sandbox?" 226 + - `blog` with no `--profile` and no `FAIR_PROFILE` errors with clear message 227 + - Unit tests pass for config loading, profile resolution, typo suggestion 228 + 229 + ### Phase 2: Markdown rendering core + hash determinism 230 + **Goal:** Goldmark integration with deterministic byte-stable round-trip; canonical frontmatter; hash function. 231 + 232 + **Components:** 233 + - Create: `internal/markdown/render.go` (goldmark with GFM, autolinks, strikethrough, tables; pinned options) 234 + - Create: `internal/markdown/render_test.go` (golden-file round-trip) 235 + - Create: `internal/markdown/canonical.go` (frontmatter canonicalization: sorted keys, no comments, LF, BOM strip) 236 + - Create: `internal/markdown/canonical_test.go` 237 + - Create: `internal/markdown/hash.go` (SHA-256 over canonicalized inputs) 238 + - Create: `internal/markdown/hash_test.go` 239 + - Create: `testdata/posts/` (copy 2-3 representative existing posts from `~/code/blog-posts` for golden tests) 240 + 241 + **Dependencies:** Phase 1 242 + 243 + **Done when:** 244 + - Round-trip test: parse + re-serialize same markdown twice = identical bytes 245 + - Canonicalization test: equivalent frontmatter (different key order, comments, whitespace) hashes identically 246 + - Hash test: same content hashes identically; field changes produce different hashes; ignored fields (`id`, `updated`, `updatedAt`) don't affect hash 247 + - All goldmark extensions work for the 11 existing post fixtures (no parse errors) 248 + 249 + ### Phase 3: ATProto client + OAuth (sandbox-only initially) 250 + **Goal:** Authenticate against a local PDS, write a record, read it back. No real PDS yet. 251 + 252 + **Components:** 253 + - Create: `internal/atproto/client.go` (thin wrapper over indigo XRPC + haileyok OAuth) 254 + - Create: `internal/atproto/oauth.go` (loopback flow, DPoP key persistence, refresh handling) 255 + - Create: `internal/atproto/keychain.go` (99designs/keyring with file fallback; profile-scoped) 256 + - Create: `internal/atproto/keychain_test.go` 257 + - Create: `cmd/blog/auth.go` (auth login/logout/status subcommands) 258 + - Create: `cmd/blog/emit_client_metadata.go` (subcommand: emit-client-metadata --out <dir>) 259 + - Create: `sandbox/compose.yaml` (PDS image, pinned tag) 260 + - Create: `sandbox/.env.example` 261 + - Create: `sandbox/seed.sh` (bootstrap: compose up → goat account create → emit metadata → server → auth → init publication) 262 + - Create: `sandbox/reset.sh` (full teardown) 263 + - Create: `sandbox/README.md` 264 + - Create: `cmd/blog/sandbox.go` (sandbox up/down/reset/serve subcommands; embedded HTTP server) 265 + - Create: `internal/atproto/client_integration_test.go` with `//go:build sandbox` tag 266 + 267 + **Dependencies:** Phase 1 (config/profile), Phase 2 (not strictly required but useful for putRecord shape tests) 268 + 269 + **Done when:** 270 + - `fair --profile sandbox sandbox up` boots PDS + embedded server; health-checked 271 + - `fair --profile sandbox emit-client-metadata --out sandbox/public/` produces a valid OAuth client metadata JSON 272 + - `fair --profile sandbox auth login` completes loopback OAuth against local PDS; tokens stored in keychain (or file fallback); `auth status` shows valid session 273 + - `fair --profile sandbox auth logout` clears session 274 + - Integration test: round-trip a synthetic record through `putRecord` + `getRecord` against sandbox PDS 275 + - Profile isolation test: sandbox login does not touch prod keychain entry 276 + 277 + ### Phase 4: Publish flow 278 + **Goal:** `fair publish` end-to-end: parse markdown → resolve identity → hash check → upload blob → put record → update state. 279 + 280 + **Components:** 281 + - Create: `internal/publish/publish.go` (the 13-step publish flow) 282 + - Create: `internal/publish/identity.go` (ULID generation + frontmatter write-back) 283 + - Create: `internal/publish/identity_test.go` 284 + - Create: `internal/publish/slug.go` (path/rkey derivation, grammar validation, reserved-name check) 285 + - Create: `internal/publish/slug_test.go` 286 + - Create: `internal/publish/state.go` (state.json read/write, atomic via temp+rename) 287 + - Create: `internal/publish/state_test.go` 288 + - Create: `internal/publish/rename.go` (local-first rename detection; PDS-fallback scan) 289 + - Create: `internal/publish/images.go` (markdown image rewriting, mirror copy) 290 + - Create: `internal/publish/images_test.go` 291 + - Create: `internal/publish/description.go` (frontmatter override + first-AST-paragraph fallback) 292 + - Create: `internal/publish/textcontent.go` (plaintext extraction, grapheme-aware truncation, warn on truncate) 293 + - Create: `cmd/blog/publish.go` (subcommand wiring; --dry-run, --force) 294 + - Create: `cmd/blog/init.go` (init local + init publication subcommands) 295 + - Create: `cmd/blog/unpublish.go`, `cmd/blog/rename.go` 296 + - Create: `internal/publish/publish_integration_test.go` (//go:build sandbox) 297 + 298 + **Dependencies:** Phases 1, 2, 3 299 + 300 + **Done when:** 301 + - Unit tests pass for: slug grammar, reserved-name rejection, ULID generation, state.json atomic write, image rewriting, description fallback, textContent grapheme truncation 302 + - Integration tests against sandbox PDS pass for: 303 + - First publish: ULID written to source, blob uploaded if cover, putRecord succeeds, state.json updated 304 + - Re-publish unchanged: hash skip, no putRecord call 305 + - Re-publish with changes: hash differs, updatedAt advances 306 + - --dry-run: no writes anywhere; reports diff 307 + - --force: re-puts even when hash unchanged 308 + - Cover removal: re-publish without `cover` frontmatter drops `coverImage` from record 309 + - Cover >1MB: rejected with clear error 310 + - Rename via local state.json: O(1) detection, blocks publish, prompts `fair rename` 311 + - `fair --profile sandbox publish` 11 fixture posts succeeds end-to-end 312 + 313 + ### Phase 5: Build flow + HTML rendering 314 + **Goal:** `fair build` reads PDS, emits complete static site under cold-start contract. 315 + 316 + **Components:** 317 + - Create: `internal/build/build.go` (orchestration: cold-start check, paginated listRecords, render loop, atomic swap) 318 + - Create: `internal/build/templates/post.html` 319 + - Create: `internal/build/templates/index.html` 320 + - Create: `internal/build/templates/feed.xml` (Atom 1.0) 321 + - Create: `internal/build/templates/sitemap.xml` 322 + - Create: `internal/build/templates/404.html` 323 + - Create: `internal/build/templates/oauth-client-metadata.json` 324 + - Create: `internal/build/templates/well-known-publication.json` 325 + - Create: `internal/build/render.go` (markdown → HTML via goldmark; resolve relative image URLs against publication.url; Bluesky blockquote handling — pure text transform, no fetch) 326 + - Create: `internal/build/atomic.go` (sweep dist.old.*; dist.tmp → dist swap) 327 + - Create: `internal/build/build_test.go` (unit; mock PDS responses) 328 + - Create: `internal/build/build_integration_test.go` (//go:build sandbox; full sandbox→dist round-trip) 329 + - Create: `cmd/blog/build.go` 330 + 331 + **Dependencies:** Phases 1, 2, 3 332 + 333 + **Done when:** 334 + - Cold-start: build with empty PDS produces stub site (all required well-known + feed/sitemap/index/404 files present and well-formed) 335 + - Build with N records produces N post pages + index + feed + sitemap; sorted by publishedAt DESC 336 + - Image URLs in HTML are absolute (joined with publication.url); URLs in PDS records remain relative 337 + - listRecords error mid-pagination aborts cleanly; existing dist/ untouched 338 + - Atomic swap survives interrupt (dist/ not deleted before dist.tmp/ ready); leftover dist.old.* swept on next start 339 + - Build performs zero network I/O beyond initial PDS reads (test asserts no http.Client.Do beyond a known allowlist) 340 + - Golden-file test: rendered HTML for fixture posts is byte-stable across runs 341 + 342 + ### Phase 6: ls, doctor, pull 343 + **Goal:** Operational subcommands that make the PDS-canonical model livable. 344 + 345 + **Components:** 346 + - Create: `cmd/blog/ls.go` (diff PDS vs local: orphaned local files, orphaned PDS records) 347 + - Create: `cmd/blog/doctor.go` (config valid, PDS reachable, well-known present, auth status, state.json consistent) 348 + - Create: `cmd/blog/pull.go` (recover markdown from PDS records into a target dir; --dry-run lists what would be written) 349 + - Create: `internal/ops/ls.go`, `internal/ops/doctor.go`, `internal/ops/pull.go` 350 + - Create: respective `_test.go` files 351 + - Create: `internal/ops/ls_integration_test.go`, etc. (//go:build sandbox) 352 + 353 + **Dependencies:** Phases 1, 2, 3, 4, 5 354 + 355 + **Done when:** 356 + - `ls` shows three columns: PDS-only, local-only, in-sync 357 + - `doctor` reports a checklist of green/red items; exits non-zero on any red 358 + - `pull --dry-run` lists records that would be materialized; `pull` writes markdown files under a target dir reconstructible into `blog-posts/` shape 359 + - All operational commands are profile-aware 360 + 361 + ### Phase 7: Migration: cutover from WhiteWind to standard.site 362 + **Goal:** Move the live aparker.io blog to the new pipeline; clean up old records after stabilization. 363 + 364 + **Components:** 365 + - Create: `cmd/blog/migrate.go` with subcommands `migrate cleanup-whtwnd` (deletes legacy `com.whtwnd.blog.entry` records) 366 + - Create: `docs/runbook-cutover.md` (step-by-step: prep, init publication, republish 11, deploy preview, verify, swap apex DNS / Cloudflare project, cleanup) 367 + - Update: existing Cloudflare Pages project to deploy `dist/` instead of Next.js output (or new project, runbook decides) 368 + - Verify against external standard.site readers: open one record in Leaflet/pckt to confirm cross-render 369 + 370 + **Dependencies:** Phases 1-6 371 + 372 + **Done when:** 373 + - All 11 posts published as `site.standard.document` records on prod PDS 374 + - aparker.io serves dist/ (preview branch first, then apex) 375 + - Cross-renders correctly in at least one external standard.site reader 376 + - After 1 week of stable operation: `fair migrate cleanup-whtwnd` removes the 11 legacy records 377 + - Old pds-blog Next.js repo archived (not deleted) 378 + 379 + ## Additional Considerations 380 + 381 + **Implementation scoping:** 7 phases, within the 8-phase limit for writing-plans. 382 + 383 + **Build trigger** is intentionally left manual + optional GitHub Action for now. Jetstream listener was explicitly ruled out — a daemon contradicts the static-redeploy model. 384 + 385 + **Domain portability:** Records contain only relative image paths and an AT-URI for `site` (not a URL). Switching domains requires updating the publication record's `url` field and redeploying. No bulk record rewrite needed. OAuth client metadata moves with the domain (it's at `<domain>/.well-known/oauth-client-metadata.json`); a re-login is required after the switch. 386 + 387 + **Inline-image domain coupling tradeoff:** External standard.site readers (Leaflet, pckt) render inline images by joining the relative URL with `publication.url`. If aparker.io is unreachable, those images don't render in external readers. Acceptable for a personal blog under the user's own domain; documented here so future work doesn't accidentally migrate to PDS-blob inline images without revisiting the spec extension. 388 + 389 + **Failure-mode tolerances:** 390 + - Orphaned blob (uploadBlob ok, putRecord fail): tolerated; `doctor` flags; PDS GC reclaims eventually. 391 + - state.json corruption: surfaced via clear error message ("published OK but local state corrupted; run `fair doctor`"). 392 + - Source markdown file dirty after partial publish: re-running publish is idempotent (id stable, hash recomputed). 393 + - listRecords mid-pagination failure: hard abort, no atomic swap. 394 + 395 + **textContent truncation** is loud (warn to stdout with from/to counts), grapheme-aware (uniseg), never silent. 396 + 397 + **Cross-platform:** macOS and Linux only. `runtime.GOOS == "windows"` causes early-exit in `fair sandbox *` with a clear "use WSL2" message. CLI itself is GOOS-portable in principle but only macOS+Linux are tested. 398 + 399 + **Repository conventions:** This is the user's project; `~/code/hound` documented `jj` workflow patterns are NOT applicable here (different project, blog-v2026 uses straight `git`/`jj` colocate per user preference). Confirm during Phase 1 if `jj` colocate is desired (default: yes, matches user's pattern).
+170
docs/runbook-cutover.md
··· 1 + # Cutover runbook: pds-blog (Next.js + WhiteWind) → blog-v2026 (Go + standard.site) 2 + 3 + Single-author production migration. Total wall-clock: ~30 min if nothing 4 + goes sideways. Safer to do this on a slow afternoon than on a Friday. 5 + 6 + ## Pre-flight 7 + 8 + - [ ] All Phase 1–6 tests passing: `go test ./...` green 9 + - [ ] Sandbox verified end-to-end at least once: `fair --profile sandbox auth login` → `init publication` → `publish` → `build` → eyeball 10 + - [ ] `~/code/blog-posts` clean and committed (this CLI mutates frontmatter on first publish to write back ULIDs) 11 + - [ ] You're prepared to deploy a near-empty `dist/` to aparker.io once before first auth login (cold-start contract; the OAuth client metadata document needs to live at the prod domain before the AS can fetch it) 12 + 13 + ## 1. Set up the prod profile 14 + 15 + Create `~/.config/fair/config.prod.toml`: 16 + 17 + ```toml 18 + pds_url = "https://bsky.social" # or your actual PDS endpoint 19 + did = "did:plc:..." # your real DID (resolve via bsky) 20 + domain = "https://aparker.io" 21 + publication_name = "Austin Parker" 22 + description = "..." # optional tagline 23 + blog_posts = "/Users/austinparker/code/blog-posts" 24 + state_file = ".fair/state.prod.json" 25 + images_mirror = "images-mirror.prod/" 26 + dist_dir = "dist/" 27 + cloudflare_project = "aparker-blog" # if you use fair deploy later 28 + loopback_client = false # prod fetches the metadata doc 29 + social_links = [ 30 + "https://bsky.app/profile/<your-handle>", 31 + # add github, mastodon, etc. 32 + ] 33 + # loopback_client deliberately omitted (defaults to false). Prod uses 34 + # the published metadata document, not the localhost client_id form. 35 + ``` 36 + 37 + Resolve your DID with: 38 + 39 + ```bash 40 + curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=<your-handle>" | jq .did 41 + ``` 42 + 43 + ## 2. Cold-start build + first deploy 44 + 45 + Build with no auth, no records — emits the empty site + the OAuth client 46 + metadata that `auth login` will need to fetch. 47 + 48 + ```bash 49 + ./fair --profile prod build 50 + ls dist/ 51 + # Should include: .well-known/oauth-client-metadata.json 52 + ``` 53 + 54 + Deploy: 55 + 56 + ```bash 57 + wrangler pages deploy dist/ --project-name=aparker-blog 58 + # Or: configure git-integrated deploys and push 59 + ``` 60 + 61 + Verify: 62 + 63 + ```bash 64 + curl -fsS https://aparker.io/.well-known/oauth-client-metadata.json | jq .client_id 65 + # Should echo: https://aparker.io/.well-known/oauth-client-metadata.json 66 + ``` 67 + 68 + ## 3. Auth login (browser hop) 69 + 70 + ```bash 71 + ./fair --profile prod auth login --handle <your-handle> 72 + ``` 73 + 74 + The browser opens to your PDS's OAuth consent page; review the requested 75 + scopes (atproto + repo:site.standard.* + blob), approve, redirect lands 76 + on the loopback. `auth status` should now show your DID. 77 + 78 + ## 4. Create the publication record 79 + 80 + ```bash 81 + ./fair --profile prod init publication 82 + # > publication record at at://did:plc:.../site.standard.publication/self 83 + ``` 84 + 85 + ## 5. Publish all 11 posts 86 + 87 + ```bash 88 + for d in ~/code/blog-posts/*/; do 89 + ./fair --profile prod publish "$d/index.md" 90 + done 91 + ``` 92 + 93 + Each first publish writes a ULID back to the markdown frontmatter — commit 94 + those changes back to ~/code/blog-posts after this loop. Re-running is 95 + idempotent (content-hash skip). 96 + 97 + Watch for: 98 + - **Cover too large**: rejected client-side at 1MB. Convert/downscale and 99 + retry the affected post. 100 + - **Slug grammar errors**: directory names must match `[a-z0-9-]+`. Rename 101 + directories (or set explicit `slug:` in frontmatter) before publishing. 102 + - **Reserved slugs**: `self`, `index`, `feed`, `sitemap`, `.well-known` are 103 + rejected. 104 + 105 + ## 6. Final build + deploy 106 + 107 + ```bash 108 + ./fair --profile prod build 109 + wrangler pages deploy dist/ --project-name=aparker-blog 110 + ``` 111 + 112 + ## 7. Verify 113 + 114 + ```bash 115 + ./fair --profile prod ls 116 + # Expect: 11 'synced' rows 117 + ./fair --profile prod doctor 118 + # Expect: all 5 checks ✓ 119 + curl -fsS https://aparker.io/feed.xml | head -10 120 + curl -fsS https://aparker.io/.well-known/site.standard.publication 121 + ``` 122 + 123 + Open https://aparker.io/ in a browser. Click through one or two posts. 124 + Eyeball OG meta tags via View Source. 125 + 126 + Cross-render in an external standard.site reader (e.g., Leaflet) to 127 + confirm interop: 128 + - Navigate to your DID in their UI 129 + - Verify your posts appear with correct metadata 130 + 131 + ## 8. (Optional) DNS / project bindings 132 + 133 + Whatever name you use for the Cloudflare Pages project, make sure the 134 + custom domain `aparker.io` is bound to it and DNS points there. 135 + 136 + ## 9. (After ~1 week of stable operation) Cleanup 137 + 138 + When you've confirmed nothing depends on the legacy WhiteWind records: 139 + 140 + ```bash 141 + ./fair --profile prod migrate cleanup-whtwnd --yes 142 + ``` 143 + 144 + Deletes every `com.whtwnd.blog.entry` record in your repo. Idempotent 145 + on missing. 146 + 147 + ## 10. Archive the old repo 148 + 149 + ```bash 150 + cd ~/code/pds-blog 151 + git tag archived-2026-04-30 152 + # Then archive the GitHub repo via the web UI (Settings → Archive). 153 + ``` 154 + 155 + Don't delete it — keep it around in case you ever want to look back at 156 + the old render. 157 + 158 + ## Rollback (if something goes wrong) 159 + 160 + The standard.site records and the WhiteWind records coexist on the PDS 161 + until step 9 is run. To roll back: 162 + 163 + 1. Re-deploy the old Next.js site to aparker.io (it still reads 164 + `com.whtwnd.blog.entry` records). 165 + 2. Optionally `./fair --profile prod ls`, then `./fair unpublish <rkey>` 166 + each `site.standard.document` record to remove the new lexicon. 167 + 3. Don't run `migrate cleanup-whtwnd` until you're sure. 168 + 169 + The blog-posts repo got ULIDs written back; those are harmless but you 170 + can manually remove the `id:` lines if you want a clean slate.
+73
go.mod
··· 1 + module aparker.io/fair 2 + 3 + go 1.26.2 4 + 5 + require github.com/spf13/cobra v1.10.2 6 + 7 + require ( 8 + github.com/BurntSushi/toml v1.6.0 // indirect 9 + github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf // indirect 10 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 11 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 12 + github.com/felixge/httpsnoop v1.0.4 // indirect 13 + github.com/go-logr/logr v1.4.2 // indirect 14 + github.com/go-logr/stdr v1.2.2 // indirect 15 + github.com/goccy/go-json v0.10.2 // indirect 16 + github.com/gogo/protobuf v1.3.2 // indirect 17 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 18 + github.com/google/uuid v1.6.0 // indirect 19 + github.com/haileyok/atproto-oauth-golang v0.0.3 // indirect 20 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 21 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 22 + github.com/hashicorp/golang-lru v1.0.2 // indirect 23 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 + github.com/ipfs/bbloom v0.0.4 // indirect 25 + github.com/ipfs/go-block-format v0.2.0 // indirect 26 + github.com/ipfs/go-cid v0.4.1 // indirect 27 + github.com/ipfs/go-datastore v0.6.0 // indirect 28 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 29 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 30 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 31 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 32 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 33 + github.com/ipfs/go-log v1.0.5 // indirect 34 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 35 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 36 + github.com/jbenet/goprocess v0.1.4 // indirect 37 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 38 + github.com/lestrrat-go/blackmagic v1.0.2 // indirect 39 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 40 + github.com/lestrrat-go/httprc v1.0.4 // indirect 41 + github.com/lestrrat-go/iter v1.0.2 // indirect 42 + github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect 43 + github.com/lestrrat-go/option v1.0.1 // indirect 44 + github.com/mattn/go-isatty v0.0.20 // indirect 45 + github.com/minio/sha256-simd v1.0.1 // indirect 46 + github.com/mr-tron/base58 v1.2.0 // indirect 47 + github.com/multiformats/go-base32 v0.1.0 // indirect 48 + github.com/multiformats/go-base36 v0.2.0 // indirect 49 + github.com/multiformats/go-multibase v0.2.0 // indirect 50 + github.com/multiformats/go-multihash v0.2.3 // indirect 51 + github.com/multiformats/go-varint v0.0.7 // indirect 52 + github.com/oklog/ulid/v2 v2.1.1 // indirect 53 + github.com/opentracing/opentracing-go v1.2.0 // indirect 54 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 55 + github.com/rivo/uniseg v0.4.7 // indirect 56 + github.com/segmentio/asm v1.2.0 // indirect 57 + github.com/spaolacci/murmur3 v1.1.0 // indirect 58 + github.com/spf13/pflag v1.0.9 // indirect 59 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 60 + github.com/yuin/goldmark v1.8.2 // indirect 61 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 62 + go.opentelemetry.io/otel v1.29.0 // indirect 63 + go.opentelemetry.io/otel/metric v1.29.0 // indirect 64 + go.opentelemetry.io/otel/trace v1.29.0 // indirect 65 + go.uber.org/atomic v1.11.0 // indirect 66 + go.uber.org/multierr v1.11.0 // indirect 67 + go.uber.org/zap v1.26.0 // indirect 68 + golang.org/x/crypto v0.31.0 // indirect 69 + golang.org/x/sys v0.28.0 // indirect 70 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 71 + gopkg.in/yaml.v3 v3.0.1 // indirect 72 + lukechampine.com/blake3 v1.2.1 // indirect 73 + )
+269
go.sum
··· 1 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 3 + github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 4 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 + github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf h1:LFlwtY9r95lAI1yYKolCLTQnwK5VjgWO87mNsKdj3Qs= 6 + github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf/go.mod h1:8FlFpF5cIq3DQG0kEHqyTkPV/5MDQoaWLcVwza5ZPJU= 7 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 8 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 9 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 10 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 11 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 + github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 14 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 15 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 16 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 17 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 18 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 19 + github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 20 + github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 22 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 23 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 24 + github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 25 + github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 26 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 27 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 28 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 29 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 30 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 31 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 34 + github.com/haileyok/atproto-oauth-golang v0.0.3 h1:LdYSl6sgz11wnv8YD5e9WtopANEg4bCfMIXHMMnkOiI= 35 + github.com/haileyok/atproto-oauth-golang v0.0.3/go.mod h1:vVRo6BPEmWOZnYk9LtXLzBPzfkY63fUaBahA+o4h55Q= 36 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 37 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 38 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 39 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 40 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 41 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 42 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 43 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 44 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 45 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 46 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 47 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 48 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 49 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 50 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 51 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 52 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 53 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 54 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 55 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 56 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 57 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 58 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 59 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 60 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 61 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 62 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 63 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 64 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 65 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 66 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 67 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 68 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 69 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 70 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 71 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 72 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 73 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 74 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 75 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 76 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 77 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 78 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 79 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 80 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 81 + github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 82 + github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 83 + github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 84 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 85 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 86 + github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 87 + github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 88 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 89 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 90 + github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 91 + github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 92 + github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 93 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 94 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 95 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 96 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 97 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 98 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 99 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 100 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 101 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 102 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 103 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 104 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 105 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 106 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 107 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 108 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 109 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 110 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 111 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 112 + github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= 113 + github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 114 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 115 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 116 + github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 117 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 118 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 119 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 120 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 121 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 122 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 123 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 124 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 125 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 126 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 127 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 128 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 129 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 130 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 131 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 132 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 133 + github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 134 + github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 135 + github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 136 + github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 137 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 138 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 139 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 140 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 141 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 142 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 143 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 144 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 145 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 146 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 147 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 148 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 149 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 150 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 151 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 152 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 153 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 154 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 155 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 156 + github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= 157 + github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 158 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 159 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 160 + go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 161 + go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 162 + go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 163 + go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 164 + go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 165 + go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 166 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 167 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 168 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 169 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 170 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 171 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 172 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 173 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 174 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 175 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 176 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 177 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 178 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 179 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 180 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 181 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 182 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 183 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 184 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 185 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 186 + golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 187 + golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 188 + golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 189 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 190 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 191 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 192 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 193 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 194 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 195 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 196 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 197 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 198 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 199 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 200 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 201 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 202 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 203 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 204 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 205 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 206 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 207 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 208 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 209 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 210 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 211 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 213 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 214 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 215 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 216 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 217 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 218 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 219 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 220 + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 221 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 222 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 223 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 + golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 + golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 228 + golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 229 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 230 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 231 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 232 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 233 + golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 234 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 235 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 236 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 237 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 238 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 239 + golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 240 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 241 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 242 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 243 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 244 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 245 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 246 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 247 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 248 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 249 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 250 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 251 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 252 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 253 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 254 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 255 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 256 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 257 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 258 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 259 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 260 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 261 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 262 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 263 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 264 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 265 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 266 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 267 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 268 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 269 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+40
internal/atproto/cabundle.go
··· 1 + package atproto 2 + 3 + import ( 4 + "crypto/x509" 5 + "errors" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + ) 10 + 11 + // LoadExtraRoots reads PEM CA bundles from each path in paths and 12 + // returns them as an x509.CertPool. Empty paths are ignored. Used to 13 + // trust mkcert's local CA when running against the sandbox. 14 + // 15 + // Returns nil pool if no path was supplied; callers can pass that 16 + // straight into newHTTPClient to fall back to system roots. 17 + func LoadExtraRoots(paths ...string) (*x509.CertPool, error) { 18 + var anyAdded bool 19 + pool := x509.NewCertPool() 20 + for _, p := range paths { 21 + if p == "" { 22 + continue 23 + } 24 + data, err := os.ReadFile(p) 25 + if err != nil { 26 + if errors.Is(err, fs.ErrNotExist) { 27 + return nil, fmt.Errorf("CA bundle not found: %s", p) 28 + } 29 + return nil, fmt.Errorf("read CA bundle %s: %w", p, err) 30 + } 31 + if !pool.AppendCertsFromPEM(data) { 32 + return nil, fmt.Errorf("no PEM certs found in %s", p) 33 + } 34 + anyAdded = true 35 + } 36 + if !anyAdded { 37 + return nil, nil 38 + } 39 + return pool, nil 40 + }
+286
internal/atproto/client.go
··· 1 + package atproto 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/x509" 7 + "encoding/json" 8 + "fmt" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/xrpc" 12 + oauth "github.com/haileyok/atproto-oauth-golang" 13 + "github.com/lestrrat-go/jwx/v2/jwk" 14 + ) 15 + 16 + // Client is the authenticated XRPC wrapper used by the publish flow. 17 + // It binds a Session to a *haileyok* XrpcClient, handles DPoP nonce 18 + // rotation transparently (persisting via the TokenStore), and exposes 19 + // repo CRUD methods modeled on com.atproto.repo.*. 20 + // 21 + // Construction: NewClient(profile, store, rootCAs). The session must 22 + // already exist on disk (i.e., `fair auth login` has run for profile). 23 + // 24 + // Concurrency: Client is not goroutine-safe. The publish flow is single- 25 + // threaded so this is acceptable; if that changes, add a Mutex around 26 + // session mutations and persistence. 27 + type Client struct { 28 + session Session 29 + store TokenStore 30 + profile string 31 + dpopKey jwk.Key 32 + xrpc *oauth.XrpcClient 33 + } 34 + 35 + // NewClient loads the session for profile and prepares the XRPC layer. 36 + // rootCAs may be nil (production); pass mkcert's CA pool for sandbox. 37 + func NewClient(profile string, store TokenStore, rootCAs *x509.CertPool) (*Client, error) { 38 + session, err := store.Load(profile) 39 + if err != nil { 40 + return nil, fmt.Errorf("load session for %s: %w", profile, err) 41 + } 42 + 43 + dpopKey, err := jwk.ParseKey(session.DPoPPrivateJWK) 44 + if err != nil { 45 + return nil, fmt.Errorf("parse stored DPoP JWK: %w", err) 46 + } 47 + 48 + c := &Client{ 49 + session: session, 50 + store: store, 51 + profile: profile, 52 + dpopKey: dpopKey, 53 + xrpc: &oauth.XrpcClient{ 54 + Client: newHTTPClient(rootCAs), 55 + }, 56 + } 57 + // Persist the new nonce on every PDS rotation so the next CLI 58 + // invocation starts with the correct value. 59 + c.xrpc.OnDpopPdsNonceChanged = func(_, nonce string) { 60 + c.session.DPoPPDSNonce = nonce 61 + _ = c.store.Save(profile, c.session) 62 + } 63 + return c, nil 64 + } 65 + 66 + // Session returns a copy of the current session state. 67 + func (c *Client) Session() Session { 68 + return c.session 69 + } 70 + 71 + // authedArgs assembles the per-request auth bundle the haileyok library 72 + // expects. 73 + func (c *Client) authedArgs() *oauth.XrpcAuthedRequestArgs { 74 + return &oauth.XrpcAuthedRequestArgs{ 75 + Did: c.session.DID, 76 + PdsUrl: c.session.PDSURL, 77 + Issuer: c.session.AuthServerIssuer, 78 + AccessToken: c.session.AccessToken, 79 + DpopPdsNonce: c.session.DPoPPDSNonce, 80 + DpopPrivateJwk: c.dpopKey, 81 + } 82 + } 83 + 84 + // --- repo CRUD -------------------------------------------------------------- 85 + 86 + // PutRecordInput models com.atproto.repo.putRecord input. 87 + type PutRecordInput struct { 88 + Repo string `json:"repo"` 89 + Collection string `json:"collection"` 90 + Rkey string `json:"rkey"` 91 + Record any `json:"record"` 92 + Validate *bool `json:"validate,omitempty"` 93 + } 94 + 95 + // RecordRef is the URI+CID returned by put/get/list operations. 96 + type RecordRef struct { 97 + URI string `json:"uri"` 98 + CID string `json:"cid"` 99 + } 100 + 101 + // PutRecord uploads a record to the repo. record is marshaled to JSON 102 + // directly (use map[string]any or a struct with json tags). 103 + func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*RecordRef, error) { 104 + in := PutRecordInput{ 105 + Repo: c.session.DID, 106 + Collection: collection, 107 + Rkey: rkey, 108 + Record: record, 109 + } 110 + var out RecordRef 111 + err := c.xrpc.Do(ctx, c.authedArgs(), 112 + xrpc.Procedure, "application/json", 113 + "com.atproto.repo.putRecord", nil, in, &out, 114 + ) 115 + if err != nil { 116 + return nil, fmt.Errorf("putRecord: %w", err) 117 + } 118 + return &out, nil 119 + } 120 + 121 + // GetRecordOutput is the shape of com.atproto.repo.getRecord output. 122 + type GetRecordOutput struct { 123 + URI string `json:"uri"` 124 + CID string `json:"cid"` 125 + Value json.RawMessage `json:"value"` 126 + } 127 + 128 + // GetRecord fetches a single record by collection+rkey. 129 + func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*GetRecordOutput, error) { 130 + params := map[string]any{ 131 + "repo": c.session.DID, 132 + "collection": collection, 133 + "rkey": rkey, 134 + } 135 + var out GetRecordOutput 136 + err := c.xrpc.Do(ctx, c.authedArgs(), 137 + xrpc.Query, "", 138 + "com.atproto.repo.getRecord", params, nil, &out, 139 + ) 140 + if err != nil { 141 + return nil, fmt.Errorf("getRecord: %w", err) 142 + } 143 + return &out, nil 144 + } 145 + 146 + // ListRecordsOutput is the shape of com.atproto.repo.listRecords output. 147 + type ListRecordsOutput struct { 148 + Records []ListRecordsItem `json:"records"` 149 + Cursor string `json:"cursor,omitempty"` 150 + } 151 + 152 + // ListRecordsItem is a single record entry in a list response. 153 + type ListRecordsItem struct { 154 + URI string `json:"uri"` 155 + CID string `json:"cid"` 156 + Value json.RawMessage `json:"value"` 157 + } 158 + 159 + // ListRecords pages through a collection. Pass cursor="" on first call; 160 + // subsequent calls pass the cursor from the prior response. 161 + // 162 + // limit is clamped to [1, 100] by the PDS; passing 0 lets the server pick. 163 + func (c *Client) ListRecords(ctx context.Context, collection, cursor string, limit int) (*ListRecordsOutput, error) { 164 + params := map[string]any{ 165 + "repo": c.session.DID, 166 + "collection": collection, 167 + } 168 + if cursor != "" { 169 + params["cursor"] = cursor 170 + } 171 + if limit > 0 { 172 + params["limit"] = limit 173 + } 174 + var out ListRecordsOutput 175 + err := c.xrpc.Do(ctx, c.authedArgs(), 176 + xrpc.Query, "", 177 + "com.atproto.repo.listRecords", params, nil, &out, 178 + ) 179 + if err != nil { 180 + return nil, fmt.Errorf("listRecords: %w", err) 181 + } 182 + return &out, nil 183 + } 184 + 185 + // DeleteRecord removes a record by collection+rkey. Idempotent on a 186 + // well-behaved PDS — deleting a missing record returns success. 187 + func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) error { 188 + in := map[string]any{ 189 + "repo": c.session.DID, 190 + "collection": collection, 191 + "rkey": rkey, 192 + } 193 + err := c.xrpc.Do(ctx, c.authedArgs(), 194 + xrpc.Procedure, "application/json", 195 + "com.atproto.repo.deleteRecord", nil, in, nil, 196 + ) 197 + if err != nil { 198 + return fmt.Errorf("deleteRecord: %w", err) 199 + } 200 + return nil 201 + } 202 + 203 + // --- blob ------------------------------------------------------------------- 204 + 205 + // BlobRef is the typed blob the publish flow embeds in record fields 206 + // (e.g., site.standard.document.coverImage). The shape matches ATProto's 207 + // canonical blob CBOR encoding when serialized to JSON. 208 + type BlobRef struct { 209 + Type string `json:"$type"` 210 + Ref BlobCID `json:"ref"` 211 + MimeType string `json:"mimeType"` 212 + Size int64 `json:"size"` 213 + } 214 + 215 + // BlobCID is the inner CID link. 216 + type BlobCID struct { 217 + Link string `json:"$link"` 218 + } 219 + 220 + // uploadBlobOutput is the response shape from com.atproto.repo.uploadBlob. 221 + type uploadBlobOutput struct { 222 + Blob BlobRef `json:"blob"` 223 + } 224 + 225 + // UploadBlob uploads a blob and returns the typed BlobRef suitable for 226 + // embedding in record fields. 227 + func (c *Client) UploadBlob(ctx context.Context, mimeType string, data []byte) (*BlobRef, error) { 228 + if mimeType == "" { 229 + return nil, fmt.Errorf("UploadBlob: mimeType required") 230 + } 231 + body := bytes.NewReader(data) 232 + var out uploadBlobOutput 233 + err := c.xrpc.Do(ctx, c.authedArgs(), 234 + xrpc.Procedure, mimeType, 235 + "com.atproto.repo.uploadBlob", nil, body, &out, 236 + ) 237 + if err != nil { 238 + return nil, fmt.Errorf("uploadBlob: %w", err) 239 + } 240 + return &out.Blob, nil 241 + } 242 + 243 + // --- token refresh ---------------------------------------------------------- 244 + 245 + // RefreshIfNeeded calls the auth server's refresh endpoint if the 246 + // session's access token is at-or-past expiry, then persists the new 247 + // session. Caller should invoke this at the top of any publish-flow 248 + // run that may use the Client across the expiry boundary. 249 + // 250 + // clientID and clientKey must match the ones used at login (loopback 251 + // or domain-derived, plus the persistent ECDSA JWK). RefreshIfNeeded 252 + // is a no-op if the session isn't expired. 253 + func (c *Client) RefreshIfNeeded(ctx context.Context, clientID string, clientKey jwk.Key, rootCAs *x509.CertPool) error { 254 + if !c.session.IsExpired(time.Now()) { 255 + return nil 256 + } 257 + oauthClient, err := oauth.NewClient(oauth.ClientArgs{ 258 + Http: newHTTPClient(rootCAs), 259 + ClientJwk: clientKey, 260 + ClientId: clientID, 261 + // RedirectUri is required by NewClient but not used during refresh. 262 + RedirectUri: "http://127.0.0.1/callback", 263 + }) 264 + if err != nil { 265 + return fmt.Errorf("oauth.NewClient for refresh: %w", err) 266 + } 267 + 268 + resp, err := oauthClient.RefreshTokenRequest( 269 + ctx, 270 + c.session.RefreshToken, 271 + c.session.AuthServerIssuer, 272 + c.session.DPoPAuthServerNonce, 273 + c.dpopKey, 274 + ) 275 + if err != nil { 276 + return fmt.Errorf("refresh token: %w", err) 277 + } 278 + 279 + c.session.AccessToken = resp.AccessToken 280 + c.session.RefreshToken = resp.RefreshToken 281 + c.session.ExpiresAt = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) 282 + if resp.DpopAuthserverNonce != "" { 283 + c.session.DPoPAuthServerNonce = resp.DpopAuthserverNonce 284 + } 285 + return c.store.Save(c.profile, c.session) 286 + }
+79
internal/atproto/clientkey.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path/filepath" 10 + 11 + "github.com/haileyok/atproto-oauth-golang/helpers" 12 + "github.com/lestrrat-go/jwx/v2/jwk" 13 + ) 14 + 15 + // LoadOrCreateClientKey returns the persistent ECDSA P-256 client signing 16 + // key at path, generating and saving one if the file is missing. The key 17 + // is used for OAuth client_assertion JWTs (the haileyok library always 18 + // signs assertions, even when the AS allows token_endpoint_auth_method=none). 19 + // 20 + // File perms are 0600. Directory created with MkdirAll if missing. 21 + // 22 + // The returned key is the private JWK; pass it through PublicJWKS to get 23 + // the JWKS document to embed in the OAuth client metadata. 24 + func LoadOrCreateClientKey(path string) (jwk.Key, error) { 25 + if data, err := os.ReadFile(path); err == nil { 26 + key, parseErr := jwk.ParseKey(data) 27 + if parseErr != nil { 28 + return nil, fmt.Errorf("parse client key %s: %w", path, parseErr) 29 + } 30 + return key, nil 31 + } else if !errors.Is(err, fs.ErrNotExist) { 32 + return nil, fmt.Errorf("read client key %s: %w", path, err) 33 + } 34 + 35 + // Generate fresh key. Pass nil to helpers.GenerateKey to use a 36 + // timestamp-based kid; we don't have a profile prefix to distinguish 37 + // keys here because the file path itself is profile-scoped. 38 + key, err := helpers.GenerateKey(nil) 39 + if err != nil { 40 + return nil, fmt.Errorf("generate client key: %w", err) 41 + } 42 + 43 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 44 + return nil, fmt.Errorf("ensure key dir: %w", err) 45 + } 46 + 47 + data, err := json.Marshal(key) 48 + if err != nil { 49 + return nil, fmt.Errorf("marshal client key: %w", err) 50 + } 51 + if err := os.WriteFile(path, data, 0o600); err != nil { 52 + return nil, fmt.Errorf("write client key: %w", err) 53 + } 54 + return key, nil 55 + } 56 + 57 + // PublicJWKS returns a JWKS-shaped map containing the public half of key. 58 + // Suitable to embed as the "jwks" field of an OAuth client metadata JSON 59 + // document. Verifies the private "d" field is not present in the result. 60 + func PublicJWKS(key jwk.Key) (map[string]any, error) { 61 + pub, err := key.PublicKey() 62 + if err != nil { 63 + return nil, fmt.Errorf("derive public key: %w", err) 64 + } 65 + b, err := json.Marshal(pub) 66 + if err != nil { 67 + return nil, fmt.Errorf("marshal public key: %w", err) 68 + } 69 + var pubMap map[string]any 70 + if err := json.Unmarshal(b, &pubMap); err != nil { 71 + return nil, fmt.Errorf("unmarshal public key: %w", err) 72 + } 73 + if _, leaked := pubMap["d"]; leaked { 74 + return nil, fmt.Errorf("private 'd' field present in public JWK; refusing to leak") 75 + } 76 + return map[string]any{ 77 + "keys": []any{pubMap}, 78 + }, nil 79 + }
+82
internal/atproto/clientkey_test.go
··· 1 + package atproto_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "runtime" 7 + "testing" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + ) 11 + 12 + func TestClientKey_GeneratesNewWhenMissing(t *testing.T) { 13 + path := filepath.Join(t.TempDir(), "client-key.json") 14 + key, err := atproto.LoadOrCreateClientKey(path) 15 + if err != nil { 16 + t.Fatalf("LoadOrCreateClientKey: %v", err) 17 + } 18 + if key == nil { 19 + t.Fatal("nil key") 20 + } 21 + if key.KeyID() == "" { 22 + t.Error("key has no kid") 23 + } 24 + if _, err := os.Stat(path); err != nil { 25 + t.Errorf("expected key file at %s: %v", path, err) 26 + } 27 + } 28 + 29 + func TestClientKey_ReusesExisting(t *testing.T) { 30 + path := filepath.Join(t.TempDir(), "client-key.json") 31 + first, err := atproto.LoadOrCreateClientKey(path) 32 + if err != nil { 33 + t.Fatal(err) 34 + } 35 + second, err := atproto.LoadOrCreateClientKey(path) 36 + if err != nil { 37 + t.Fatal(err) 38 + } 39 + if first.KeyID() != second.KeyID() { 40 + t.Errorf("kid drifted on reload: %s -> %s", first.KeyID(), second.KeyID()) 41 + } 42 + } 43 + 44 + func TestClientKey_FileIs0600(t *testing.T) { 45 + if runtime.GOOS == "windows" { 46 + t.Skip("perms model differs on windows") 47 + } 48 + path := filepath.Join(t.TempDir(), "client-key.json") 49 + if _, err := atproto.LoadOrCreateClientKey(path); err != nil { 50 + t.Fatal(err) 51 + } 52 + info, err := os.Stat(path) 53 + if err != nil { 54 + t.Fatal(err) 55 + } 56 + if info.Mode().Perm() != 0o600 { 57 + t.Errorf("got perms %o, want 0600", info.Mode().Perm()) 58 + } 59 + } 60 + 61 + func TestClientKey_PublicJWKSContainsKey(t *testing.T) { 62 + path := filepath.Join(t.TempDir(), "client-key.json") 63 + key, err := atproto.LoadOrCreateClientKey(path) 64 + if err != nil { 65 + t.Fatal(err) 66 + } 67 + jwks, err := atproto.PublicJWKS(key) 68 + if err != nil { 69 + t.Fatalf("PublicJWKS: %v", err) 70 + } 71 + keys, ok := jwks["keys"].([]any) 72 + if !ok || len(keys) != 1 { 73 + t.Fatalf("expected 1 key in JWKS, got %v", jwks["keys"]) 74 + } 75 + pub, _ := keys[0].(map[string]any) 76 + if pub["kid"] != key.KeyID() { 77 + t.Errorf("public kid mismatch: %v vs %s", pub["kid"], key.KeyID()) 78 + } 79 + if pub["d"] != nil { 80 + t.Error("private key 'd' field leaked into public JWKS") 81 + } 82 + }
+286
internal/atproto/login.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "crypto/x509" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "net/http" 11 + "net/url" 12 + "os" 13 + "os/exec" 14 + "runtime" 15 + "time" 16 + 17 + oauth "github.com/haileyok/atproto-oauth-golang" 18 + "github.com/haileyok/atproto-oauth-golang/helpers" 19 + "github.com/lestrrat-go/jwx/v2/jwk" 20 + ) 21 + 22 + // LoopbackRedirectPath is the path the loopback HTTP server registers 23 + // for the OAuth callback. It's also embedded in the loopback client_id 24 + // query string so the PDS's auto-generated client metadata accepts our 25 + // dynamic-port redirect_uri. 26 + const LoopbackRedirectPath = "/callback" 27 + 28 + // BlogScopes is the space-separated OAuth scope string the publish flow 29 + // needs from the auth server. ATProto's scope grammar requires explicit 30 + // per-collection grants; "atproto" alone is identity-only and won't let 31 + // us putRecord against site.standard.* collections. 32 + // 33 + // Composition: 34 + // - atproto — base scope (always required) 35 + // - repo:site.standard.publication — read/write the publication singleton 36 + // - repo:site.standard.document — read/write/delete posts 37 + // - repo:com.whtwnd.blog.entry — for migration cleanup of legacy records 38 + // - blob — upload cover images 39 + const BlogScopes = "atproto repo:site.standard.publication repo:site.standard.document repo:com.whtwnd.blog.entry blob" 40 + 41 + // LoopbackClientID builds an ATProto-compliant loopback client_id of the 42 + // form http://localhost?scope=...&redirect_uri=http%3A%2F%2F127.0.0.1%2F<path>. 43 + // The PDS auto-generates client metadata from this URL — no metadata 44 + // document is fetched, bypassing SSRF protections that block fetches 45 + // to any hostname resolving to a loopback IP. 46 + func LoopbackClientID(redirectPath string) string { 47 + if redirectPath == "" { 48 + redirectPath = LoopbackRedirectPath 49 + } 50 + q := url.Values{} 51 + q.Set("scope", BlogScopes) 52 + // http://127.0.0.1<path> — port left blank because RFC 8252 allows 53 + // the actual loopback port to be picked at runtime. 54 + q.Set("redirect_uri", "http://127.0.0.1"+redirectPath) 55 + return "http://localhost?" + q.Encode() 56 + } 57 + 58 + // LoginConfig is the input to Login. ClientKey is the persistent ECDSA 59 + // JWK loaded by LoadOrCreateClientKey. RootCAs is optional — set it to 60 + // the mkcert CA pool when running against the sandbox so the Go HTTP 61 + // client trusts the sandbox cert without requiring sudo mkcert -install. 62 + type LoginConfig struct { 63 + // PDSURL: the user's PDS URL (e.g., "https://pds.localtest.me", 64 + // "https://bsky.social"). Must be HTTPS and have no port (the 65 + // haileyok library rejects URLs with explicit ports). 66 + PDSURL string 67 + 68 + // Handle: optional login_hint sent in the PAR request. Empty means 69 + // the user types it on the auth page. 70 + Handle string 71 + 72 + // ClientID: the public URL of this CLI's OAuth client metadata 73 + // document, e.g. "https://aparker.io/.well-known/oauth-client-metadata.json". 74 + ClientID string 75 + 76 + // ClientKey: the ECDSA P-256 JWK used to sign client_assertion JWTs. 77 + ClientKey jwk.Key 78 + 79 + // RootCAs: optional extra CA pool. If non-nil, it's used as the 80 + // trust anchor; the system pool is NOT consulted. Use for sandbox 81 + // (mkcert root). Leave nil for production. 82 + RootCAs *x509.CertPool 83 + } 84 + 85 + // ErrStateMismatch indicates the state value returned in the loopback 86 + // callback did not match what was sent in the PAR request. Treat as a 87 + // CSRF / replay attempt. 88 + var ErrStateMismatch = errors.New("OAuth state mismatch") 89 + 90 + // Login runs the full ATProto OAuth loopback flow: starts the loopback 91 + // HTTP listener, makes a PAR request, opens the user's browser to the 92 + // authorization URL, waits for the redirect, exchanges the code for 93 + // tokens, and returns a populated Session. 94 + // 95 + // The browser hop happens via `open`/`xdg-open`; if that fails the 96 + // authorization URL is printed to stderr so the user can open it 97 + // manually. Login blocks until the callback arrives or ctx is canceled. 98 + // 99 + // The returned Session has DPoPPDSNonce empty — that nonce is issued by 100 + // the PDS on the first authenticated XRPC request, not at login time. 101 + func Login(ctx context.Context, cfg LoginConfig) (Session, error) { 102 + if cfg.PDSURL == "" { 103 + return Session{}, fmt.Errorf("Login: PDSURL is required") 104 + } 105 + if cfg.ClientID == "" { 106 + return Session{}, fmt.Errorf("Login: ClientID is required") 107 + } 108 + if cfg.ClientKey == nil { 109 + return Session{}, fmt.Errorf("Login: ClientKey is required") 110 + } 111 + 112 + // 1. Loopback first — we need its random port to construct the 113 + // OAuth client's redirect_uri before the PAR request. 114 + loopback, err := StartLoopback() 115 + if err != nil { 116 + return Session{}, fmt.Errorf("start loopback: %w", err) 117 + } 118 + defer loopback.Close() 119 + 120 + // 2. HTTP client. If RootCAs is set, trust only those (sandbox); 121 + // otherwise rely on system trust (production). 122 + httpClient := newHTTPClient(cfg.RootCAs) 123 + 124 + // 3. Build the haileyok OAuth client. 125 + oauthClient, err := oauth.NewClient(oauth.ClientArgs{ 126 + Http: httpClient, 127 + ClientJwk: cfg.ClientKey, 128 + ClientId: cfg.ClientID, 129 + RedirectUri: loopback.RedirectURI(), 130 + }) 131 + if err != nil { 132 + return Session{}, fmt.Errorf("oauth.NewClient: %w", err) 133 + } 134 + 135 + // 4. Resolve PDS -> auth server. 136 + authServerURL, err := oauthClient.ResolvePdsAuthServer(ctx, cfg.PDSURL) 137 + if err != nil { 138 + return Session{}, fmt.Errorf("resolve PDS auth server: %w", err) 139 + } 140 + 141 + // 5. Auth server metadata. The library validates strict ATProto 142 + // conformance; failures here mean the AS isn't speaking the 143 + // profile correctly (e.g., issuer carries a port). 144 + meta, err := oauthClient.FetchAuthServerMetadata(ctx, authServerURL) 145 + if err != nil { 146 + return Session{}, fmt.Errorf("fetch auth server metadata: %w", err) 147 + } 148 + 149 + // 6. Per-session DPoP key. Distinct from the persistent client 150 + // signing key — DPoP keys may be rotated freely. 151 + dpopKey, err := helpers.GenerateKey(nil) 152 + if err != nil { 153 + return Session{}, fmt.Errorf("generate dpop key: %w", err) 154 + } 155 + 156 + // 7. PAR. Library handles PKCE + state generation internally and 157 + // returns them on the response so we can verify in step 11. 158 + // Scope must match what was declared in the client metadata 159 + // (loopback) or the published metadata document (prod). 160 + parResp, err := oauthClient.SendParAuthRequest( 161 + ctx, 162 + authServerURL, 163 + meta, 164 + cfg.Handle, // login_hint, may be empty 165 + BlogScopes, // scope 166 + dpopKey, 167 + ) 168 + if err != nil { 169 + return Session{}, fmt.Errorf("PAR request: %w", err) 170 + } 171 + 172 + // 8. Build the authorization URL the user opens in their browser. 173 + authURL, err := buildAuthorizationURL(meta.AuthorizationEndpoint, cfg.ClientID, parResp.RequestUri) 174 + if err != nil { 175 + return Session{}, fmt.Errorf("build auth URL: %w", err) 176 + } 177 + 178 + // 9. Browser hop. 179 + fmt.Fprintf(os.Stderr, "Opening browser for authorization...\n") 180 + fmt.Fprintf(os.Stderr, "If it doesn't open, paste this URL:\n %s\n\n", authURL) 181 + if err := openBrowser(authURL); err != nil { 182 + // Not fatal — the user can copy-paste from stderr above. 183 + fmt.Fprintf(os.Stderr, "(could not auto-open browser: %v)\n", err) 184 + } 185 + 186 + // 10. Wait for the redirect. 187 + callback, err := loopback.Wait(ctx) 188 + if err != nil { 189 + return Session{}, fmt.Errorf("await callback: %w", err) 190 + } 191 + 192 + // 11. Verify state to prevent CSRF / replay. 193 + if callback.State != parResp.State { 194 + return Session{}, ErrStateMismatch 195 + } 196 + if callback.Error != "" { 197 + msg := callback.Error 198 + if callback.ErrorDescription != "" { 199 + msg += ": " + callback.ErrorDescription 200 + } 201 + return Session{}, fmt.Errorf("authorization denied: %s", msg) 202 + } 203 + if callback.Code == "" { 204 + return Session{}, fmt.Errorf("callback returned empty code") 205 + } 206 + 207 + // 12. Code -> tokens. 208 + tokens, err := oauthClient.InitialTokenRequest( 209 + ctx, 210 + callback.Code, 211 + callback.Iss, 212 + parResp.PkceVerifier, 213 + parResp.DpopAuthserverNonce, 214 + dpopKey, 215 + ) 216 + if err != nil { 217 + return Session{}, fmt.Errorf("token exchange: %w", err) 218 + } 219 + 220 + // 13. Persist DPoP key as JSON bytes so we can round-trip it across 221 + // CLI invocations later. 222 + dpopBytes, err := json.Marshal(dpopKey) 223 + if err != nil { 224 + return Session{}, fmt.Errorf("marshal dpop key: %w", err) 225 + } 226 + 227 + return Session{ 228 + DID: tokens.Sub, 229 + PDSURL: cfg.PDSURL, 230 + AuthServerIssuer: callback.Iss, 231 + AccessToken: tokens.AccessToken, 232 + RefreshToken: tokens.RefreshToken, 233 + ExpiresAt: time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second), 234 + DPoPPrivateJWK: dpopBytes, 235 + DPoPPDSNonce: "", // set on first authenticated XRPC call 236 + DPoPAuthServerNonce: tokens.DpopAuthserverNonce, 237 + }, nil 238 + } 239 + 240 + // newHTTPClient builds an http.Client. When extraRoots is non-nil, the 241 + // client trusts ONLY that pool (suitable for sandbox + mkcert). When 242 + // nil, the client uses the system trust store. 243 + func newHTTPClient(extraRoots *x509.CertPool) *http.Client { 244 + transport := http.DefaultTransport.(*http.Transport).Clone() 245 + if extraRoots != nil { 246 + transport.TLSClientConfig = &tls.Config{ 247 + RootCAs: extraRoots, 248 + } 249 + } 250 + return &http.Client{ 251 + Transport: transport, 252 + Timeout: 30 * time.Second, 253 + } 254 + } 255 + 256 + // buildAuthorizationURL appends client_id and request_uri to the 257 + // authorization endpoint. Per RFC 9126, when using PAR the auth server 258 + // only needs (client_id, request_uri) — all other params live in the 259 + // pushed request. 260 + func buildAuthorizationURL(endpoint, clientID, requestURI string) (string, error) { 261 + u, err := url.Parse(endpoint) 262 + if err != nil { 263 + return "", err 264 + } 265 + q := u.Query() 266 + q.Set("client_id", clientID) 267 + q.Set("request_uri", requestURI) 268 + u.RawQuery = q.Encode() 269 + return u.String(), nil 270 + } 271 + 272 + // openBrowser launches the OS default browser pointed at rawURL. 273 + // macOS: `open`. Linux: `xdg-open`. Windows: not supported (fair sandbox 274 + // doesn't run on Windows; in production deploy from macOS/Linux). 275 + func openBrowser(rawURL string) error { 276 + var cmd *exec.Cmd 277 + switch runtime.GOOS { 278 + case "darwin": 279 + cmd = exec.Command("open", rawURL) 280 + case "linux": 281 + cmd = exec.Command("xdg-open", rawURL) 282 + default: 283 + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) 284 + } 285 + return cmd.Start() 286 + }
+146
internal/atproto/loopback.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "net" 8 + "net/http" 9 + "sync" 10 + "time" 11 + ) 12 + 13 + // CallbackResult is the parsed query string from /callback. Either 14 + // Code+Iss are set (success) or Error is set (PDS rejected the 15 + // authorization). State is always echoed back so the caller can verify 16 + // it matches what was sent in the PAR request. 17 + type CallbackResult struct { 18 + Code string 19 + State string 20 + Iss string 21 + Error string 22 + ErrorDescription string 23 + } 24 + 25 + // LoopbackServer is the embedded HTTP server that catches the OAuth 26 + // authorization-code redirect during `fair auth login`. The CLI binds 27 + // it on a random local port, opens the browser to the auth URL with 28 + // this redirect_uri, and Wait()s for the user to complete the consent 29 + // flow in their browser. 30 + type LoopbackServer struct { 31 + listener net.Listener 32 + server *http.Server 33 + port int 34 + 35 + once sync.Once 36 + result chan CallbackResult 37 + closed chan struct{} 38 + } 39 + 40 + // StartLoopback binds 127.0.0.1:0, registers the /callback handler, and 41 + // starts serving in the background. The returned server is ready to 42 + // receive a redirect immediately. Caller must invoke Close() when done. 43 + func StartLoopback() (*LoopbackServer, error) { 44 + ln, err := net.Listen("tcp", "127.0.0.1:0") 45 + if err != nil { 46 + return nil, fmt.Errorf("bind loopback: %w", err) 47 + } 48 + port := ln.Addr().(*net.TCPAddr).Port 49 + 50 + s := &LoopbackServer{ 51 + listener: ln, 52 + port: port, 53 + result: make(chan CallbackResult, 1), 54 + closed: make(chan struct{}), 55 + } 56 + 57 + mux := http.NewServeMux() 58 + mux.HandleFunc("/callback", s.handleCallback) 59 + s.server = &http.Server{ 60 + Handler: mux, 61 + ReadHeaderTimeout: 5 * time.Second, 62 + } 63 + 64 + go func() { 65 + _ = s.server.Serve(ln) 66 + }() 67 + return s, nil 68 + } 69 + 70 + // RedirectURI returns the loopback URI for use in the OAuth client 71 + // metadata's `redirect_uri` and the PAR request's redirect_uri. The 72 + // port is random per invocation; the URI is only valid for the life 73 + // of this LoopbackServer. 74 + func (s *LoopbackServer) RedirectURI() string { 75 + return fmt.Sprintf("http://127.0.0.1:%d/callback", s.port) 76 + } 77 + 78 + // Wait blocks until the browser hits /callback or ctx is canceled. 79 + // Calling Wait after Close returns an error. 80 + func (s *LoopbackServer) Wait(ctx context.Context) (CallbackResult, error) { 81 + select { 82 + case r := <-s.result: 83 + return r, nil 84 + case <-s.closed: 85 + return CallbackResult{}, errors.New("loopback server closed before callback received") 86 + case <-ctx.Done(): 87 + return CallbackResult{}, ctx.Err() 88 + } 89 + } 90 + 91 + // Close shuts down the listener and any in-flight serve goroutine. It is 92 + // safe to call multiple times; only the first invocation does work. 93 + func (s *LoopbackServer) Close() error { 94 + var err error 95 + s.once.Do(func() { 96 + close(s.closed) 97 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 98 + defer cancel() 99 + err = s.server.Shutdown(ctx) 100 + }) 101 + return err 102 + } 103 + 104 + // handleCallback parses the redirect query string and emits a 105 + // CallbackResult on the result channel. If the channel is full 106 + // (more than one /callback hit, somehow), subsequent hits are dropped 107 + // — the OAuth flow is single-shot. 108 + func (s *LoopbackServer) handleCallback(w http.ResponseWriter, r *http.Request) { 109 + q := r.URL.Query() 110 + res := CallbackResult{ 111 + Code: q.Get("code"), 112 + State: q.Get("state"), 113 + Iss: q.Get("iss"), 114 + Error: q.Get("error"), 115 + ErrorDescription: q.Get("error_description"), 116 + } 117 + 118 + // Show the user a confirmation page; they may need to switch back 119 + // to the terminal to see what happens next. 120 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 121 + if res.Error != "" { 122 + fmt.Fprintf(w, 123 + `<!doctype html><title>Sign-in failed</title> 124 + <h1>Sign-in failed</h1> 125 + <p>%s%s</p> 126 + <p>You can close this tab and check the terminal.</p>`, 127 + res.Error, 128 + func() string { 129 + if res.ErrorDescription != "" { 130 + return ": " + res.ErrorDescription 131 + } 132 + return "" 133 + }(), 134 + ) 135 + } else { 136 + fmt.Fprint(w, `<!doctype html><title>Signed in</title> 137 + <h1>Signed in.</h1> 138 + <p>You can close this tab and return to the terminal.</p>`) 139 + } 140 + 141 + select { 142 + case s.result <- res: 143 + default: 144 + // Already received a callback — drop subsequent ones. 145 + } 146 + }
+142
internal/atproto/loopback_test.go
··· 1 + package atproto_test 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "io" 7 + "net/http" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "aparker.io/fair/internal/atproto" 13 + ) 14 + 15 + func TestLoopback_RedirectURIPointsAtRandomLocalPort(t *testing.T) { 16 + srv, err := atproto.StartLoopback() 17 + if err != nil { 18 + t.Fatalf("StartLoopback: %v", err) 19 + } 20 + defer srv.Close() 21 + 22 + uri := srv.RedirectURI() 23 + if !strings.HasPrefix(uri, "http://127.0.0.1:") { 24 + t.Errorf("RedirectURI %q should start with http://127.0.0.1:", uri) 25 + } 26 + if !strings.HasSuffix(uri, "/callback") { 27 + t.Errorf("RedirectURI %q should end with /callback", uri) 28 + } 29 + } 30 + 31 + func TestLoopback_CallbackDeliversCodeStateIss(t *testing.T) { 32 + srv, err := atproto.StartLoopback() 33 + if err != nil { 34 + t.Fatal(err) 35 + } 36 + defer srv.Close() 37 + 38 + go func() { 39 + // give the server a moment to be in the Accept loop 40 + time.Sleep(20 * time.Millisecond) 41 + resp, err := http.Get(srv.RedirectURI() + "?code=THE_CODE&state=THE_STATE&iss=https%3A%2F%2Fbsky.social") 42 + if err != nil { 43 + t.Errorf("GET callback: %v", err) 44 + return 45 + } 46 + // Confirm browser sees a friendly success page 47 + body, _ := io.ReadAll(resp.Body) 48 + resp.Body.Close() 49 + if !strings.Contains(strings.ToLower(string(body)), "you can close") { 50 + t.Errorf("expected close-this-tab page, got:\n%s", string(body)) 51 + } 52 + }() 53 + 54 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 55 + defer cancel() 56 + result, err := srv.Wait(ctx) 57 + if err != nil { 58 + t.Fatalf("Wait: %v", err) 59 + } 60 + if result.Code != "THE_CODE" { 61 + t.Errorf("Code: %q", result.Code) 62 + } 63 + if result.State != "THE_STATE" { 64 + t.Errorf("State: %q", result.State) 65 + } 66 + if result.Iss != "https://bsky.social" { 67 + t.Errorf("Iss: %q", result.Iss) 68 + } 69 + if result.Error != "" { 70 + t.Errorf("Error: %q (expected empty)", result.Error) 71 + } 72 + } 73 + 74 + func TestLoopback_CallbackSurfacesAuthError(t *testing.T) { 75 + srv, err := atproto.StartLoopback() 76 + if err != nil { 77 + t.Fatal(err) 78 + } 79 + defer srv.Close() 80 + 81 + go func() { 82 + time.Sleep(20 * time.Millisecond) 83 + resp, _ := http.Get(srv.RedirectURI() + "?error=access_denied&error_description=user+rejected&state=ST") 84 + if resp != nil { 85 + resp.Body.Close() 86 + } 87 + }() 88 + 89 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 90 + defer cancel() 91 + result, err := srv.Wait(ctx) 92 + if err != nil { 93 + t.Fatalf("Wait should succeed even on auth error (caller decides): %v", err) 94 + } 95 + if result.Error != "access_denied" { 96 + t.Errorf("Error: %q", result.Error) 97 + } 98 + if result.State != "ST" { 99 + t.Errorf("State: %q", result.State) 100 + } 101 + } 102 + 103 + func TestLoopback_WaitRespectsContextCancel(t *testing.T) { 104 + srv, err := atproto.StartLoopback() 105 + if err != nil { 106 + t.Fatal(err) 107 + } 108 + defer srv.Close() 109 + 110 + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) 111 + defer cancel() 112 + 113 + _, err = srv.Wait(ctx) 114 + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { 115 + t.Errorf("expected context error, got %v", err) 116 + } 117 + } 118 + 119 + func TestLoopback_CloseUnblocksWait(t *testing.T) { 120 + srv, err := atproto.StartLoopback() 121 + if err != nil { 122 + t.Fatal(err) 123 + } 124 + 125 + done := make(chan error, 1) 126 + go func() { 127 + _, werr := srv.Wait(context.Background()) 128 + done <- werr 129 + }() 130 + 131 + time.Sleep(20 * time.Millisecond) 132 + srv.Close() 133 + 134 + select { 135 + case err := <-done: 136 + if err == nil { 137 + t.Error("expected non-nil error after Close()") 138 + } 139 + case <-time.After(time.Second): 140 + t.Error("Wait did not return after Close()") 141 + } 142 + }
+75
internal/atproto/metadata.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + ) 10 + 11 + // ClientMetadata is the public OAuth client metadata document the PDS 12 + // fetches at the URL given as `client_id`. The shape follows the ATProto 13 + // OAuth profile (RFC 7591 + RFC 9449 DPoP) for a native loopback CLI: 14 + // no client secret, application_type=native, token_endpoint_auth_method=none, 15 + // loopback redirect URI. 16 + // 17 + // The @atproto/oauth-provider validator enforces native + none in 18 + // ClientManager.validateClientMetadata; using private_key_jwt with native 19 + // is rejected with `invalid_client_metadata`. 20 + type ClientMetadata struct { 21 + ClientID string `json:"client_id"` 22 + ClientName string `json:"client_name"` 23 + ApplicationType string `json:"application_type"` 24 + GrantTypes []string `json:"grant_types"` 25 + ResponseTypes []string `json:"response_types"` 26 + RedirectURIs []string `json:"redirect_uris"` 27 + Scope string `json:"scope"` 28 + DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 29 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 30 + } 31 + 32 + // EmitClientMetadata writes the OAuth client metadata JSON to 33 + // <out>/.well-known/oauth-client-metadata.json. The client_id is derived 34 + // from the domain so the document is self-referential when fetched. 35 + // 36 + // `domain` should be an absolute URL like "https://aparker.io" (trailing 37 + // slash optional). Empty domain returns an error — silently emitting 38 + // half-configured metadata would be a footgun later. 39 + // 40 + // out is created with MkdirAll if missing. 41 + func EmitClientMetadata(out string, domain string) error { 42 + domain = strings.TrimRight(domain, "/") 43 + if domain == "" { 44 + return fmt.Errorf("empty domain: cannot derive client_id") 45 + } 46 + 47 + meta := ClientMetadata{ 48 + ClientID: domain + "/.well-known/oauth-client-metadata.json", 49 + ClientName: "fair", 50 + ApplicationType: "native", 51 + GrantTypes: []string{"authorization_code", "refresh_token"}, 52 + ResponseTypes: []string{"code"}, 53 + RedirectURIs: []string{"http://127.0.0.1/callback"}, 54 + Scope: BlogScopes, 55 + DPoPBoundAccessTokens: true, 56 + TokenEndpointAuthMethod: "none", 57 + } 58 + 59 + data, err := json.MarshalIndent(meta, "", " ") 60 + if err != nil { 61 + return fmt.Errorf("marshal metadata: %w", err) 62 + } 63 + // Trailing newline — friendlier in editors and curl output. 64 + data = append(data, '\n') 65 + 66 + dir := filepath.Join(out, ".well-known") 67 + if err := os.MkdirAll(dir, 0o755); err != nil { 68 + return fmt.Errorf("mkdir %s: %w", dir, err) 69 + } 70 + path := filepath.Join(dir, "oauth-client-metadata.json") 71 + if err := os.WriteFile(path, data, 0o644); err != nil { 72 + return fmt.Errorf("write %s: %w", path, err) 73 + } 74 + return nil 75 + }
+135
internal/atproto/metadata_test.go
··· 1 + package atproto_test 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + 10 + "aparker.io/fair/internal/atproto" 11 + ) 12 + 13 + func TestEmitClientMetadata_WritesAtWellKnownPath(t *testing.T) { 14 + dir := t.TempDir() 15 + if err := atproto.EmitClientMetadata(dir, "https://aparker.io"); err != nil { 16 + t.Fatalf("EmitClientMetadata: %v", err) 17 + } 18 + path := filepath.Join(dir, ".well-known", "oauth-client-metadata.json") 19 + if _, err := os.Stat(path); err != nil { 20 + t.Fatalf("expected file at %s: %v", path, err) 21 + } 22 + } 23 + 24 + func TestEmitClientMetadata_ProducesValidShape(t *testing.T) { 25 + dir := t.TempDir() 26 + if err := atproto.EmitClientMetadata(dir, "https://aparker.io"); err != nil { 27 + t.Fatal(err) 28 + } 29 + path := filepath.Join(dir, ".well-known", "oauth-client-metadata.json") 30 + data, err := os.ReadFile(path) 31 + if err != nil { 32 + t.Fatal(err) 33 + } 34 + var got map[string]any 35 + if err := json.Unmarshal(data, &got); err != nil { 36 + t.Fatalf("metadata is not valid JSON: %v\n%s", err, string(data)) 37 + } 38 + 39 + wantString := map[string]string{ 40 + "client_id": "https://aparker.io/.well-known/oauth-client-metadata.json", 41 + "application_type": "native", 42 + "token_endpoint_auth_method": "none", 43 + } 44 + if !strings.Contains(got["scope"].(string), "atproto") { 45 + t.Errorf("scope missing 'atproto': %q", got["scope"]) 46 + } 47 + if !strings.Contains(got["scope"].(string), "repo:site.standard.document") { 48 + t.Errorf("scope missing 'repo:site.standard.document': %q", got["scope"]) 49 + } 50 + for k, want := range wantString { 51 + if got[k] != want { 52 + t.Errorf("%s: got %v, want %q", k, got[k], want) 53 + } 54 + } 55 + 56 + // dpop_bound_access_tokens is a bool 57 + if b, ok := got["dpop_bound_access_tokens"].(bool); !ok || !b { 58 + t.Errorf("dpop_bound_access_tokens: got %v, want true", got["dpop_bound_access_tokens"]) 59 + } 60 + 61 + // grant_types must include both authorization_code and refresh_token 62 + for _, want := range []string{"authorization_code", "refresh_token"} { 63 + if !containsAny(got["grant_types"], want) { 64 + t.Errorf("grant_types missing %q: got %v", want, got["grant_types"]) 65 + } 66 + } 67 + 68 + // response_types ⊇ ["code"] 69 + if !containsAny(got["response_types"], "code") { 70 + t.Errorf("response_types missing 'code': got %v", got["response_types"]) 71 + } 72 + 73 + // redirect_uris must include the loopback callback 74 + if !containsAny(got["redirect_uris"], "http://127.0.0.1/callback") { 75 + t.Errorf("redirect_uris missing loopback callback: got %v", got["redirect_uris"]) 76 + } 77 + } 78 + 79 + func TestEmitClientMetadata_ClientIDFollowsDomain(t *testing.T) { 80 + cases := []struct { 81 + domain string 82 + want string 83 + }{ 84 + {"https://aparker.io", "https://aparker.io/.well-known/oauth-client-metadata.json"}, 85 + {"https://aparker.io/", "https://aparker.io/.well-known/oauth-client-metadata.json"}, 86 + {"http://localhost:8000", "http://localhost:8000/.well-known/oauth-client-metadata.json"}, 87 + } 88 + for _, tc := range cases { 89 + t.Run(tc.domain, func(t *testing.T) { 90 + dir := t.TempDir() 91 + if err := atproto.EmitClientMetadata(dir, tc.domain); err != nil { 92 + t.Fatal(err) 93 + } 94 + data, _ := os.ReadFile(filepath.Join(dir, ".well-known", "oauth-client-metadata.json")) 95 + var got map[string]any 96 + _ = json.Unmarshal(data, &got) 97 + if got["client_id"] != tc.want { 98 + t.Errorf("got %q, want %q", got["client_id"], tc.want) 99 + } 100 + }) 101 + } 102 + } 103 + 104 + func TestEmitClientMetadata_RejectsEmptyDomain(t *testing.T) { 105 + dir := t.TempDir() 106 + err := atproto.EmitClientMetadata(dir, "") 107 + if err == nil { 108 + t.Fatal("expected error for empty domain") 109 + } 110 + } 111 + 112 + func TestEmitClientMetadata_CreatesDirectoryIfMissing(t *testing.T) { 113 + dir := filepath.Join(t.TempDir(), "deep", "nested", "out") 114 + if err := atproto.EmitClientMetadata(dir, "https://aparker.io"); err != nil { 115 + t.Fatalf("should mkdir-p: %v", err) 116 + } 117 + path := filepath.Join(dir, ".well-known", "oauth-client-metadata.json") 118 + if _, err := os.Stat(path); err != nil { 119 + t.Errorf("file not at %s: %v", path, err) 120 + } 121 + } 122 + 123 + // containsAny returns true if v is a JSON-decoded array containing want. 124 + func containsAny(v any, want string) bool { 125 + arr, ok := v.([]any) 126 + if !ok { 127 + return false 128 + } 129 + for _, item := range arr { 130 + if s, ok := item.(string); ok && s == want { 131 + return true 132 + } 133 + } 134 + return false 135 + }
+36
internal/atproto/session.go
··· 1 + // Package atproto holds the OAuth + XRPC plumbing the fair CLI uses to 2 + // talk to a PDS. Session and TokenStore live here; the higher-level 3 + // Client lives in client.go. 4 + package atproto 5 + 6 + import "time" 7 + 8 + // Session is the per-profile authentication state persisted across CLI 9 + // invocations. Every field is required to be present for the client to 10 + // successfully sign and refresh requests; missing any of them means the 11 + // user must `fair auth login` again. 12 + // 13 + // Field choices follow what haileyok/atproto-oauth-golang produces: 14 + // - DPoPPrivateJWK is the raw JSON-encoded JWK (not a parsed struct) 15 + // so we can persist whatever shape the library writes without 16 + // re-marshaling. 17 + // - DPoPPDSNonce and DPoPAuthServerNonce rotate independently; the 18 + // library exposes a callback when the PDS nonce changes. 19 + type Session struct { 20 + DID string `json:"did"` 21 + PDSURL string `json:"pds_url"` 22 + AuthServerIssuer string `json:"auth_server_issuer"` 23 + AccessToken string `json:"access_token"` 24 + RefreshToken string `json:"refresh_token"` 25 + ExpiresAt time.Time `json:"expires_at"` 26 + DPoPPrivateJWK []byte `json:"dpop_private_jwk"` 27 + DPoPPDSNonce string `json:"dpop_pds_nonce"` 28 + DPoPAuthServerNonce string `json:"dpop_auth_server_nonce"` 29 + } 30 + 31 + // IsExpired reports whether the access token is at-or-past its expiry 32 + // at the given clock time. Callers should refresh before issuing the 33 + // next request when this returns true. 34 + func (s Session) IsExpired(now time.Time) bool { 35 + return !now.Before(s.ExpiresAt) 36 + }
+102
internal/atproto/store.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path/filepath" 10 + ) 11 + 12 + // ErrSessionNotFound is returned by TokenStore.Load when no session 13 + // exists for the given profile. Use errors.Is to check. 14 + var ErrSessionNotFound = errors.New("session not found") 15 + 16 + // TokenStore persists Session values keyed by profile name. Implementations 17 + // must isolate profiles — saving "sandbox" must never affect "prod". 18 + type TokenStore interface { 19 + Save(profile string, s Session) error 20 + Load(profile string) (Session, error) 21 + Delete(profile string) error 22 + } 23 + 24 + // FileStore stores sessions as JSON files at <dir>/session.<profile>.json 25 + // with 0600 perms. It's the always-available fallback when no OS keychain 26 + // backend is reachable, and the implementation tests exercise. 27 + type FileStore struct { 28 + dir string 29 + } 30 + 31 + // NewFileStore returns a FileStore writing to dir. The directory is 32 + // created lazily on the first Save; callers don't need to mkdir it. 33 + func NewFileStore(dir string) *FileStore { 34 + return &FileStore{dir: dir} 35 + } 36 + 37 + func (s *FileStore) path(profile string) string { 38 + return filepath.Join(s.dir, "session."+profile+".json") 39 + } 40 + 41 + // Save writes session JSON atomically: marshal -> tmpfile -> rename. 42 + // The temp file lives in the same directory as the target so rename is 43 + // guaranteed to be atomic on the same filesystem. 44 + func (s *FileStore) Save(profile string, sess Session) error { 45 + if err := os.MkdirAll(s.dir, 0o700); err != nil { 46 + return fmt.Errorf("ensure store dir: %w", err) 47 + } 48 + data, err := json.MarshalIndent(sess, "", " ") 49 + if err != nil { 50 + return fmt.Errorf("marshal session: %w", err) 51 + } 52 + 53 + target := s.path(profile) 54 + tmp, err := os.CreateTemp(s.dir, "session."+profile+".*.tmp") 55 + if err != nil { 56 + return fmt.Errorf("create temp: %w", err) 57 + } 58 + tmpName := tmp.Name() 59 + // Best-effort cleanup if anything below fails; rename will replace it. 60 + defer os.Remove(tmpName) 61 + 62 + if err := tmp.Chmod(0o600); err != nil { 63 + tmp.Close() 64 + return fmt.Errorf("chmod temp: %w", err) 65 + } 66 + if _, err := tmp.Write(data); err != nil { 67 + tmp.Close() 68 + return fmt.Errorf("write temp: %w", err) 69 + } 70 + if err := tmp.Close(); err != nil { 71 + return fmt.Errorf("close temp: %w", err) 72 + } 73 + if err := os.Rename(tmpName, target); err != nil { 74 + return fmt.Errorf("rename: %w", err) 75 + } 76 + return nil 77 + } 78 + 79 + // Load returns the persisted session for profile, or ErrSessionNotFound. 80 + func (s *FileStore) Load(profile string) (Session, error) { 81 + data, err := os.ReadFile(s.path(profile)) 82 + if err != nil { 83 + if errors.Is(err, fs.ErrNotExist) { 84 + return Session{}, ErrSessionNotFound 85 + } 86 + return Session{}, fmt.Errorf("read session: %w", err) 87 + } 88 + var sess Session 89 + if err := json.Unmarshal(data, &sess); err != nil { 90 + return Session{}, fmt.Errorf("parse session %s: %w", s.path(profile), err) 91 + } 92 + return sess, nil 93 + } 94 + 95 + // Delete removes the session for profile. Missing files are not an error. 96 + func (s *FileStore) Delete(profile string) error { 97 + err := os.Remove(s.path(profile)) 98 + if err != nil && !errors.Is(err, fs.ErrNotExist) { 99 + return fmt.Errorf("remove session: %w", err) 100 + } 101 + return nil 102 + }
+165
internal/atproto/store_test.go
··· 1 + package atproto_test 2 + 3 + import ( 4 + "errors" 5 + "os" 6 + "path/filepath" 7 + "reflect" 8 + "runtime" 9 + "testing" 10 + "time" 11 + 12 + "aparker.io/fair/internal/atproto" 13 + ) 14 + 15 + func sampleSession() atproto.Session { 16 + return atproto.Session{ 17 + DID: "did:plc:abc", 18 + PDSURL: "https://bsky.social", 19 + AuthServerIssuer: "https://bsky.social", 20 + AccessToken: "AT-token", 21 + RefreshToken: "RT-token", 22 + ExpiresAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC), 23 + DPoPPrivateJWK: []byte(`{"kty":"EC","crv":"P-256","x":"abc","y":"def","d":"ghi"}`), 24 + DPoPPDSNonce: "pds-nonce-1", 25 + DPoPAuthServerNonce: "as-nonce-1", 26 + } 27 + } 28 + 29 + func TestFileStore_RoundTripPreservesAllFields(t *testing.T) { 30 + store := atproto.NewFileStore(t.TempDir()) 31 + want := sampleSession() 32 + 33 + if err := store.Save("prod", want); err != nil { 34 + t.Fatalf("Save: %v", err) 35 + } 36 + got, err := store.Load("prod") 37 + if err != nil { 38 + t.Fatalf("Load: %v", err) 39 + } 40 + if !reflect.DeepEqual(got, want) { 41 + t.Errorf("round-trip mismatch:\ngot: %#v\nwant: %#v", got, want) 42 + } 43 + } 44 + 45 + func TestFileStore_LoadMissingReturnsErrNotFound(t *testing.T) { 46 + store := atproto.NewFileStore(t.TempDir()) 47 + _, err := store.Load("nonexistent") 48 + if !errors.Is(err, atproto.ErrSessionNotFound) { 49 + t.Errorf("got %v, want ErrSessionNotFound", err) 50 + } 51 + } 52 + 53 + func TestFileStore_DeleteRemovesSession(t *testing.T) { 54 + store := atproto.NewFileStore(t.TempDir()) 55 + if err := store.Save("prod", sampleSession()); err != nil { 56 + t.Fatal(err) 57 + } 58 + if err := store.Delete("prod"); err != nil { 59 + t.Fatalf("Delete: %v", err) 60 + } 61 + _, err := store.Load("prod") 62 + if !errors.Is(err, atproto.ErrSessionNotFound) { 63 + t.Errorf("after delete, expected ErrSessionNotFound, got %v", err) 64 + } 65 + } 66 + 67 + func TestFileStore_DeleteMissingIsNoOp(t *testing.T) { 68 + store := atproto.NewFileStore(t.TempDir()) 69 + // No save first — delete on missing must not error 70 + if err := store.Delete("ghost"); err != nil { 71 + t.Errorf("delete on missing: %v", err) 72 + } 73 + } 74 + 75 + func TestFileStore_FilePermsAre0600(t *testing.T) { 76 + if runtime.GOOS == "windows" { 77 + t.Skip("perms model differs on windows") 78 + } 79 + dir := t.TempDir() 80 + store := atproto.NewFileStore(dir) 81 + if err := store.Save("prod", sampleSession()); err != nil { 82 + t.Fatal(err) 83 + } 84 + 85 + matches, err := filepath.Glob(filepath.Join(dir, "session.prod*")) 86 + if err != nil || len(matches) == 0 { 87 + t.Fatalf("no session file found: %v %v", err, matches) 88 + } 89 + info, err := os.Stat(matches[0]) 90 + if err != nil { 91 + t.Fatal(err) 92 + } 93 + if info.Mode().Perm() != 0o600 { 94 + t.Errorf("got perms %o, want 0600", info.Mode().Perm()) 95 + } 96 + } 97 + 98 + func TestFileStore_SaveIsAtomic_NoPartialWrite(t *testing.T) { 99 + // Save the same profile twice; if Save crashed in the middle we'd 100 + // expect a partial file. We can't crash mid-write in a unit test, 101 + // but we *can* assert that Save's intermediate temp file does not 102 + // linger after success. 103 + dir := t.TempDir() 104 + store := atproto.NewFileStore(dir) 105 + if err := store.Save("prod", sampleSession()); err != nil { 106 + t.Fatal(err) 107 + } 108 + 109 + entries, err := os.ReadDir(dir) 110 + if err != nil { 111 + t.Fatal(err) 112 + } 113 + for _, e := range entries { 114 + if e.Name() == "session.prod.json" { 115 + continue 116 + } 117 + t.Errorf("unexpected leftover file after Save: %q", e.Name()) 118 + } 119 + } 120 + 121 + func TestFileStore_ProfilesIsolated(t *testing.T) { 122 + store := atproto.NewFileStore(t.TempDir()) 123 + prodSess := sampleSession() 124 + prodSess.DID = "did:plc:prod" 125 + 126 + sandSess := sampleSession() 127 + sandSess.DID = "did:plc:sandbox" 128 + 129 + if err := store.Save("prod", prodSess); err != nil { 130 + t.Fatal(err) 131 + } 132 + if err := store.Save("sandbox", sandSess); err != nil { 133 + t.Fatal(err) 134 + } 135 + 136 + got, err := store.Load("prod") 137 + if err != nil || got.DID != "did:plc:prod" { 138 + t.Errorf("prod load: got %v, %v", got, err) 139 + } 140 + got, err = store.Load("sandbox") 141 + if err != nil || got.DID != "did:plc:sandbox" { 142 + t.Errorf("sandbox load: got %v, %v", got, err) 143 + } 144 + } 145 + 146 + func TestSession_IsExpired(t *testing.T) { 147 + now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) 148 + cases := []struct { 149 + name string 150 + expiresAt time.Time 151 + want bool 152 + }{ 153 + {"future, not expired", now.Add(time.Hour), false}, 154 + {"past, expired", now.Add(-time.Hour), true}, 155 + {"at exactly now (treat as expired)", now, true}, 156 + } 157 + for _, tc := range cases { 158 + t.Run(tc.name, func(t *testing.T) { 159 + s := atproto.Session{ExpiresAt: tc.expiresAt} 160 + if got := s.IsExpired(now); got != tc.want { 161 + t.Errorf("got %v, want %v", got, tc.want) 162 + } 163 + }) 164 + } 165 + }
+25
internal/atproto/testhelpers_test.go
··· 1 + package atproto_test 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/atproto" 8 + ) 9 + 10 + // testJWKS returns a minimal JWKS map suitable for EmitClientMetadata 11 + // in tests. Each call generates a fresh client key in t.TempDir() so 12 + // tests don't share state. 13 + func testJWKS(t *testing.T) map[string]any { 14 + t.Helper() 15 + keyPath := filepath.Join(t.TempDir(), "test-client-key.json") 16 + key, err := atproto.LoadOrCreateClientKey(keyPath) 17 + if err != nil { 18 + t.Fatalf("LoadOrCreateClientKey: %v", err) 19 + } 20 + jwks, err := atproto.PublicJWKS(key) 21 + if err != nil { 22 + t.Fatalf("PublicJWKS: %v", err) 23 + } 24 + return jwks 25 + }
+99
internal/build/atomic.go
··· 1 + // Package build renders standard.site PDS records into a static HTML 2 + // site under dist/. The orchestrator (Build in build.go) reads the 3 + // publication + document records, renders each, writes them to dist.tmp/, 4 + // and atomically promotes dist.tmp/ to dist/. 5 + package build 6 + 7 + import ( 8 + "errors" 9 + "fmt" 10 + "io/fs" 11 + "os" 12 + "path/filepath" 13 + "strings" 14 + "time" 15 + ) 16 + 17 + const ( 18 + // distOldPrefix is the per-promote rename of the existing dist/. 19 + // SweepStaleDistOld removes any leftover from prior crashes. 20 + distOldPrefix = "dist.old." 21 + ) 22 + 23 + // AtomicPromote replaces target with tmpOut atomically: 24 + // 1. mv target -> target.dist.old.<pid> (skip if target absent) 25 + // 2. mv tmpOut -> target 26 + // 3. rm -rf the old dir (best-effort; not fatal) 27 + // 28 + // On a same-filesystem layout, step 2 is the actual atomic operation; 29 + // observers see either the old or new tree, never a half-move. Step 1 30 + // is also a rename, also atomic, so the only window where target may 31 + // be absent is between (1) and (2) — typically microseconds. 32 + // 33 + // tmpOut must exist and be a directory. target's parent must exist. 34 + func AtomicPromote(tmpOut, target string) error { 35 + if _, err := os.Stat(tmpOut); err != nil { 36 + return fmt.Errorf("tmpOut %s: %w", tmpOut, err) 37 + } 38 + 39 + parent := filepath.Dir(target) 40 + old := filepath.Join(parent, distOldPrefix+pidString()) 41 + 42 + // Step 1: rename target aside if it exists. 43 + targetExisted := false 44 + if _, err := os.Stat(target); err == nil { 45 + if err := os.Rename(target, old); err != nil { 46 + return fmt.Errorf("rename %s -> %s: %w", target, old, err) 47 + } 48 + targetExisted = true 49 + } else if !errors.Is(err, fs.ErrNotExist) { 50 + return fmt.Errorf("stat %s: %w", target, err) 51 + } 52 + 53 + // Step 2: rename tmpOut -> target. The atomic step. 54 + if err := os.Rename(tmpOut, target); err != nil { 55 + // Try to put the old dir back so we don't leave a missing dist. 56 + if targetExisted { 57 + _ = os.Rename(old, target) 58 + } 59 + return fmt.Errorf("rename %s -> %s: %w", tmpOut, target, err) 60 + } 61 + 62 + // Step 3: best-effort cleanup of the old tree. 63 + if targetExisted { 64 + _ = os.RemoveAll(old) 65 + } 66 + return nil 67 + } 68 + 69 + // SweepStaleDistOld removes any dist.old.* directories under parent. 70 + // Called at the start of Build so a prior crash mid-promote doesn't 71 + // leave stale snapshots behind. 72 + func SweepStaleDistOld(parent string) error { 73 + entries, err := os.ReadDir(parent) 74 + if err != nil { 75 + if errors.Is(err, fs.ErrNotExist) { 76 + return nil 77 + } 78 + return err 79 + } 80 + for _, e := range entries { 81 + if !e.IsDir() { 82 + continue 83 + } 84 + if !strings.HasPrefix(e.Name(), distOldPrefix) { 85 + continue 86 + } 87 + if err := os.RemoveAll(filepath.Join(parent, e.Name())); err != nil { 88 + return err 89 + } 90 + } 91 + return nil 92 + } 93 + 94 + // pidString builds a tag for the dist.old directory name. PID + nanos 95 + // are unique enough to handle two concurrent builds (which we don't 96 + // support but want to fail gracefully on). 97 + func pidString() string { 98 + return fmt.Sprintf("%d.%d", os.Getpid(), time.Now().UnixNano()) 99 + }
+117
internal/build/atomic_test.go
··· 1 + package build_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "aparker.io/fair/internal/build" 10 + ) 11 + 12 + func TestAtomicPromote_FreshTarget(t *testing.T) { 13 + tmpDir := t.TempDir() 14 + tmpOut := filepath.Join(tmpDir, "dist.tmp") 15 + target := filepath.Join(tmpDir, "dist") 16 + 17 + if err := os.MkdirAll(tmpOut, 0o755); err != nil { 18 + t.Fatal(err) 19 + } 20 + if err := os.WriteFile(filepath.Join(tmpOut, "index.html"), []byte("<h1>v1</h1>"), 0o644); err != nil { 21 + t.Fatal(err) 22 + } 23 + 24 + if err := build.AtomicPromote(tmpOut, target); err != nil { 25 + t.Fatalf("AtomicPromote: %v", err) 26 + } 27 + 28 + got, err := os.ReadFile(filepath.Join(target, "index.html")) 29 + if err != nil { 30 + t.Fatal(err) 31 + } 32 + if string(got) != "<h1>v1</h1>" { 33 + t.Errorf("got %q", string(got)) 34 + } 35 + } 36 + 37 + func TestAtomicPromote_ReplacesExisting(t *testing.T) { 38 + tmpDir := t.TempDir() 39 + tmpOut := filepath.Join(tmpDir, "dist.tmp") 40 + target := filepath.Join(tmpDir, "dist") 41 + 42 + // Pretend a previous build already exists with v0 43 + os.MkdirAll(target, 0o755) 44 + os.WriteFile(filepath.Join(target, "index.html"), []byte("<h1>v0</h1>"), 0o644) 45 + 46 + // New build in tmpOut 47 + os.MkdirAll(tmpOut, 0o755) 48 + os.WriteFile(filepath.Join(tmpOut, "index.html"), []byte("<h1>v1</h1>"), 0o644) 49 + 50 + if err := build.AtomicPromote(tmpOut, target); err != nil { 51 + t.Fatalf("AtomicPromote: %v", err) 52 + } 53 + 54 + got, err := os.ReadFile(filepath.Join(target, "index.html")) 55 + if err != nil { 56 + t.Fatal(err) 57 + } 58 + if string(got) != "<h1>v1</h1>" { 59 + t.Errorf("target not replaced: got %q", string(got)) 60 + } 61 + } 62 + 63 + func TestAtomicPromote_RemovesOldDirAfterPromote(t *testing.T) { 64 + tmpDir := t.TempDir() 65 + tmpOut := filepath.Join(tmpDir, "dist.tmp") 66 + target := filepath.Join(tmpDir, "dist") 67 + 68 + os.MkdirAll(target, 0o755) 69 + os.WriteFile(filepath.Join(target, "index.html"), []byte("v0"), 0o644) 70 + os.MkdirAll(tmpOut, 0o755) 71 + os.WriteFile(filepath.Join(tmpOut, "index.html"), []byte("v1"), 0o644) 72 + 73 + if err := build.AtomicPromote(tmpOut, target); err != nil { 74 + t.Fatal(err) 75 + } 76 + 77 + entries, err := os.ReadDir(tmpDir) 78 + if err != nil { 79 + t.Fatal(err) 80 + } 81 + for _, e := range entries { 82 + // Only the target should remain. The tmp dir was renamed; old 83 + // dir was renamed-aside and removed. 84 + if !strings.HasSuffix(e.Name(), "dist") || strings.HasPrefix(e.Name(), "dist.old") { 85 + t.Errorf("leftover directory: %s", e.Name()) 86 + } 87 + } 88 + } 89 + 90 + func TestAtomicPromote_TmpOutMustExist(t *testing.T) { 91 + tmpDir := t.TempDir() 92 + err := build.AtomicPromote(filepath.Join(tmpDir, "missing"), filepath.Join(tmpDir, "dist")) 93 + if err == nil { 94 + t.Fatal("expected error for missing tmp dir") 95 + } 96 + } 97 + 98 + func TestSweepStaleDistOldDirs(t *testing.T) { 99 + tmpDir := t.TempDir() 100 + for _, name := range []string{"dist.old.123", "dist.old.456", "unrelated"} { 101 + os.MkdirAll(filepath.Join(tmpDir, name), 0o755) 102 + } 103 + 104 + if err := build.SweepStaleDistOld(tmpDir); err != nil { 105 + t.Fatal(err) 106 + } 107 + 108 + entries, _ := os.ReadDir(tmpDir) 109 + got := []string{} 110 + for _, e := range entries { 111 + got = append(got, e.Name()) 112 + } 113 + want := []string{"unrelated"} 114 + if len(got) != len(want) || got[0] != want[0] { 115 + t.Errorf("got %v, want %v", got, want) 116 + } 117 + }
+654
internal/build/build.go
··· 1 + package build 2 + 3 + import ( 4 + "context" 5 + "crypto/x509" 6 + "embed" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + htmltemplate "html/template" 11 + "io" 12 + "io/fs" 13 + texttemplate "text/template" 14 + "net/http" 15 + "os" 16 + "path/filepath" 17 + "sort" 18 + "strings" 19 + "time" 20 + 21 + "aparker.io/fair/internal/atproto" 22 + "aparker.io/fair/internal/markdown" 23 + ) 24 + 25 + // Embedded templates. These ship inside the binary so the build flow 26 + // works without a working directory layout. 27 + // 28 + //go:embed templates/* 29 + var templatesFS embed.FS 30 + 31 + // Config bundles the per-call build inputs. 32 + type Config struct { 33 + // PDSURL: the user's PDS, used for unauthenticated listRecords / 34 + // getRecord (public records don't need OAuth). 35 + PDSURL string 36 + 37 + // DID: the user's identity. The build flow only reads records 38 + // owned by this DID. 39 + DID string 40 + 41 + // PublicationURL: absolute URL where the rendered site lives. Used 42 + // for resolving relative image URLs and for canonical links in the 43 + // rendered HTML. 44 + PublicationURL string 45 + 46 + // PublicationName: display title used in <title> tags, etc. 47 + // Falls back to PublicationURL if empty. 48 + PublicationName string 49 + 50 + // Description: optional publication-wide tagline. Used for index 51 + // <meta description> and OpenGraph og:description. 52 + Description string 53 + 54 + // SocialLinks: URLs declared as <link rel="me"> in every page. 55 + SocialLinks []string 56 + 57 + // ImagesMirror: directory containing the staged inline images 58 + // (populated by the publish flow). Copied into <DistDir>/images/. 59 + ImagesMirror string 60 + 61 + // DistDir: target output directory. Build writes to DistDir+".tmp" 62 + // then atomically renames. 63 + DistDir string 64 + 65 + // CAPool: optional extra trust anchors. Use for the sandbox 66 + // (mkcert root) to talk to https://pds.localtest.me without 67 + // system trust modifications. 68 + CAPool *x509.CertPool 69 + } 70 + 71 + // Result reports what Build produced. 72 + type Result struct { 73 + PostCount int 74 + ColdStart bool // true when the publication record was missing 75 + OutDir string 76 + } 77 + 78 + // Build reads the publication + document records from the configured 79 + // PDS and emits a complete static site in cfg.DistDir. The flow: 80 + // 1. Sweep stale dist.old.* dirs from prior crashes. 81 + // 2. Always emit <DistDir.tmp>/.well-known/oauth-client-metadata.json. 82 + // 3. Try to fetch the publication record; on absence, emit a cold-start 83 + // stub site (well-known, empty feed/sitemap, stub index/404) and exit. 84 + // 4. listRecords paginated; abort hard on any pagination error. 85 + // 5. Render each document to dist.tmp/<path>/index.html. 86 + // 6. Render index.html, feed.xml, sitemap.xml, .well-known/site.standard.publication, 404.html. 87 + // 7. Copy ImagesMirror -> dist.tmp/images/. 88 + // 8. Atomically promote dist.tmp -> dist. 89 + func Build(ctx context.Context, cfg Config) (*Result, error) { 90 + if cfg.DistDir == "" { 91 + return nil, errors.New("DistDir is required") 92 + } 93 + // Strip trailing slash so filepath.Dir computes the actual parent 94 + // (Dir of "foo/" is "foo", not "."), and so AtomicPromote's rename 95 + // target doesn't end up inside the source. 96 + cfg.DistDir = strings.TrimRight(cfg.DistDir, "/") 97 + parent := filepath.Dir(cfg.DistDir) 98 + if err := SweepStaleDistOld(parent); err != nil { 99 + return nil, fmt.Errorf("sweep stale dist.old: %w", err) 100 + } 101 + 102 + tmpOut := cfg.DistDir + ".tmp" 103 + // Reset tmpOut so a failed prior build doesn't leak files. 104 + if err := os.RemoveAll(tmpOut); err != nil { 105 + return nil, fmt.Errorf("reset tmp: %w", err) 106 + } 107 + if err := os.MkdirAll(tmpOut, 0o755); err != nil { 108 + return nil, fmt.Errorf("mkdir tmp: %w", err) 109 + } 110 + 111 + // Always emit OAuth client metadata first — it's part of the 112 + // cold-start contract: the auth flow needs it before any login 113 + // can happen, even on a virgin deploy. 114 + if err := atproto.EmitClientMetadata(tmpOut, cfg.PublicationURL); err != nil { 115 + return nil, fmt.Errorf("emit client metadata: %w", err) 116 + } 117 + 118 + httpClient := &http.Client{Timeout: 30 * time.Second} 119 + if cfg.CAPool != nil { 120 + httpClient.Transport = caTransport(cfg.CAPool) 121 + } 122 + 123 + // Step 3: try to fetch the publication record. 124 + pub, err := fetchPublication(ctx, httpClient, cfg.PDSURL, cfg.DID) 125 + if err != nil { 126 + // Hard error fetching from PDS — abort, leave existing dist alone. 127 + return nil, fmt.Errorf("fetch publication: %w", err) 128 + } 129 + 130 + siteData := siteContext{ 131 + Publication: publicationView{ 132 + URL: strings.TrimRight(cfg.PublicationURL, "/"), 133 + Name: coalesce(cfg.PublicationName, cfg.PublicationURL), 134 + Description: cfg.Description, 135 + SocialLinks: cfg.SocialLinks, 136 + }, 137 + GeneratedAt: time.Now().UTC(), 138 + } 139 + if pub != nil { 140 + if pub.Name != "" { 141 + siteData.Publication.Name = pub.Name 142 + } 143 + if pub.Description != "" { 144 + siteData.Publication.Description = pub.Description 145 + } 146 + // publication.icon is a typed blob in the lexicon. Fetch it and 147 + // save as /favicon.<ext>; absent records leave FaviconPath empty. 148 + if pub.Icon != nil { 149 + if path, err := fetchAndSaveFavicon(ctx, httpClient, cfg, pub.Icon, tmpOut); err == nil && path != "" { 150 + siteData.Publication.FaviconPath = path 151 + } else if err != nil { 152 + fmt.Fprintf(os.Stderr, "warn: favicon fetch failed: %v\n", err) 153 + } 154 + } 155 + } 156 + 157 + if pub == nil { 158 + // Cold start: emit minimal site, atomic-promote, return. 159 + if err := emitColdStart(tmpOut, siteData); err != nil { 160 + return nil, err 161 + } 162 + if err := AtomicPromote(tmpOut, cfg.DistDir); err != nil { 163 + return nil, err 164 + } 165 + return &Result{ColdStart: true, OutDir: cfg.DistDir}, nil 166 + } 167 + 168 + // Step 4: paginated listRecords (read-only, unauthenticated). 169 + docs, err := listAllDocuments(ctx, httpClient, cfg.PDSURL, cfg.DID) 170 + if err != nil { 171 + return nil, fmt.Errorf("listRecords: %w", err) 172 + } 173 + 174 + posts := make([]postView, 0, len(docs)) 175 + for _, d := range docs { 176 + pv, err := documentToPostView(d, cfg.PublicationURL) 177 + if err != nil { 178 + // Skip malformed records; log and move on. 179 + fmt.Fprintf(os.Stderr, " skipping %s: %v\n", d.URI, err) 180 + continue 181 + } 182 + posts = append(posts, pv) 183 + } 184 + 185 + // publishedAt DESC. 186 + sort.Slice(posts, func(i, j int) bool { 187 + return posts[i].PublishedAt.After(posts[j].PublishedAt) 188 + }) 189 + 190 + // Wire prev/next now that order is final. 191 + // Convention: "prev" = older (further down the list), "next" = newer. 192 + // Index page is sorted newest-first, so posts[i+1] is older than posts[i]. 193 + for i := range posts { 194 + if i+1 < len(posts) { 195 + posts[i].Prev = &postNeighbor{Title: posts[i+1].Title, Path: posts[i+1].Path} 196 + } 197 + if i > 0 { 198 + posts[i].Next = &postNeighbor{Title: posts[i-1].Title, Path: posts[i-1].Path} 199 + } 200 + } 201 + siteData.Posts = posts 202 + 203 + // Step 5: render per-post pages. documentToPostView leaves 204 + // p.Publication.Name empty (it doesn't have access to the 205 + // publication record); fill it in here so post.html's <title> 206 + // and footer link aren't half-rendered. 207 + for _, p := range posts { 208 + p.Publication = siteData.Publication 209 + dir := filepath.Join(tmpOut, strings.TrimPrefix(p.Path, "/")) 210 + if err := os.MkdirAll(dir, 0o755); err != nil { 211 + return nil, fmt.Errorf("mkdir %s: %w", dir, err) 212 + } 213 + out, err := os.Create(filepath.Join(dir, "index.html")) 214 + if err != nil { 215 + return nil, err 216 + } 217 + err = renderTemplate(out, "post.html", p) 218 + out.Close() 219 + if err != nil { 220 + return nil, fmt.Errorf("render post %s: %w", p.Path, err) 221 + } 222 + } 223 + 224 + // Step 6: index, feed, sitemap, well-known, 404. 225 + if err := writeRendered(tmpOut, "index.html", "index.html", siteData); err != nil { 226 + return nil, err 227 + } 228 + if err := writeRendered(tmpOut, "feed.xml", "feed.xml", siteData); err != nil { 229 + return nil, err 230 + } 231 + if err := writeRendered(tmpOut, "sitemap.xml", "sitemap.xml", siteData); err != nil { 232 + return nil, err 233 + } 234 + if err := writeRendered(tmpOut, "404.html", "404.html", siteData); err != nil { 235 + return nil, err 236 + } 237 + if err := writeRendered(tmpOut, ".well-known/site.standard.publication", "well-known-publication.json", siteData); err != nil { 238 + return nil, err 239 + } 240 + if err := writeRendered(tmpOut, "robots.txt", "robots.txt", siteData); err != nil { 241 + return nil, err 242 + } 243 + if err := writeRendered(tmpOut, "rss.xml", "rss.xml", siteData); err != nil { 244 + return nil, err 245 + } 246 + if err := writeJSONFeed(tmpOut, siteData); err != nil { 247 + return nil, err 248 + } 249 + if err := writeIndexJSON(tmpOut, siteData); err != nil { 250 + return nil, err 251 + } 252 + 253 + // Tag pages: one per distinct tag plus a /tags/ index. Tags are 254 + // already canonicalized (lowercased, deduped) by the publish flow. 255 + tagsByName := groupByTag(posts) 256 + if len(tagsByName) > 0 { 257 + // /tags/ index 258 + tagSummaries := make([]tagSummary, 0, len(tagsByName)) 259 + tagNames := make([]string, 0, len(tagsByName)) 260 + for name := range tagsByName { 261 + tagNames = append(tagNames, name) 262 + } 263 + sort.Strings(tagNames) 264 + for _, name := range tagNames { 265 + tagSummaries = append(tagSummaries, tagSummary{Name: name, Count: len(tagsByName[name])}) 266 + } 267 + if err := writeRendered(tmpOut, "tags/index.html", "tags-index.html", struct { 268 + Publication publicationView 269 + Tags []tagSummary 270 + }{siteData.Publication, tagSummaries}); err != nil { 271 + return nil, err 272 + } 273 + // /tags/<name>/index.html 274 + for _, name := range tagNames { 275 + rel := filepath.Join("tags", name, "index.html") 276 + if err := writeRendered(tmpOut, rel, "tag.html", struct { 277 + Publication publicationView 278 + Tag string 279 + Posts []postView 280 + }{siteData.Publication, name, tagsByName[name]}); err != nil { 281 + return nil, err 282 + } 283 + } 284 + } 285 + 286 + // Archive: posts grouped by year, descending. 287 + if len(posts) > 0 { 288 + years := groupByYear(posts) 289 + if err := writeRendered(tmpOut, "archive/index.html", "archive.html", struct { 290 + Publication publicationView 291 + Years []yearSection 292 + }{siteData.Publication, years}); err != nil { 293 + return nil, err 294 + } 295 + } 296 + 297 + // Step 7: copy image mirror. 298 + if cfg.ImagesMirror != "" { 299 + if err := copyImagesMirror(cfg.ImagesMirror, filepath.Join(tmpOut, "images")); err != nil { 300 + return nil, err 301 + } 302 + } 303 + 304 + // Step 8: atomic promote. 305 + if err := AtomicPromote(tmpOut, cfg.DistDir); err != nil { 306 + return nil, err 307 + } 308 + return &Result{ 309 + PostCount: len(posts), 310 + OutDir: cfg.DistDir, 311 + }, nil 312 + } 313 + 314 + // emitColdStart writes a complete-but-empty site so the well-known 315 + // endpoints, feed, sitemap, and index serve correctly even before any 316 + // publication record exists. 317 + func emitColdStart(tmpOut string, siteData siteContext) error { 318 + for _, t := range []struct{ outRel, tmpl string }{ 319 + {"index.html", "index.html"}, 320 + {"feed.xml", "feed.xml"}, 321 + {"sitemap.xml", "sitemap.xml"}, 322 + {"404.html", "404.html"}, 323 + {".well-known/site.standard.publication", "well-known-publication.json"}, 324 + {"robots.txt", "robots.txt"}, 325 + {"rss.xml", "rss.xml"}, 326 + } { 327 + if err := writeRendered(tmpOut, t.outRel, t.tmpl, siteData); err != nil { 328 + return err 329 + } 330 + } 331 + if err := writeJSONFeed(tmpOut, siteData); err != nil { 332 + return err 333 + } 334 + if err := writeIndexJSON(tmpOut, siteData); err != nil { 335 + return err 336 + } 337 + return nil 338 + } 339 + 340 + // writeRendered renders the named template into <tmpOut>/<outRel>. 341 + func writeRendered(tmpOut, outRel, tmplName string, data any) error { 342 + full := filepath.Join(tmpOut, outRel) 343 + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { 344 + return err 345 + } 346 + out, err := os.Create(full) 347 + if err != nil { 348 + return err 349 + } 350 + defer out.Close() 351 + return renderTemplate(out, tmplName, data) 352 + } 353 + 354 + // renderTemplate parses templates/<name> from the embedded FS and writes 355 + // the rendered bytes to w. HTML templates use html/template (auto- 356 + // escaping), XML/JSON templates use text/template (verbatim) so the 357 + // "<?xml ?>" declaration and JSON braces survive intact. 358 + func renderTemplate(w io.Writer, name string, data any) error { 359 + src, err := fs.ReadFile(templatesFS, "templates/"+name) 360 + if err != nil { 361 + return fmt.Errorf("read template %s: %w", name, err) 362 + } 363 + if isHTMLTemplate(name) { 364 + t, err := htmltemplate.New(name).Parse(string(src)) 365 + if err != nil { 366 + return fmt.Errorf("parse html template %s: %w", name, err) 367 + } 368 + return t.Execute(w, data) 369 + } 370 + t, err := texttemplate.New(name).Parse(string(src)) 371 + if err != nil { 372 + return fmt.Errorf("parse text template %s: %w", name, err) 373 + } 374 + return t.Execute(w, data) 375 + } 376 + 377 + // isHTMLTemplate returns true for files whose output should be auto- 378 + // escaped HTML. Anything else (XML, JSON) goes through text/template 379 + // verbatim. 380 + func isHTMLTemplate(name string) bool { 381 + return strings.HasSuffix(name, ".html") 382 + } 383 + 384 + // coalesce returns the first non-empty string of its args. 385 + func coalesce(values ...string) string { 386 + for _, v := range values { 387 + if v != "" { 388 + return v 389 + } 390 + } 391 + return "" 392 + } 393 + 394 + // caTransport returns an http.Transport pinned to the given pool. 395 + func caTransport(pool *x509.CertPool) http.RoundTripper { 396 + t := http.DefaultTransport.(*http.Transport).Clone() 397 + t.TLSClientConfig = httpsClientConfig(pool) 398 + return t 399 + } 400 + 401 + // --- view types ------------------------------------------------------------- 402 + 403 + type publicationView struct { 404 + URL string 405 + Name string 406 + Description string 407 + SocialLinks []string // <link rel="me"> entries 408 + FaviconPath string // e.g. "/favicon.png"; empty when no icon blob 409 + } 410 + 411 + type postNeighbor struct { 412 + Title string 413 + Path string 414 + } 415 + 416 + type postView struct { 417 + Title string 418 + Path string // leading slash, no trailing slash 419 + AbsoluteURL string // publication.URL + path + "/" 420 + ATURI htmltemplate.URL // at://did/site.standard.document/<rkey> (marked safe so html/template doesn't rewrite to #ZgotmplZ) 421 + PublishedAt time.Time 422 + UpdatedAt time.Time 423 + Lastmod time.Time // = UpdatedAt or PublishedAt; precomputed for sitemap.xml 424 + Description string 425 + HTMLBody htmltemplate.HTML 426 + Tags []string 427 + BskyPostURL string // bsky.app web URL derived from bskyPostRef 428 + Prev *postNeighbor 429 + Next *postNeighbor 430 + Publication publicationView // copied so post template can reach it 431 + } 432 + 433 + type siteContext struct { 434 + Publication publicationView 435 + Posts []postView 436 + GeneratedAt time.Time 437 + } 438 + 439 + // --- record fetching -------------------------------------------------------- 440 + 441 + type rawDocument struct { 442 + URI string `json:"uri"` 443 + CID string `json:"cid"` 444 + Value json.RawMessage `json:"value"` 445 + } 446 + 447 + type listRecordsResponse struct { 448 + Records []rawDocument `json:"records"` 449 + Cursor string `json:"cursor,omitempty"` 450 + } 451 + 452 + // fetchPublication returns the publication record for did, or nil when 453 + // the record is absent (404 from getRecord). Other errors are returned 454 + // as-is. 455 + func fetchPublication(ctx context.Context, hc *http.Client, pdsURL, did string) (*publicationRecord, error) { 456 + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=site.standard.publication&rkey=self", 457 + strings.TrimRight(pdsURL, "/"), did) 458 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 459 + if err != nil { 460 + return nil, err 461 + } 462 + resp, err := hc.Do(req) 463 + if err != nil { 464 + return nil, err 465 + } 466 + defer resp.Body.Close() 467 + 468 + if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound { 469 + // PDS returns 400 with InvalidRequest on a missing record. 470 + return nil, nil 471 + } 472 + if resp.StatusCode != http.StatusOK { 473 + return nil, fmt.Errorf("getRecord status %d", resp.StatusCode) 474 + } 475 + var out struct { 476 + Value publicationRecord `json:"value"` 477 + } 478 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 479 + return nil, fmt.Errorf("decode publication: %w", err) 480 + } 481 + return &out.Value, nil 482 + } 483 + 484 + type publicationRecord struct { 485 + URL string `json:"url"` 486 + Name string `json:"name"` 487 + Description string `json:"description,omitempty"` 488 + Icon *blobRef `json:"icon,omitempty"` 489 + } 490 + 491 + // blobRef is the JSON shape of an ATProto typed blob. We only use it 492 + // to extract the CID + mimeType for fetching the icon via getBlob. 493 + type blobRef struct { 494 + Type string `json:"$type"` 495 + Ref struct { 496 + Link string `json:"$link"` 497 + } `json:"ref"` 498 + MimeType string `json:"mimeType"` 499 + Size int64 `json:"size"` 500 + } 501 + 502 + // listAllDocuments paginates through site.standard.document records. 503 + func listAllDocuments(ctx context.Context, hc *http.Client, pdsURL, did string) ([]rawDocument, error) { 504 + var all []rawDocument 505 + cursor := "" 506 + for { 507 + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=site.standard.document&limit=100", 508 + strings.TrimRight(pdsURL, "/"), did) 509 + if cursor != "" { 510 + u += "&cursor=" + cursor 511 + } 512 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 513 + if err != nil { 514 + return nil, err 515 + } 516 + resp, err := hc.Do(req) 517 + if err != nil { 518 + return nil, err 519 + } 520 + body, _ := io.ReadAll(resp.Body) 521 + resp.Body.Close() 522 + if resp.StatusCode != http.StatusOK { 523 + return nil, fmt.Errorf("listRecords status %d: %s", resp.StatusCode, string(body)) 524 + } 525 + var page listRecordsResponse 526 + if err := json.Unmarshal(body, &page); err != nil { 527 + return nil, fmt.Errorf("decode listRecords: %w", err) 528 + } 529 + all = append(all, page.Records...) 530 + if page.Cursor == "" || len(page.Records) == 0 { 531 + return all, nil 532 + } 533 + cursor = page.Cursor 534 + } 535 + } 536 + 537 + // --- conversion ------------------------------------------------------------- 538 + 539 + type rawDocumentValue struct { 540 + Title string `json:"title"` 541 + Path string `json:"path"` 542 + PublishedAt string `json:"publishedAt"` 543 + UpdatedAt string `json:"updatedAt,omitempty"` 544 + Description string `json:"description,omitempty"` 545 + Tags []string `json:"tags,omitempty"` 546 + Content struct { 547 + Type string `json:"$type"` 548 + Text string `json:"text"` 549 + Version string `json:"version,omitempty"` 550 + } `json:"content"` 551 + BskyPostRef *struct { 552 + URI string `json:"uri"` 553 + } `json:"bskyPostRef,omitempty"` 554 + } 555 + 556 + func documentToPostView(d rawDocument, publicationURL string) (postView, error) { 557 + var v rawDocumentValue 558 + if err := json.Unmarshal(d.Value, &v); err != nil { 559 + return postView{}, fmt.Errorf("decode value: %w", err) 560 + } 561 + if v.Title == "" || v.Path == "" || v.PublishedAt == "" || v.Content.Text == "" { 562 + return postView{}, errors.New("missing required field (title/path/publishedAt/content.text)") 563 + } 564 + 565 + publishedAt, err := time.Parse(time.RFC3339, v.PublishedAt) 566 + if err != nil { 567 + return postView{}, fmt.Errorf("parse publishedAt: %w", err) 568 + } 569 + var updatedAt time.Time 570 + if v.UpdatedAt != "" { 571 + if t, e := time.Parse(time.RFC3339, v.UpdatedAt); e == nil { 572 + updatedAt = t 573 + } 574 + } 575 + html, err := RenderMarkdown(v.Content.Text, publicationURL) 576 + if err != nil { 577 + return postView{}, err 578 + } 579 + 580 + lastmod := publishedAt 581 + if !updatedAt.IsZero() { 582 + lastmod = updatedAt 583 + } 584 + 585 + pub := publicationView{ 586 + URL: strings.TrimRight(publicationURL, "/"), 587 + Name: "", // populated in Build's pass via field assignment 588 + } 589 + 590 + path := strings.TrimRight(v.Path, "/") 591 + absoluteURL := pub.URL + path + "/" 592 + 593 + // Extract rkey from the at-uri tail for the ATProto-canonical 594 + // alternate link. d.URI looks like 595 + // "at://did:plc:.../site.standard.document/<rkey>". 596 + atURI := d.URI 597 + 598 + bskyPostURL := "" 599 + if v.BskyPostRef != nil && v.BskyPostRef.URI != "" { 600 + if w, err := bskyWebURL(v.BskyPostRef.URI); err == nil { 601 + bskyPostURL = w 602 + } 603 + } 604 + 605 + return postView{ 606 + Title: v.Title, 607 + Path: path, 608 + AbsoluteURL: absoluteURL, 609 + ATURI: htmltemplate.URL(atURI), 610 + PublishedAt: publishedAt, 611 + UpdatedAt: updatedAt, 612 + Lastmod: lastmod, 613 + Description: v.Description, 614 + HTMLBody: htmltemplate.HTML(html), 615 + Tags: v.Tags, 616 + BskyPostURL: bskyPostURL, 617 + Publication: pub, 618 + }, nil 619 + } 620 + 621 + // --- helpers ---------------------------------------------------------------- 622 + 623 + // copyImagesMirror walks src and copies each file to dest, preserving 624 + // the relative path. Idempotent on identical files. 625 + func copyImagesMirror(src, dest string) error { 626 + if _, err := os.Stat(src); errors.Is(err, fs.ErrNotExist) { 627 + return nil // nothing to copy 628 + } 629 + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { 630 + if err != nil { 631 + return err 632 + } 633 + rel, err := filepath.Rel(src, p) 634 + if err != nil { 635 + return err 636 + } 637 + target := filepath.Join(dest, rel) 638 + if d.IsDir() { 639 + return os.MkdirAll(target, 0o755) 640 + } 641 + data, err := os.ReadFile(p) 642 + if err != nil { 643 + return err 644 + } 645 + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 646 + return err 647 + } 648 + return os.WriteFile(target, data, 0o644) 649 + }) 650 + } 651 + 652 + // _ keeps markdown imported for the future render pipeline if we move 653 + // transforms here. 654 + var _ = markdown.Normalize
+72
internal/build/favicon.go
··· 1 + package build 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "mime" 9 + "net/http" 10 + "os" 11 + "path/filepath" 12 + "strings" 13 + ) 14 + 15 + // fetchAndSaveFavicon pulls the publication's icon blob from the PDS 16 + // and writes it to <tmpOut>/favicon.<ext>. Returns the public path 17 + // ("/favicon.png") suitable for <link rel="icon"> hrefs. 18 + // 19 + // The icon is the publication record's `icon` typed-blob field. 20 + // Resolution: com.atproto.sync.getBlob?did=<did>&cid=<cid>. 21 + // 22 + // Failures here are non-fatal — the build flow logs and continues 23 + // without a favicon. 24 + func fetchAndSaveFavicon(ctx context.Context, hc *http.Client, cfg Config, icon *blobRef, tmpOut string) (string, error) { 25 + if icon == nil || icon.Ref.Link == "" { 26 + return "", errors.New("icon ref empty") 27 + } 28 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 29 + strings.TrimRight(cfg.PDSURL, "/"), cfg.DID, icon.Ref.Link) 30 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 31 + if err != nil { 32 + return "", err 33 + } 34 + resp, err := hc.Do(req) 35 + if err != nil { 36 + return "", err 37 + } 38 + defer resp.Body.Close() 39 + if resp.StatusCode != http.StatusOK { 40 + return "", fmt.Errorf("getBlob status %d", resp.StatusCode) 41 + } 42 + 43 + ext := extensionForMime(icon.MimeType) 44 + if ext == "" { 45 + ext = ".png" // safe default 46 + } 47 + dest := filepath.Join(tmpOut, "favicon"+ext) 48 + out, err := os.Create(dest) 49 + if err != nil { 50 + return "", err 51 + } 52 + defer out.Close() 53 + if _, err := io.Copy(out, resp.Body); err != nil { 54 + return "", err 55 + } 56 + return "/favicon" + ext, nil 57 + } 58 + 59 + // extensionForMime maps an image MIME type to a conventional file 60 + // extension. Handles the common cases; falls back to "" for callers 61 + // to substitute a default. 62 + func extensionForMime(mt string) string { 63 + exts, _ := mime.ExtensionsByType(mt) 64 + for _, e := range exts { 65 + // Prefer canonical short forms (ExtensionsByType returns several). 66 + switch e { 67 + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico": 68 + return e 69 + } 70 + } 71 + return "" 72 + }
+61
internal/build/groups.go
··· 1 + package build 2 + 3 + import ( 4 + "sort" 5 + ) 6 + 7 + // tagSummary is the per-tag entry rendered on /tags/index.html. 8 + type tagSummary struct { 9 + Name string 10 + Count int 11 + } 12 + 13 + // yearSection is one year's worth of posts, used for /archive/index.html. 14 + type yearSection struct { 15 + Year int 16 + Posts []postView 17 + } 18 + 19 + // groupByTag bucketizes posts by tag. Each post appears under each of 20 + // its tags. The returned map's values are pre-sorted publishedAt DESC. 21 + func groupByTag(posts []postView) map[string][]postView { 22 + out := map[string][]postView{} 23 + for _, p := range posts { 24 + for _, tag := range p.Tags { 25 + out[tag] = append(out[tag], p) 26 + } 27 + } 28 + // Posts inside each bucket: sort newest first. 29 + for name, ps := range out { 30 + sort.Slice(ps, func(i, j int) bool { 31 + return ps[i].PublishedAt.After(ps[j].PublishedAt) 32 + }) 33 + out[name] = ps 34 + } 35 + return out 36 + } 37 + 38 + // groupByYear bucketizes posts by publishedAt year, returning years in 39 + // descending order. 40 + func groupByYear(posts []postView) []yearSection { 41 + byYear := map[int][]postView{} 42 + for _, p := range posts { 43 + y := p.PublishedAt.Year() 44 + byYear[y] = append(byYear[y], p) 45 + } 46 + years := make([]int, 0, len(byYear)) 47 + for y := range byYear { 48 + years = append(years, y) 49 + } 50 + sort.Sort(sort.Reverse(sort.IntSlice(years))) 51 + 52 + out := make([]yearSection, 0, len(years)) 53 + for _, y := range years { 54 + ps := byYear[y] 55 + sort.Slice(ps, func(i, j int) bool { 56 + return ps[i].PublishedAt.After(ps[j].PublishedAt) 57 + }) 58 + out = append(out, yearSection{Year: y, Posts: ps}) 59 + } 60 + return out 61 + }
+110
internal/build/jsonfeed.go
··· 1 + package build 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + ) 10 + 11 + // jsonFeed implements the JSON Feed v1.1 spec 12 + // (https://www.jsonfeed.org/version/1.1/). Marshaled directly via 13 + // encoding/json instead of a template so escaping is bulletproof. 14 + type jsonFeed struct { 15 + Version string `json:"version"` 16 + Title string `json:"title"` 17 + HomePageURL string `json:"home_page_url"` 18 + FeedURL string `json:"feed_url"` 19 + Description string `json:"description,omitempty"` 20 + Items []jsonFeedItem `json:"items"` 21 + } 22 + 23 + type jsonFeedItem struct { 24 + ID string `json:"id"` 25 + URL string `json:"url"` 26 + Title string `json:"title"` 27 + ContentHTML string `json:"content_html,omitempty"` 28 + Summary string `json:"summary,omitempty"` 29 + DatePublished time.Time `json:"date_published"` 30 + DateModified *time.Time `json:"date_modified,omitempty"` 31 + Tags []string `json:"tags,omitempty"` 32 + ExternalURL string `json:"external_url,omitempty"` // bsky discussion link 33 + } 34 + 35 + // writeJSONFeed emits feed.json into tmpOut. 36 + func writeJSONFeed(tmpOut string, site siteContext) error { 37 + feed := jsonFeed{ 38 + Version: "https://jsonfeed.org/version/1.1", 39 + Title: site.Publication.Name, 40 + HomePageURL: site.Publication.URL + "/", 41 + FeedURL: site.Publication.URL + "/feed.json", 42 + Description: site.Publication.Description, 43 + Items: make([]jsonFeedItem, 0, len(site.Posts)), 44 + } 45 + for _, p := range site.Posts { 46 + item := jsonFeedItem{ 47 + ID: p.AbsoluteURL, 48 + URL: p.AbsoluteURL, 49 + Title: p.Title, 50 + ContentHTML: string(p.HTMLBody), 51 + Summary: p.Description, 52 + DatePublished: p.PublishedAt, 53 + Tags: p.Tags, 54 + ExternalURL: p.BskyPostURL, 55 + } 56 + if !p.UpdatedAt.IsZero() { 57 + u := p.UpdatedAt 58 + item.DateModified = &u 59 + } 60 + feed.Items = append(feed.Items, item) 61 + } 62 + data, err := json.MarshalIndent(feed, "", " ") 63 + if err != nil { 64 + return fmt.Errorf("marshal json feed: %w", err) 65 + } 66 + data = append(data, '\n') 67 + return os.WriteFile(filepath.Join(tmpOut, "feed.json"), data, 0o644) 68 + } 69 + 70 + // indexJSONItem is the trimmed-down per-post entry rendered into 71 + // index.json (no body, just metadata) for programmatic consumption. 72 + type indexJSONItem struct { 73 + Title string `json:"title"` 74 + URL string `json:"url"` 75 + Path string `json:"path"` 76 + ATURI string `json:"at_uri"` 77 + PublishedAt time.Time `json:"published_at"` 78 + UpdatedAt *time.Time `json:"updated_at,omitempty"` 79 + Description string `json:"description,omitempty"` 80 + Tags []string `json:"tags,omitempty"` 81 + } 82 + 83 + // writeIndexJSON emits a JSON listing of all posts (metadata only) 84 + // at /index.json. Useful for anyone who wants to scrape the site 85 + // programmatically without parsing HTML. 86 + func writeIndexJSON(tmpOut string, site siteContext) error { 87 + items := make([]indexJSONItem, 0, len(site.Posts)) 88 + for _, p := range site.Posts { 89 + entry := indexJSONItem{ 90 + Title: p.Title, 91 + URL: p.AbsoluteURL, 92 + Path: p.Path + "/", 93 + ATURI: string(p.ATURI), 94 + PublishedAt: p.PublishedAt, 95 + Description: p.Description, 96 + Tags: p.Tags, 97 + } 98 + if !p.UpdatedAt.IsZero() { 99 + u := p.UpdatedAt 100 + entry.UpdatedAt = &u 101 + } 102 + items = append(items, entry) 103 + } 104 + data, err := json.MarshalIndent(items, "", " ") 105 + if err != nil { 106 + return fmt.Errorf("marshal index json: %w", err) 107 + } 108 + data = append(data, '\n') 109 + return os.WriteFile(filepath.Join(tmpOut, "index.json"), data, 0o644) 110 + }
+161
internal/build/render.go
··· 1 + package build 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "net/url" 7 + "regexp" 8 + "strings" 9 + 10 + "aparker.io/fair/internal/markdown" 11 + ) 12 + 13 + // RenderMarkdown turns a markdown body (as stored in the PDS record's 14 + // content.text field) into the HTML chunk embedded in post.html. 15 + // 16 + // Inline image URLs in the body are stored relative to the publication 17 + // (e.g., "/images/<slug>/foo.png"); we rewrite them to absolute URLs 18 + // against publicationURL so the rendered HTML works the same on disk 19 + // and on the live origin. 20 + // 21 + // Bluesky blockquotes (<blockquote data-bluesky-uri="...">) are 22 + // detected and rewritten into a no-JS-friendly form: a quote block 23 + // followed by a <footer> with a "Discuss on Bluesky" link. Pure text 24 + // transformation; this function performs zero network I/O. 25 + func RenderMarkdown(body, publicationURL string) (string, error) { 26 + body = markdown.Normalize(body) 27 + 28 + // Goldmark converts to HTML; we then post-process the output to 29 + // resolve relative image URLs. Doing it on the markdown source 30 + // would also work but the HTML pass is simpler. 31 + var buf bytes.Buffer 32 + if err := markdown.Parser().Convert([]byte(body), &buf); err != nil { 33 + return "", fmt.Errorf("render markdown: %w", err) 34 + } 35 + html := buf.String() 36 + 37 + html, err := resolveRelativeURLs(html, publicationURL) 38 + if err != nil { 39 + return "", err 40 + } 41 + html = transformBlueskyEmbeds(html) 42 + html = wrapStandaloneImagesAsFigures(html) 43 + return html, nil 44 + } 45 + 46 + // resolveRelativeURLs joins publicationURL with any href/src that 47 + // starts with "/" (relative to site root). Already-absolute URLs are 48 + // untouched. Anchors (#frag) and other relative forms are also left 49 + // alone for now — the convention is that internal links use absolute 50 + // site-relative paths starting with "/". 51 + func resolveRelativeURLs(html, publicationURL string) (string, error) { 52 + if publicationURL == "" { 53 + return "", fmt.Errorf("resolveRelativeURLs: publicationURL is empty") 54 + } 55 + pub, err := url.Parse(strings.TrimRight(publicationURL, "/")) 56 + if err != nil { 57 + return "", err 58 + } 59 + // Match src="/..." and href="/..." (the leading / disambiguates 60 + // from anchors and protocol-relative URLs). 61 + re := regexp.MustCompile(`(src|href)="(/[^"]*)"`) 62 + return re.ReplaceAllStringFunc(html, func(match string) string { 63 + parts := re.FindStringSubmatch(match) 64 + if len(parts) != 3 { 65 + return match 66 + } 67 + attr := parts[1] 68 + path := parts[2] 69 + return fmt.Sprintf(`%s="%s%s"`, attr, pub.String(), path) 70 + }), nil 71 + } 72 + 73 + // blueskyBlockquoteRe matches blockquotes carrying our convention 74 + // data-bluesky-uri="at://..." marker. Everything inside the quote is 75 + // captured verbatim. 76 + var blueskyBlockquoteRe = regexp.MustCompile(`(?s)<blockquote data-bluesky-uri="(at://[^"]+)"[^>]*>(.*?)</blockquote>`) 77 + 78 + // transformBlueskyEmbeds rewrites <blockquote data-bluesky-uri="..."> 79 + // blocks into a plain blockquote followed by a "Discuss on Bluesky" 80 + // link, so the rendered output works without JavaScript. 81 + // 82 + // Bluesky URIs are at-uris (at://did:plc:.../app.bsky.feed.post/rkey). 83 + // The link target is the canonical bsky.app web URL derived from the 84 + // at-uri components. 85 + func transformBlueskyEmbeds(html string) string { 86 + return blueskyBlockquoteRe.ReplaceAllStringFunc(html, func(match string) string { 87 + parts := blueskyBlockquoteRe.FindStringSubmatch(match) 88 + if len(parts) != 3 { 89 + return match 90 + } 91 + atURI := parts[1] 92 + inner := parts[2] 93 + webURL, err := bskyWebURL(atURI) 94 + if err != nil { 95 + // On parse failure, leave the at-uri as the link target. 96 + webURL = atURI 97 + } 98 + return fmt.Sprintf(`<blockquote>%s<footer>— <a href="%s">Discuss on Bluesky</a></footer></blockquote>`, 99 + inner, webURL) 100 + }) 101 + } 102 + 103 + // standaloneImageParaRe matches a paragraph that contains exactly one 104 + // <img> tag and nothing else (whitespace tolerated). Goldmark wraps 105 + // standalone images in <p>; we promote those to <figure> + <figcaption> 106 + // so the rendered output gets semantic figure markup without changing 107 + // the goldmark parser config (which would invalidate stored hashes). 108 + var standaloneImageParaRe = regexp.MustCompile(`(?s)<p>\s*(<img\b[^>]*>)\s*</p>`) 109 + 110 + // imgAltRe extracts the alt attribute (single- or double-quoted) from 111 + // an <img ...> tag. Match group 2 is the alt text. 112 + var imgAltRe = regexp.MustCompile(`alt=("([^"]*)"|'([^']*)')`) 113 + 114 + // wrapStandaloneImagesAsFigures rewrites <p><img></p> blocks into 115 + // <figure><img><figcaption>alt</figcaption></figure>. Inline images 116 + // (e.g., "see <img> here") are left alone — only paragraphs whose only 117 + // non-whitespace content is one <img> get promoted. 118 + // 119 + // If the alt attribute is empty or absent, no <figcaption> is emitted 120 + // (an empty caption looks worse than none). 121 + func wrapStandaloneImagesAsFigures(html string) string { 122 + return standaloneImageParaRe.ReplaceAllStringFunc(html, func(match string) string { 123 + groups := standaloneImageParaRe.FindStringSubmatch(match) 124 + if len(groups) < 2 { 125 + return match 126 + } 127 + imgTag := groups[1] 128 + alt := extractAlt(imgTag) 129 + if alt == "" { 130 + return "<figure>" + imgTag + "</figure>" 131 + } 132 + return "<figure>" + imgTag + "<figcaption>" + alt + "</figcaption></figure>" 133 + }) 134 + } 135 + 136 + // extractAlt returns the alt attribute text from an <img> tag, or "" 137 + // when absent. Handles both ' and " quoting. 138 + func extractAlt(imgTag string) string { 139 + m := imgAltRe.FindStringSubmatch(imgTag) 140 + if len(m) == 0 { 141 + return "" 142 + } 143 + if m[2] != "" { 144 + return m[2] 145 + } 146 + return m[3] 147 + } 148 + 149 + // bskyWebURL converts at://did:plc:abc/app.bsky.feed.post/xyz into 150 + // https://bsky.app/profile/did:plc:abc/post/xyz. 151 + func bskyWebURL(atURI string) (string, error) { 152 + const prefix = "at://" 153 + if !strings.HasPrefix(atURI, prefix) { 154 + return "", fmt.Errorf("not an at-uri: %s", atURI) 155 + } 156 + parts := strings.Split(atURI[len(prefix):], "/") 157 + if len(parts) != 3 { 158 + return "", fmt.Errorf("malformed at-uri %s", atURI) 159 + } 160 + return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", parts[0], parts[2]), nil 161 + }
+125
internal/build/render_test.go
··· 1 + package build_test 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/build" 8 + ) 9 + 10 + func TestRenderMarkdown_BasicHTML(t *testing.T) { 11 + got, err := build.RenderMarkdown("# Hello\n\nWorld.\n", "https://aparker.io") 12 + if err != nil { 13 + t.Fatal(err) 14 + } 15 + for _, want := range []string{"<h1", "Hello", "<p>", "World"} { 16 + if !strings.Contains(got, want) { 17 + t.Errorf("missing %q in:\n%s", want, got) 18 + } 19 + } 20 + } 21 + 22 + func TestRenderMarkdown_ResolvesRelativeImageURL(t *testing.T) { 23 + body := "![alt](/images/post/foo.png)\n" 24 + got, err := build.RenderMarkdown(body, "https://aparker.io") 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + if !strings.Contains(got, `src="https://aparker.io/images/post/foo.png"`) { 29 + t.Errorf("relative URL not resolved:\n%s", got) 30 + } 31 + } 32 + 33 + func TestRenderMarkdown_LeavesAbsoluteURLAlone(t *testing.T) { 34 + body := "![alt](https://example.com/foo.png)\n" 35 + got, err := build.RenderMarkdown(body, "https://aparker.io") 36 + if err != nil { 37 + t.Fatal(err) 38 + } 39 + if !strings.Contains(got, "https://example.com/foo.png") { 40 + t.Errorf("absolute URL changed:\n%s", got) 41 + } 42 + if strings.Contains(got, "aparker.io/foo.png") { 43 + t.Errorf("absolute URL prefixed with publication:\n%s", got) 44 + } 45 + } 46 + 47 + func TestRenderMarkdown_BlueskyEmbedToFooterLink(t *testing.T) { 48 + body := `<blockquote data-bluesky-uri="at://did:plc:abc/app.bsky.feed.post/xyz">a quote</blockquote>` + "\n" 49 + got, err := build.RenderMarkdown(body, "https://aparker.io") 50 + if err != nil { 51 + t.Fatal(err) 52 + } 53 + if strings.Contains(got, "data-bluesky-uri") { 54 + t.Errorf("data-bluesky-uri should be transformed away:\n%s", got) 55 + } 56 + if !strings.Contains(got, "https://bsky.app/profile/did:plc:abc/post/xyz") { 57 + t.Errorf("bsky web URL missing:\n%s", got) 58 + } 59 + if !strings.Contains(got, "Discuss on Bluesky") { 60 + t.Errorf("footer link missing:\n%s", got) 61 + } 62 + } 63 + 64 + func TestRenderMarkdown_StandaloneImageBecomesFigure(t *testing.T) { 65 + body := "![cover image](/images/post/foo.png)\n" 66 + got, err := build.RenderMarkdown(body, "https://aparker.io") 67 + if err != nil { 68 + t.Fatal(err) 69 + } 70 + if !strings.Contains(got, "<figure>") { 71 + t.Errorf("standalone image not wrapped in figure:\n%s", got) 72 + } 73 + if !strings.Contains(got, "<figcaption>cover image</figcaption>") { 74 + t.Errorf("alt text not used as figcaption:\n%s", got) 75 + } 76 + } 77 + 78 + func TestRenderMarkdown_StandaloneImageWithoutAlt_NoFigcaption(t *testing.T) { 79 + body := "![](/images/post/foo.png)\n" 80 + got, err := build.RenderMarkdown(body, "https://aparker.io") 81 + if err != nil { 82 + t.Fatal(err) 83 + } 84 + if !strings.Contains(got, "<figure>") { 85 + t.Errorf("expected figure wrap, got:\n%s", got) 86 + } 87 + if strings.Contains(got, "<figcaption>") { 88 + t.Errorf("empty alt should not produce figcaption:\n%s", got) 89 + } 90 + } 91 + 92 + func TestRenderMarkdown_InlineImageNotWrappedInFigure(t *testing.T) { 93 + // Image embedded in a sentence — not a standalone paragraph. 94 + body := "See ![inline](/images/post/foo.png) here.\n" 95 + got, err := build.RenderMarkdown(body, "https://aparker.io") 96 + if err != nil { 97 + t.Fatal(err) 98 + } 99 + if strings.Contains(got, "<figure>") { 100 + t.Errorf("inline image should NOT be wrapped:\n%s", got) 101 + } 102 + } 103 + 104 + func TestRenderMarkdown_DetailsSummaryPassesThrough(t *testing.T) { 105 + body := "<details><summary>Click to expand</summary>\n\nHidden content here.\n\n</details>\n" 106 + got, err := build.RenderMarkdown(body, "https://aparker.io") 107 + if err != nil { 108 + t.Fatal(err) 109 + } 110 + for _, want := range []string{"<details>", "<summary>Click to expand</summary>", "Hidden content"} { 111 + if !strings.Contains(got, want) { 112 + t.Errorf("missing %q in:\n%s", want, got) 113 + } 114 + } 115 + } 116 + 117 + func TestRenderMarkdown_PerformsZeroNetworkIO(t *testing.T) { 118 + // Sanity assertion via the bsky transform doing pure URL parsing. 119 + // If we ever wire goldmark extensions that fetch (oembed, etc.), 120 + // this comment + a CI net-block flag should catch it. 121 + body := `<blockquote data-bluesky-uri="at://did:plc:abc/app.bsky.feed.post/xyz">x</blockquote>` 122 + if _, err := build.RenderMarkdown(body, "https://aparker.io"); err != nil { 123 + t.Fatal(err) 124 + } 125 + }
+8
internal/build/templates/404.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head><meta charset="utf-8"><title>Not found — {{.Publication.Name}}</title></head> 4 + <body> 5 + <h1>Not found.</h1> 6 + <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p> 7 + </body> 8 + </html>
+27
internal/build/templates/archive.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Archive — {{.Publication.Name}}</title> 6 + <link rel="canonical" href="{{.Publication.URL}}/archive/"> 7 + 8 + {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 9 + {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} 10 + </head> 11 + <body> 12 + <header> 13 + <h1>Archive</h1> 14 + <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p> 15 + </header> 16 + {{if .Years}} 17 + {{range .Years}}<section> 18 + <h2>{{.Year}}</h2> 19 + <ul> 20 + {{range .Posts}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 21 + {{end}}</ul> 22 + </section> 23 + {{end}}{{else}} 24 + <p>No posts yet.</p> 25 + {{end}} 26 + </body> 27 + </html>
+17
internal/build/templates/feed.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <feed xmlns="http://www.w3.org/2005/Atom"> 3 + <title>{{.Publication.Name}}</title> 4 + <link href="{{.Publication.URL}}/"/> 5 + <link rel="self" href="{{.Publication.URL}}/feed.xml"/> 6 + <id>{{.Publication.URL}}/</id> 7 + <updated>{{.GeneratedAt.Format "2006-01-02T15:04:05Z07:00"}}</updated> 8 + {{range .Posts}}<entry> 9 + <title>{{.Title}}</title> 10 + <link href="{{$.Publication.URL}}{{.Path}}/"/> 11 + <id>{{$.Publication.URL}}{{.Path}}/</id> 12 + <published>{{.PublishedAt.Format "2006-01-02T15:04:05Z07:00"}}</published> 13 + {{if not .UpdatedAt.IsZero}}<updated>{{.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}</updated>{{end}} 14 + {{if .Description}}<summary>{{.Description}}</summary>{{end}} 15 + <content type="html"><![CDATA[{{.HTMLBody}}]]></content> 16 + </entry> 17 + {{end}}</feed>
+41
internal/build/templates/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>{{.Publication.Name}}</title> 6 + {{if .Publication.Description}}<meta name="description" content="{{.Publication.Description}}">{{end}} 7 + <link rel="canonical" href="{{.Publication.URL}}/"> 8 + 9 + <meta property="og:title" content="{{.Publication.Name}}"> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:url" content="{{.Publication.URL}}/"> 12 + <meta property="og:site_name" content="{{.Publication.Name}}"> 13 + {{if .Publication.Description}}<meta property="og:description" content="{{.Publication.Description}}">{{end}} 14 + <meta name="twitter:card" content="summary"> 15 + <meta name="twitter:title" content="{{.Publication.Name}}"> 16 + {{if .Publication.Description}}<meta name="twitter:description" content="{{.Publication.Description}}">{{end}} 17 + 18 + {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 19 + {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} 20 + 21 + <link rel="alternate" type="application/atom+xml" title="{{.Publication.Name}}" href="{{.Publication.URL}}/feed.xml"> 22 + <link rel="alternate" type="application/feed+json" title="{{.Publication.Name}}" href="{{.Publication.URL}}/feed.json"> 23 + <link rel="alternate" type="application/rss+xml" title="{{.Publication.Name}}" href="{{.Publication.URL}}/rss.xml"> 24 + </head> 25 + <body> 26 + <header> 27 + <h1>{{.Publication.Name}}</h1> 28 + {{if .Publication.Description}}<p>{{.Publication.Description}}</p>{{end}} 29 + </header> 30 + {{if .Posts}} 31 + <ul> 32 + {{range .Posts}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2 2006"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 33 + {{end}}</ul> 34 + {{else}} 35 + <p>No posts yet.</p> 36 + {{end}} 37 + <footer> 38 + <p><a href="{{.Publication.URL}}/feed.xml">Atom</a> &middot; <a href="{{.Publication.URL}}/feed.json">JSON Feed</a> &middot; <a href="{{.Publication.URL}}/rss.xml">RSS</a></p> 39 + </footer> 40 + </body> 41 + </html>
+50
internal/build/templates/post.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>{{.Title}} — {{.Publication.Name}}</title> 6 + {{if .Description}}<meta name="description" content="{{.Description}}">{{end}} 7 + <link rel="canonical" href="{{.AbsoluteURL}}"> 8 + 9 + <meta property="og:title" content="{{.Title}}"> 10 + <meta property="og:type" content="article"> 11 + <meta property="og:url" content="{{.AbsoluteURL}}"> 12 + <meta property="og:site_name" content="{{.Publication.Name}}"> 13 + {{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}} 14 + <meta name="twitter:card" content="summary"> 15 + <meta name="twitter:title" content="{{.Title}}"> 16 + {{if .Description}}<meta name="twitter:description" content="{{.Description}}">{{end}} 17 + 18 + <meta name="article:published_time" content="{{.PublishedAt.Format "2006-01-02T15:04:05Z07:00"}}"> 19 + {{if not .UpdatedAt.IsZero}}<meta name="article:modified_time" content="{{.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{end}} 20 + {{range .Tags}}<meta name="article:tag" content="{{.}}"> 21 + {{end}} 22 + 23 + {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 24 + {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} 25 + 26 + <link rel="alternate" type="application/atom+xml" title="{{.Publication.Name}}" href="{{.Publication.URL}}/feed.xml"> 27 + <link rel="alternate" type="application/feed+json" title="{{.Publication.Name}}" href="{{.Publication.URL}}/feed.json"> 28 + <link rel="alternate" type="application/rss+xml" title="{{.Publication.Name}}" href="{{.Publication.URL}}/rss.xml"> 29 + <link rel="alternate" type="application/atproto+json" href="{{.ATURI}}"> 30 + {{if .Prev}}<link rel="prev" href="{{.Prev.Path}}/" title="{{.Prev.Title}}">{{end}} 31 + {{if .Next}}<link rel="next" href="{{.Next.Path}}/" title="{{.Next.Title}}">{{end}} 32 + </head> 33 + <body> 34 + <article> 35 + <header> 36 + <h1>{{.Title}}</h1> 37 + <p><time datetime="{{.PublishedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.PublishedAt.Format "January 2, 2006"}}</time>{{if not .UpdatedAt.IsZero}} (updated {{.UpdatedAt.Format "January 2, 2006"}}){{end}}{{if .Tags}} &middot; {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$.Publication.URL}}/tags/{{$t}}/">{{$t}}</a>{{end}}{{end}}</p> 38 + </header> 39 + {{.HTMLBody}} 40 + <footer> 41 + {{if or .Prev .Next}}<nav> 42 + {{if .Prev}}<p>← Previous: <a href="{{.Prev.Path}}/">{{.Prev.Title}}</a></p>{{end}} 43 + {{if .Next}}<p>Next: <a href="{{.Next.Path}}/">{{.Next.Title}}</a> →</p>{{end}} 44 + </nav>{{end}} 45 + {{if .BskyPostURL}}<p><a href="{{.BskyPostURL}}">Discuss on Bluesky</a></p>{{end}} 46 + <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p> 47 + </footer> 48 + </article> 49 + </body> 50 + </html>
+4
internal/build/templates/robots.txt
··· 1 + User-agent: * 2 + Allow: / 3 + 4 + Sitemap: {{.Publication.URL}}/sitemap.xml
+20
internal/build/templates/rss.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"> 3 + <channel> 4 + <title>{{.Publication.Name}}</title> 5 + <link>{{.Publication.URL}}/</link> 6 + <description>{{.Publication.Description}}</description> 7 + <language>en</language> 8 + <atom:link href="{{.Publication.URL}}/rss.xml" rel="self" type="application/rss+xml"/> 9 + <lastBuildDate>{{.GeneratedAt.Format "Mon, 02 Jan 2006 15:04:05 -0700"}}</lastBuildDate> 10 + {{range .Posts}}<item> 11 + <title>{{.Title}}</title> 12 + <link>{{$.Publication.URL}}{{.Path}}/</link> 13 + <guid isPermaLink="true">{{$.Publication.URL}}{{.Path}}/</guid> 14 + <pubDate>{{.PublishedAt.Format "Mon, 02 Jan 2006 15:04:05 -0700"}}</pubDate> 15 + {{if .Description}}<description>{{.Description}}</description>{{end}} 16 + <content:encoded><![CDATA[{{.HTMLBody}}]]></content:encoded> 17 + {{range .Tags}}<category>{{.}}</category> 18 + {{end}}</item> 19 + {{end}}</channel> 20 + </rss>
+5
internal/build/templates/sitemap.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 3 + <url><loc>{{.Publication.URL}}/</loc></url> 4 + {{range .Posts}}<url><loc>{{$.Publication.URL}}{{.Path}}/</loc><lastmod>{{.Lastmod.Format "2006-01-02"}}</lastmod></url> 5 + {{end}}</urlset>
+27
internal/build/templates/tag.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Posts tagged "{{.Tag}}" — {{.Publication.Name}}</title> 6 + <link rel="canonical" href="{{.Publication.URL}}/tags/{{.Tag}}/"> 7 + 8 + <meta property="og:title" content="Posts tagged &quot;{{.Tag}}&quot; — {{.Publication.Name}}"> 9 + <meta property="og:type" content="website"> 10 + <meta property="og:url" content="{{.Publication.URL}}/tags/{{.Tag}}/"> 11 + <meta property="og:site_name" content="{{.Publication.Name}}"> 12 + 13 + {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 14 + {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} 15 + 16 + <link rel="alternate" type="application/atom+xml" title="{{.Publication.Name}}" href="{{.Publication.URL}}/feed.xml"> 17 + </head> 18 + <body> 19 + <header> 20 + <h1>Posts tagged &ldquo;{{.Tag}}&rdquo;</h1> 21 + <p><a href="{{.Publication.URL}}/tags/">All tags</a> &middot; <a href="{{.Publication.URL}}/">{{.Publication.Name}}</a></p> 22 + </header> 23 + <ul> 24 + {{range .Posts}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2 2006"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 25 + {{end}}</ul> 26 + </body> 27 + </html>
+24
internal/build/templates/tags-index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Tags — {{.Publication.Name}}</title> 6 + <link rel="canonical" href="{{.Publication.URL}}/tags/"> 7 + 8 + {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 9 + {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} 10 + </head> 11 + <body> 12 + <header> 13 + <h1>Tags</h1> 14 + <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p> 15 + </header> 16 + {{if .Tags}} 17 + <ul> 18 + {{range .Tags}}<li><a href="{{$.Publication.URL}}/tags/{{.Name}}/">{{.Name}}</a> ({{.Count}})</li> 19 + {{end}}</ul> 20 + {{else}} 21 + <p>No tags yet.</p> 22 + {{end}} 23 + </body> 24 + </html>
+6
internal/build/templates/well-known-publication.json
··· 1 + { 2 + "$type": "site.standard.publication", 3 + "url": "{{.Publication.URL}}", 4 + "name": "{{.Publication.Name}}"{{if .Publication.Description}}, 5 + "description": "{{.Publication.Description}}"{{end}} 6 + }
+13
internal/build/tls.go
··· 1 + package build 2 + 3 + import ( 4 + "crypto/tls" 5 + "crypto/x509" 6 + ) 7 + 8 + // httpsClientConfig builds a *tls.Config trusting the given pool. 9 + // Split into its own file so atomic.go and build.go don't both 10 + // duplicate the TLS plumbing. 11 + func httpsClientConfig(pool *x509.CertPool) *tls.Config { 12 + return &tls.Config{RootCAs: pool} 13 + }
+81
internal/config/config.go
··· 1 + // Package config loads per-profile fair CLI configuration from TOML files 2 + // and resolves which profile a given invocation should use. 3 + package config 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + 9 + "github.com/BurntSushi/toml" 10 + ) 11 + 12 + // Config is the per-profile CLI configuration loaded from TOML. 13 + type Config struct { 14 + PDSURL string `toml:"pds_url"` 15 + DID string `toml:"did"` 16 + Domain string `toml:"domain"` 17 + PublicationName string `toml:"publication_name"` 18 + BlogPosts string `toml:"blog_posts"` 19 + StateFile string `toml:"state_file"` 20 + ImagesMirror string `toml:"images_mirror"` 21 + DistDir string `toml:"dist_dir"` 22 + CloudflareProject string `toml:"cloudflare_project"` 23 + 24 + // LoopbackClient switches the OAuth flow to use the special 25 + // `http://localhost`-style client_id documented in ATProto's 26 + // oauth-types loopback handling. When true, no client metadata 27 + // document is fetched by the auth server — required for sandbox 28 + // because the PDS's SSRF protection blocks fetches to any 29 + // hostname that resolves to a loopback address. 30 + LoopbackClient bool `toml:"loopback_client"` 31 + 32 + // SocialLinks: URLs declared as <link rel="me"> in every page, 33 + // supporting IndieAuth-style social verification. Typical entries 34 + // are bsky.app and github profile URLs. 35 + SocialLinks []string `toml:"social_links"` 36 + 37 + // Description: optional publication-wide tagline rendered on the 38 + // index page and in OpenGraph meta. Falls back empty. 39 + Description string `toml:"description"` 40 + } 41 + 42 + // Load reads a TOML config file from path, decodes it, and validates that 43 + // all required fields are populated. It returns an error if the file cannot 44 + // be read, the TOML cannot be parsed, or any required field is empty. 45 + func Load(path string) (*Config, error) { 46 + data, err := os.ReadFile(path) 47 + if err != nil { 48 + return nil, fmt.Errorf("read config %s: %w", path, err) 49 + } 50 + var c Config 51 + if err := toml.Unmarshal(data, &c); err != nil { 52 + return nil, fmt.Errorf("parse config %s: %w", path, err) 53 + } 54 + if err := c.validate(); err != nil { 55 + return nil, fmt.Errorf("config %s: %w", path, err) 56 + } 57 + return &c, nil 58 + } 59 + 60 + // validate returns an error naming the first missing required field. 61 + // Required fields are those without sensible defaults: connection details 62 + // (PDSURL, DID), site identity (Domain, PublicationName), and the source 63 + // directory (BlogPosts). Other fields fall back to defaults via Defaults(). 64 + func (c *Config) validate() error { 65 + required := []struct { 66 + name string 67 + value string 68 + }{ 69 + {"pds_url", c.PDSURL}, 70 + {"did", c.DID}, 71 + {"domain", c.Domain}, 72 + {"publication_name", c.PublicationName}, 73 + {"blog_posts", c.BlogPosts}, 74 + } 75 + for _, f := range required { 76 + if f.value == "" { 77 + return fmt.Errorf("required field %s is missing or empty", f.name) 78 + } 79 + } 80 + return nil 81 + }
+102
internal/config/config_test.go
··· 1 + package config_test 2 + 3 + import ( 4 + "path/filepath" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/config" 8 + ) 9 + 10 + func TestLoad_ValidTOML(t *testing.T) { 11 + dir := t.TempDir() 12 + path := filepath.Join(dir, "config.prod.toml") 13 + contents := `pds_url = "https://bsky.social" 14 + did = "did:plc:abc123" 15 + domain = "https://aparker.io" 16 + publication_name = "Austin Parker" 17 + blog_posts = "/Users/austinparker/code/blog-posts" 18 + state_file = ".fair/state.prod.json" 19 + images_mirror = "images-mirror.prod/" 20 + dist_dir = "dist/" 21 + cloudflare_project = "aparker-blog" 22 + ` 23 + if err := writeFile(path, contents); err != nil { 24 + t.Fatal(err) 25 + } 26 + 27 + got, err := config.Load(path) 28 + if err != nil { 29 + t.Fatalf("Load returned error: %v", err) 30 + } 31 + 32 + if got.PDSURL != "https://bsky.social" { 33 + t.Errorf("PDSURL: got %q, want %q", got.PDSURL, "https://bsky.social") 34 + } 35 + if got.DID != "did:plc:abc123" { 36 + t.Errorf("DID: got %q, want %q", got.DID, "did:plc:abc123") 37 + } 38 + if got.Domain != "https://aparker.io" { 39 + t.Errorf("Domain: got %q, want %q", got.Domain, "https://aparker.io") 40 + } 41 + if got.PublicationName != "Austin Parker" { 42 + t.Errorf("PublicationName: got %q, want %q", got.PublicationName, "Austin Parker") 43 + } 44 + if got.BlogPosts != "/Users/austinparker/code/blog-posts" { 45 + t.Errorf("BlogPosts: got %q", got.BlogPosts) 46 + } 47 + if got.StateFile != ".fair/state.prod.json" { 48 + t.Errorf("StateFile: got %q", got.StateFile) 49 + } 50 + if got.ImagesMirror != "images-mirror.prod/" { 51 + t.Errorf("ImagesMirror: got %q", got.ImagesMirror) 52 + } 53 + if got.DistDir != "dist/" { 54 + t.Errorf("DistDir: got %q", got.DistDir) 55 + } 56 + if got.CloudflareProject != "aparker-blog" { 57 + t.Errorf("CloudflareProject: got %q", got.CloudflareProject) 58 + } 59 + } 60 + 61 + func TestLoad_MissingRequiredField(t *testing.T) { 62 + cases := []struct { 63 + name string 64 + omit string 65 + wantSub string 66 + }{ 67 + {"pds_url", "pds_url", "pds_url"}, 68 + {"did", "did", "did"}, 69 + {"domain", "domain", "domain"}, 70 + {"publication_name", "publication_name", "publication_name"}, 71 + {"blog_posts", "blog_posts", "blog_posts"}, 72 + } 73 + for _, tc := range cases { 74 + t.Run(tc.name, func(t *testing.T) { 75 + full := map[string]string{ 76 + "pds_url": `pds_url = "https://bsky.social"`, 77 + "did": `did = "did:plc:abc"`, 78 + "domain": `domain = "https://aparker.io"`, 79 + "publication_name": `publication_name = "Test"`, 80 + "blog_posts": `blog_posts = "/tmp/posts"`, 81 + } 82 + delete(full, tc.omit) 83 + 84 + path := filepath.Join(t.TempDir(), "c.toml") 85 + contents := "" 86 + for _, line := range full { 87 + contents += line + "\n" 88 + } 89 + if err := writeFile(path, contents); err != nil { 90 + t.Fatal(err) 91 + } 92 + 93 + _, err := config.Load(path) 94 + if err == nil { 95 + t.Fatalf("expected error for missing %s, got nil", tc.omit) 96 + } 97 + if !contains(err.Error(), tc.wantSub) { 98 + t.Errorf("error %q should mention %q", err.Error(), tc.wantSub) 99 + } 100 + }) 101 + } 102 + }
+135
internal/config/profile.go
··· 1 + package config 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io/fs" 7 + "os" 8 + "slices" 9 + "strings" 10 + ) 11 + 12 + // ResolveProfile picks the active profile name from the explicit flag value, 13 + // the FAIR_PROFILE environment variable, and the known-profiles allowlist 14 + // (typically discovered by listing config.<name>.toml files). 15 + // 16 + // Resolution order: flag > env > error. The chosen name must appear in known. 17 + // On a miss with a Levenshtein distance <= 2 to a known profile, the error 18 + // includes a "did you mean" suggestion. 19 + func ResolveProfile(flag, env string, known []string) (string, error) { 20 + if len(known) == 0 { 21 + return "", fmt.Errorf("no profiles configured (expected config.<name>.toml in config dir)") 22 + } 23 + 24 + chosen := flag 25 + if chosen == "" { 26 + chosen = env 27 + } 28 + if chosen == "" { 29 + return "", fmt.Errorf("profile required: pass --profile <name> or set FAIR_PROFILE (known: %s)", strings.Join(known, ", ")) 30 + } 31 + 32 + if slices.Contains(known, chosen) { 33 + return chosen, nil 34 + } 35 + 36 + // Unknown — try to suggest a close match. 37 + suggestion := closestMatch(chosen, known, 2) 38 + if suggestion != "" { 39 + return "", fmt.Errorf("unknown profile %q; did you mean %q? (known: %s)", chosen, suggestion, strings.Join(known, ", ")) 40 + } 41 + return "", fmt.Errorf("unknown profile %q (known: %s)", chosen, strings.Join(known, ", ")) 42 + } 43 + 44 + // DiscoverProfiles returns the profile names found in dir, by scanning for 45 + // files named "config.<name>.toml". A non-existent dir is not an error; 46 + // it returns an empty slice. Returned names are sorted (callers should not 47 + // rely on this for correctness, but it stabilizes error messages). 48 + func DiscoverProfiles(dir string) ([]string, error) { 49 + entries, err := os.ReadDir(dir) 50 + if err != nil { 51 + if errors.Is(err, fs.ErrNotExist) { 52 + return nil, nil 53 + } 54 + return nil, fmt.Errorf("read config dir %s: %w", dir, err) 55 + } 56 + const prefix = "config." 57 + const suffix = ".toml" 58 + var names []string 59 + for _, e := range entries { 60 + if e.IsDir() { 61 + continue 62 + } 63 + n := e.Name() 64 + if !strings.HasPrefix(n, prefix) || !strings.HasSuffix(n, suffix) { 65 + continue 66 + } 67 + // Reject "config.toml" (no profile name) — the prefix and suffix 68 + // would overlap, so the file is too short to contain a profile. 69 + if len(n) <= len(prefix)+len(suffix) { 70 + continue 71 + } 72 + profile := n[len(prefix) : len(n)-len(suffix)] 73 + if profile == "" { 74 + continue 75 + } 76 + names = append(names, profile) 77 + } 78 + slices.Sort(names) 79 + return names, nil 80 + } 81 + 82 + // closestMatch returns the candidate with the smallest Levenshtein distance 83 + // to s, provided that distance is at most maxDistance. Empty string means 84 + // no candidate qualifies. 85 + func closestMatch(s string, candidates []string, maxDistance int) string { 86 + best := "" 87 + bestDist := maxDistance + 1 88 + for _, c := range candidates { 89 + d := levenshtein(s, c) 90 + if d < bestDist { 91 + bestDist = d 92 + best = c 93 + } 94 + } 95 + if bestDist > maxDistance { 96 + return "" 97 + } 98 + return best 99 + } 100 + 101 + // levenshtein computes the edit distance between a and b using the standard 102 + // dynamic-programming recurrence. Operates on runes so non-ASCII profile 103 + // names (rare but possible) compare by character, not byte. 104 + func levenshtein(a, b string) int { 105 + ra := []rune(a) 106 + rb := []rune(b) 107 + if len(ra) == 0 { 108 + return len(rb) 109 + } 110 + if len(rb) == 0 { 111 + return len(ra) 112 + } 113 + 114 + prev := make([]int, len(rb)+1) 115 + cur := make([]int, len(rb)+1) 116 + for j := range prev { 117 + prev[j] = j 118 + } 119 + for i := 1; i <= len(ra); i++ { 120 + cur[0] = i 121 + for j := 1; j <= len(rb); j++ { 122 + cost := 1 123 + if ra[i-1] == rb[j-1] { 124 + cost = 0 125 + } 126 + cur[j] = min( 127 + prev[j]+1, // deletion 128 + cur[j-1]+1, // insertion 129 + prev[j-1]+cost, // substitution 130 + ) 131 + } 132 + prev, cur = cur, prev 133 + } 134 + return prev[len(rb)] 135 + }
+140
internal/config/profile_test.go
··· 1 + package config_test 2 + 3 + import ( 4 + "path/filepath" 5 + "strings" 6 + "testing" 7 + 8 + "aparker.io/fair/internal/config" 9 + ) 10 + 11 + func TestResolveProfile_FlagWinsOverEnv(t *testing.T) { 12 + got, err := config.ResolveProfile("prod", "sandbox", []string{"prod", "sandbox"}) 13 + if err != nil { 14 + t.Fatalf("unexpected error: %v", err) 15 + } 16 + if got != "prod" { 17 + t.Errorf("got %q, want prod", got) 18 + } 19 + } 20 + 21 + func TestResolveProfile_EnvUsedWhenFlagEmpty(t *testing.T) { 22 + got, err := config.ResolveProfile("", "sandbox", []string{"prod", "sandbox"}) 23 + if err != nil { 24 + t.Fatalf("unexpected error: %v", err) 25 + } 26 + if got != "sandbox" { 27 + t.Errorf("got %q, want sandbox", got) 28 + } 29 + } 30 + 31 + func TestResolveProfile_NoFlagNoEnv_Errors(t *testing.T) { 32 + _, err := config.ResolveProfile("", "", []string{"prod", "sandbox"}) 33 + if err == nil { 34 + t.Fatal("expected error, got nil") 35 + } 36 + if !strings.Contains(err.Error(), "FAIR_PROFILE") && !strings.Contains(err.Error(), "--profile") { 37 + t.Errorf("error %q should mention --profile or FAIR_PROFILE", err.Error()) 38 + } 39 + } 40 + 41 + func TestResolveProfile_UnknownProfile_SuggestsCloseMatch(t *testing.T) { 42 + _, err := config.ResolveProfile("sandbo", "", []string{"prod", "sandbox"}) 43 + if err == nil { 44 + t.Fatal("expected error for typo") 45 + } 46 + if !strings.Contains(err.Error(), "sandbox") { 47 + t.Errorf("error %q should suggest %q", err.Error(), "sandbox") 48 + } 49 + if !strings.Contains(err.Error(), "did you mean") { 50 + t.Errorf("error %q should say 'did you mean'", err.Error()) 51 + } 52 + } 53 + 54 + func TestResolveProfile_UnknownProfile_NoCloseMatch(t *testing.T) { 55 + _, err := config.ResolveProfile("xyzzy", "", []string{"prod", "sandbox"}) 56 + if err == nil { 57 + t.Fatal("expected error for unknown profile") 58 + } 59 + // far-from-anything should still surface the unknown name and the known list, 60 + // but not lie about a match 61 + if strings.Contains(err.Error(), "did you mean") { 62 + t.Errorf("error %q must not suggest a match for far-from-anything name", err.Error()) 63 + } 64 + if !strings.Contains(err.Error(), "xyzzy") { 65 + t.Errorf("error %q should mention the unknown profile name", err.Error()) 66 + } 67 + } 68 + 69 + func TestResolveProfile_KnownProfileFromEnv(t *testing.T) { 70 + got, err := config.ResolveProfile("", "prod", []string{"prod", "sandbox"}) 71 + if err != nil { 72 + t.Fatalf("unexpected error: %v", err) 73 + } 74 + if got != "prod" { 75 + t.Errorf("got %q, want prod", got) 76 + } 77 + } 78 + 79 + func TestResolveProfile_EmptyKnownList_Errors(t *testing.T) { 80 + _, err := config.ResolveProfile("prod", "", nil) 81 + if err == nil { 82 + t.Fatal("expected error when no profiles configured") 83 + } 84 + if !strings.Contains(strings.ToLower(err.Error()), "no profiles") { 85 + t.Errorf("error %q should explain no profiles configured", err.Error()) 86 + } 87 + } 88 + 89 + func TestDiscoverProfiles_FindsConfigFiles(t *testing.T) { 90 + dir := t.TempDir() 91 + for _, name := range []string{"config.prod.toml", "config.sandbox.toml"} { 92 + if err := writeFile(filepath.Join(dir, name), "pds_url = \"\""); err != nil { 93 + t.Fatal(err) 94 + } 95 + } 96 + // noise files that should be ignored 97 + for _, name := range []string{"profiles.toml", "config.toml", "session.json", "config.bad.txt"} { 98 + if err := writeFile(filepath.Join(dir, name), ""); err != nil { 99 + t.Fatal(err) 100 + } 101 + } 102 + 103 + got, err := config.DiscoverProfiles(dir) 104 + if err != nil { 105 + t.Fatalf("unexpected error: %v", err) 106 + } 107 + want := []string{"prod", "sandbox"} 108 + if len(got) != len(want) { 109 + t.Fatalf("got %v, want %v", got, want) 110 + } 111 + gotSet := map[string]bool{} 112 + for _, n := range got { 113 + gotSet[n] = true 114 + } 115 + for _, n := range want { 116 + if !gotSet[n] { 117 + t.Errorf("missing profile %q in %v", n, got) 118 + } 119 + } 120 + } 121 + 122 + func TestDiscoverProfiles_EmptyDir(t *testing.T) { 123 + got, err := config.DiscoverProfiles(t.TempDir()) 124 + if err != nil { 125 + t.Fatalf("unexpected error: %v", err) 126 + } 127 + if len(got) != 0 { 128 + t.Errorf("got %v, want empty", got) 129 + } 130 + } 131 + 132 + func TestDiscoverProfiles_MissingDir(t *testing.T) { 133 + got, err := config.DiscoverProfiles(filepath.Join(t.TempDir(), "does-not-exist")) 134 + if err != nil { 135 + t.Fatalf("missing dir should not error (returns empty list): %v", err) 136 + } 137 + if len(got) != 0 { 138 + t.Errorf("got %v, want empty", got) 139 + } 140 + }
+14
internal/config/testhelpers_test.go
··· 1 + package config_test 2 + 3 + import ( 4 + "os" 5 + "strings" 6 + ) 7 + 8 + func writeFile(path, contents string) error { 9 + return os.WriteFile(path, []byte(contents), 0o600) 10 + } 11 + 12 + func contains(s, sub string) bool { 13 + return strings.Contains(s, sub) 14 + }
+65
internal/markdown/canonical.go
··· 1 + package markdown 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strings" 7 + ) 8 + 9 + // Canonical returns deterministic bytes representing the hash-relevant 10 + // portion of a Frontmatter. It excludes fields that should not affect 11 + // idempotency (id, updated) and normalizes lists (tags: lowercased, sorted, 12 + // deduplicated). Used as one input to Hash(). 13 + // 14 + // Format is a stable, line-per-key textual encoding rather than YAML so 15 + // that "the canonical bytes" don't depend on a YAML library's whitespace 16 + // or quoting choices. Hashing reads bytes verbatim — formatting changes 17 + // here would invalidate stored hashes, so the format is intentionally 18 + // rigid. 19 + func Canonical(fm Frontmatter) []byte { 20 + var b strings.Builder 21 + 22 + // Keys appear alphabetically; missing/empty fields are omitted so the 23 + // canonical output of `{Title: "X"}` doesn't carry empty defaults. 24 + if fm.Cover != "" { 25 + fmt.Fprintf(&b, "cover: %s\n", fm.Cover) 26 + } 27 + if fm.Date != "" { 28 + fmt.Fprintf(&b, "date: %s\n", fm.Date) 29 + } 30 + if fm.Description != "" { 31 + fmt.Fprintf(&b, "description: %s\n", fm.Description) 32 + } 33 + if fm.Slug != "" { 34 + fmt.Fprintf(&b, "slug: %s\n", fm.Slug) 35 + } 36 + if tags := normalizeTags(fm.Tags); len(tags) > 0 { 37 + fmt.Fprintf(&b, "tags: [%s]\n", strings.Join(tags, ", ")) 38 + } 39 + if fm.Title != "" { 40 + fmt.Fprintf(&b, "title: %s\n", fm.Title) 41 + } 42 + // id, updated intentionally omitted — they must not affect the hash. 43 + 44 + return []byte(b.String()) 45 + } 46 + 47 + // normalizeTags returns tags with whitespace trimmed, lowercased, deduped, 48 + // and sorted. Empty tags after trimming are dropped. 49 + func normalizeTags(in []string) []string { 50 + seen := make(map[string]struct{}, len(in)) 51 + out := make([]string, 0, len(in)) 52 + for _, t := range in { 53 + t = strings.ToLower(strings.TrimSpace(t)) 54 + if t == "" { 55 + continue 56 + } 57 + if _, ok := seen[t]; ok { 58 + continue 59 + } 60 + seen[t] = struct{}{} 61 + out = append(out, t) 62 + } 63 + slices.Sort(out) 64 + return out 65 + }
+83
internal/markdown/canonical_test.go
··· 1 + package markdown_test 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + 8 + "aparker.io/fair/internal/markdown" 9 + ) 10 + 11 + func TestCanonical_DeterministicForEqualInputs(t *testing.T) { 12 + fm1 := markdown.Frontmatter{ 13 + Title: "Hello", 14 + Date: "2025-04-17", 15 + Tags: []string{"observability", "personal"}, 16 + } 17 + fm2 := fm1 18 + a := markdown.Canonical(fm1) 19 + b := markdown.Canonical(fm2) 20 + if !bytes.Equal(a, b) { 21 + t.Errorf("not deterministic:\na = %q\nb = %q", a, b) 22 + } 23 + } 24 + 25 + func TestCanonical_TagsSortedLowercasedDeduped(t *testing.T) { 26 + fm := markdown.Frontmatter{ 27 + Title: "X", 28 + Date: "2025-04-17", 29 + Tags: []string{"Personal", "observability", "PERSONAL", "Foo"}, 30 + } 31 + got := string(markdown.Canonical(fm)) 32 + if !strings.Contains(got, "[foo, observability, personal]") { 33 + t.Errorf("tags not normalized; got:\n%s", got) 34 + } 35 + } 36 + 37 + func TestCanonical_ExcludesIDUpdated(t *testing.T) { 38 + with := markdown.Frontmatter{ 39 + ID: "01HXTEST", 40 + Title: "Hello", 41 + Date: "2025-04-17", 42 + Updated: "2025-05-01", 43 + } 44 + without := markdown.Frontmatter{ 45 + Title: "Hello", 46 + Date: "2025-04-17", 47 + } 48 + a := markdown.Canonical(with) 49 + b := markdown.Canonical(without) 50 + if !bytes.Equal(a, b) { 51 + t.Errorf("id/updated affected canonical bytes:\nwith = %q\nwithout = %q", a, b) 52 + } 53 + } 54 + 55 + func TestCanonical_DifferentTitleProducesDifferentBytes(t *testing.T) { 56 + a := markdown.Canonical(markdown.Frontmatter{Title: "A", Date: "2025-04-17"}) 57 + b := markdown.Canonical(markdown.Frontmatter{Title: "B", Date: "2025-04-17"}) 58 + if bytes.Equal(a, b) { 59 + t.Error("different titles produced equal bytes") 60 + } 61 + } 62 + 63 + func TestCanonical_FieldsAppearInStableOrder(t *testing.T) { 64 + fm := markdown.Frontmatter{ 65 + Title: "Hello", 66 + Date: "2025-04-17", 67 + Description: "An essay", 68 + Cover: "./cover.png", 69 + Tags: []string{"a"}, 70 + Slug: "hello", 71 + } 72 + got := string(markdown.Canonical(fm)) 73 + // Expect alphabetical key order: cover, date, description, slug, tags, title 74 + wantOrder := []string{"cover:", "date:", "description:", "slug:", "tags:", "title:"} 75 + pos := 0 76 + for _, key := range wantOrder { 77 + idx := strings.Index(got[pos:], key) 78 + if idx < 0 { 79 + t.Fatalf("key %q not found in canonical output (or out of order):\n%s", key, got) 80 + } 81 + pos += idx + len(key) 82 + } 83 + }
+100
internal/markdown/frontmatter.go
··· 1 + package markdown 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "gopkg.in/yaml.v3" 8 + ) 9 + 10 + // Frontmatter is the typed view of a post's YAML frontmatter. Fields that 11 + // the publish flow cares about (title, date, etc.) are surfaced explicitly. 12 + // Unknown YAML keys are silently ignored — callers should treat the source 13 + // markdown file as the source of truth for any extra metadata. 14 + // 15 + // All string fields are stored as written. Date normalization to RFC3339 16 + // happens later in the publish flow, not here. 17 + type Frontmatter struct { 18 + ID string `yaml:"id,omitempty"` 19 + Title string `yaml:"title"` 20 + Date string `yaml:"date"` 21 + Description string `yaml:"description,omitempty"` 22 + Cover string `yaml:"cover,omitempty"` 23 + Tags []string `yaml:"tags,omitempty"` 24 + Slug string `yaml:"slug,omitempty"` 25 + Updated string `yaml:"updated,omitempty"` 26 + } 27 + 28 + // Split separates the optional YAML frontmatter (delimited by "---" lines) 29 + // from the body of a markdown source. Returns ("", body, nil) when no 30 + // frontmatter is present. 31 + // 32 + // The returned frontmatter is the YAML text between the two fence lines 33 + // (no fences, ending with one trailing newline if non-empty). The body is 34 + // the source after the closing fence with up to one leading blank line 35 + // trimmed (matching gray-matter convention). 36 + func Split(src string) (frontmatter, body string, err error) { 37 + const fence = "---" 38 + if !strings.HasPrefix(src, fence) { 39 + return "", src, nil 40 + } 41 + 42 + // Walk the source line-by-line. Line 0 is the opening fence; we look 43 + // for a subsequent line that is exactly "---" (CR-tolerant) to mark 44 + // the close. 45 + lines := strings.Split(src, "\n") 46 + openLine := strings.TrimSuffix(lines[0], "\r") 47 + if openLine != fence { 48 + // First line starts with "---" but has trailing junk (e.g. "---x"); 49 + // treat the source as bodyless-frontmatter mismatch — accept as body. 50 + return "", src, nil 51 + } 52 + 53 + closing := -1 54 + for i := 1; i < len(lines); i++ { 55 + if strings.TrimSuffix(lines[i], "\r") == fence { 56 + closing = i 57 + break 58 + } 59 + } 60 + if closing < 0 { 61 + return "", "", fmt.Errorf("unterminated frontmatter (missing closing %q)", fence) 62 + } 63 + 64 + frontmatter = strings.Join(lines[1:closing], "\n") 65 + if frontmatter != "" { 66 + frontmatter += "\n" 67 + } 68 + 69 + // Body is everything after the closing fence line. strings.Split with 70 + // "\n" leaves the trailing slice intact; rejoin and trim one blank line. 71 + body = strings.Join(lines[closing+1:], "\n") 72 + body = strings.TrimPrefix(body, "\n") 73 + return frontmatter, body, nil 74 + } 75 + 76 + // Parse splits source markdown into typed frontmatter + body and validates 77 + // that required fields (title, date) are present. Use Parse when the 78 + // publish flow needs typed access; use Split for raw splitting. 79 + func Parse(src string) (Frontmatter, string, error) { 80 + fmRaw, body, err := Split(src) 81 + if err != nil { 82 + return Frontmatter{}, "", err 83 + } 84 + 85 + var fm Frontmatter 86 + if fmRaw != "" { 87 + if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil { 88 + return Frontmatter{}, "", fmt.Errorf("parse frontmatter: %w", err) 89 + } 90 + } 91 + 92 + if fm.Title == "" { 93 + return Frontmatter{}, "", fmt.Errorf("frontmatter missing required field: title") 94 + } 95 + if fm.Date == "" { 96 + return Frontmatter{}, "", fmt.Errorf("frontmatter missing required field: date") 97 + } 98 + 99 + return fm, body, nil 100 + }
+120
internal/markdown/frontmatter_test.go
··· 1 + package markdown_test 2 + 3 + import ( 4 + "reflect" 5 + "strings" 6 + "testing" 7 + 8 + "aparker.io/fair/internal/markdown" 9 + ) 10 + 11 + func TestSplit_FrontmatterAndBody(t *testing.T) { 12 + src := "---\ntitle: Hello\ndate: 2025-04-17\n---\n\nbody text\n" 13 + fm, body, err := markdown.Split(src) 14 + if err != nil { 15 + t.Fatalf("unexpected error: %v", err) 16 + } 17 + if fm != "title: Hello\ndate: 2025-04-17\n" { 18 + t.Errorf("frontmatter: %q", fm) 19 + } 20 + if body != "body text\n" { 21 + t.Errorf("body: %q", body) 22 + } 23 + } 24 + 25 + func TestSplit_NoFrontmatter(t *testing.T) { 26 + src := "just body, no frontmatter\n" 27 + fm, body, err := markdown.Split(src) 28 + if err != nil { 29 + t.Fatalf("unexpected error: %v", err) 30 + } 31 + if fm != "" { 32 + t.Errorf("frontmatter: %q (expected empty)", fm) 33 + } 34 + if body != src { 35 + t.Errorf("body: %q", body) 36 + } 37 + } 38 + 39 + func TestSplit_UnclosedFrontmatter_IsError(t *testing.T) { 40 + src := "---\ntitle: Hello\nbody text without closing fence\n" 41 + _, _, err := markdown.Split(src) 42 + if err == nil { 43 + t.Fatal("expected error for unterminated frontmatter") 44 + } 45 + if !strings.Contains(err.Error(), "frontmatter") { 46 + t.Errorf("error %q should mention frontmatter", err.Error()) 47 + } 48 + } 49 + 50 + func TestSplit_EmptyFrontmatter(t *testing.T) { 51 + // "---\n---\n\nbody" — valid: empty frontmatter, body follows 52 + src := "---\n---\nbody\n" 53 + fm, body, err := markdown.Split(src) 54 + if err != nil { 55 + t.Fatalf("unexpected error: %v", err) 56 + } 57 + if fm != "" { 58 + t.Errorf("frontmatter should be empty, got %q", fm) 59 + } 60 + if body != "body\n" { 61 + t.Errorf("body: %q", body) 62 + } 63 + } 64 + 65 + func TestParse_KnownFields(t *testing.T) { 66 + src := "---\nid: 01HXTEST123\ntitle: \"Wide Events\"\ndate: \"2025-04-17\"\ndescription: An essay\ntags:\n - observability\n - personal\nslug: wide-events\ncover: ./images/cover.png\nupdated: \"2025-05-01\"\n---\n\nbody\n" 67 + fm, body, err := markdown.Parse(src) 68 + if err != nil { 69 + t.Fatalf("Parse: %v", err) 70 + } 71 + if fm.ID != "01HXTEST123" { 72 + t.Errorf("ID: %q", fm.ID) 73 + } 74 + if fm.Title != "Wide Events" { 75 + t.Errorf("Title: %q", fm.Title) 76 + } 77 + if fm.Date != "2025-04-17" { 78 + t.Errorf("Date: %q", fm.Date) 79 + } 80 + if fm.Description != "An essay" { 81 + t.Errorf("Description: %q", fm.Description) 82 + } 83 + if !reflect.DeepEqual(fm.Tags, []string{"observability", "personal"}) { 84 + t.Errorf("Tags: %v", fm.Tags) 85 + } 86 + if fm.Slug != "wide-events" { 87 + t.Errorf("Slug: %q", fm.Slug) 88 + } 89 + if fm.Cover != "./images/cover.png" { 90 + t.Errorf("Cover: %q", fm.Cover) 91 + } 92 + if fm.Updated != "2025-05-01" { 93 + t.Errorf("Updated: %q", fm.Updated) 94 + } 95 + if body != "body\n" { 96 + t.Errorf("body: %q", body) 97 + } 98 + } 99 + 100 + func TestParse_MissingTitleOrDate_IsError(t *testing.T) { 101 + cases := []struct { 102 + name string 103 + src string 104 + want string 105 + }{ 106 + {"no title", "---\ndate: 2025-04-17\n---\nbody\n", "title"}, 107 + {"no date", "---\ntitle: Hello\n---\nbody\n", "date"}, 108 + } 109 + for _, tc := range cases { 110 + t.Run(tc.name, func(t *testing.T) { 111 + _, _, err := markdown.Parse(tc.src) 112 + if err == nil { 113 + t.Fatal("expected error") 114 + } 115 + if !strings.Contains(err.Error(), tc.want) { 116 + t.Errorf("error %q should mention %q", err.Error(), tc.want) 117 + } 118 + }) 119 + } 120 + }
+49
internal/markdown/hash.go
··· 1 + package markdown 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + ) 7 + 8 + // HashInput is the bundle of normalized inputs that drive content-hash 9 + // idempotency for `fair publish`. All fields except Frontmatter are pre- 10 + // normalized by the caller; Hash applies the same canonical-frontmatter 11 + // transform to make the hash insensitive to id/updated/tag-order/case 12 + // changes. 13 + type HashInput struct { 14 + // NormalizedBody is the markdown body after Normalize() AND inline 15 + // image-path rewriting. Two equivalent posts must arrive here as 16 + // byte-identical strings or the hashes will differ. 17 + NormalizedBody string 18 + 19 + // Frontmatter is the typed YAML metadata. Hash uses Canonical(fm) 20 + // internally — id/updated are excluded, tags are normalized. 21 + Frontmatter Frontmatter 22 + 23 + // CoverSourceHash is the SHA-256 (hex) of the local cover-image file 24 + // bytes, computed before upload. Empty string means "no cover". 25 + // Hashing the source file (not the PDS blob CID) means the publish 26 + // flow can short-circuit before any network round-trip. 27 + CoverSourceHash string 28 + } 29 + 30 + // Hash returns a stable hex-encoded SHA-256 of the input. The hash is 31 + // the only thing `fair publish` consults to decide whether a record put 32 + // is needed (--force bypasses it). 33 + // 34 + // Field separators (\x00) are used between sections so concatenation 35 + // can't smear adjacent fields together (e.g. an empty CoverSourceHash 36 + // followed by a body starting with "abc" is distinct from a CoverSourceHash 37 + // "abc" followed by an empty body). 38 + func Hash(in HashInput) string { 39 + h := sha256.New() 40 + h.Write([]byte("body:")) 41 + h.Write([]byte(in.NormalizedBody)) 42 + h.Write([]byte{0}) 43 + h.Write([]byte("frontmatter:")) 44 + h.Write(Canonical(in.Frontmatter)) 45 + h.Write([]byte{0}) 46 + h.Write([]byte("cover:")) 47 + h.Write([]byte(in.CoverSourceHash)) 48 + return hex.EncodeToString(h.Sum(nil)) 49 + }
+104
internal/markdown/hash_test.go
··· 1 + package markdown_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "aparker.io/fair/internal/markdown" 7 + ) 8 + 9 + func TestHash_DeterministicForEqualInputs(t *testing.T) { 10 + in := markdown.HashInput{ 11 + NormalizedBody: "Hello world.\n", 12 + Frontmatter: markdown.Frontmatter{Title: "X", Date: "2025-04-17"}, 13 + CoverSourceHash: "", 14 + } 15 + a := markdown.Hash(in) 16 + b := markdown.Hash(in) 17 + if a != b { 18 + t.Errorf("hash not deterministic: %s vs %s", a, b) 19 + } 20 + if len(a) != 64 { 21 + t.Errorf("hash length: got %d, want 64 hex chars", len(a)) 22 + } 23 + } 24 + 25 + func TestHash_BodyChange_ChangesHash(t *testing.T) { 26 + base := markdown.HashInput{ 27 + NormalizedBody: "v1\n", 28 + Frontmatter: markdown.Frontmatter{Title: "X", Date: "2025-04-17"}, 29 + } 30 + other := base 31 + other.NormalizedBody = "v2\n" 32 + if markdown.Hash(base) == markdown.Hash(other) { 33 + t.Error("body change did not affect hash") 34 + } 35 + } 36 + 37 + func TestHash_TitleChange_ChangesHash(t *testing.T) { 38 + a := markdown.Hash(markdown.HashInput{ 39 + NormalizedBody: "x\n", 40 + Frontmatter: markdown.Frontmatter{Title: "A", Date: "2025-04-17"}, 41 + }) 42 + b := markdown.Hash(markdown.HashInput{ 43 + NormalizedBody: "x\n", 44 + Frontmatter: markdown.Frontmatter{Title: "B", Date: "2025-04-17"}, 45 + }) 46 + if a == b { 47 + t.Error("title change did not affect hash") 48 + } 49 + } 50 + 51 + func TestHash_CoverHash_AffectsHash(t *testing.T) { 52 + base := markdown.HashInput{ 53 + NormalizedBody: "x\n", 54 + Frontmatter: markdown.Frontmatter{Title: "X", Date: "2025-04-17"}, 55 + } 56 + a := markdown.Hash(base) 57 + withCover := base 58 + withCover.CoverSourceHash = "abc123" 59 + b := markdown.Hash(withCover) 60 + if a == b { 61 + t.Error("cover hash did not affect output") 62 + } 63 + } 64 + 65 + func TestHash_IgnoresIDAndUpdated(t *testing.T) { 66 + a := markdown.Hash(markdown.HashInput{ 67 + NormalizedBody: "x\n", 68 + Frontmatter: markdown.Frontmatter{Title: "X", Date: "2025-04-17"}, 69 + }) 70 + b := markdown.Hash(markdown.HashInput{ 71 + NormalizedBody: "x\n", 72 + Frontmatter: markdown.Frontmatter{ 73 + Title: "X", 74 + Date: "2025-04-17", 75 + ID: "01HXTEST", 76 + Updated: "2025-05-01", 77 + }, 78 + }) 79 + if a != b { 80 + t.Errorf("id/updated affected hash: %s vs %s", a, b) 81 + } 82 + } 83 + 84 + func TestHash_TagOrderingIgnored(t *testing.T) { 85 + a := markdown.Hash(markdown.HashInput{ 86 + NormalizedBody: "x\n", 87 + Frontmatter: markdown.Frontmatter{ 88 + Title: "X", 89 + Date: "2025-04-17", 90 + Tags: []string{"a", "B", "c"}, 91 + }, 92 + }) 93 + b := markdown.Hash(markdown.HashInput{ 94 + NormalizedBody: "x\n", 95 + Frontmatter: markdown.Frontmatter{ 96 + Title: "X", 97 + Date: "2025-04-17", 98 + Tags: []string{"C", "b", "A"}, 99 + }, 100 + }) 101 + if a != b { 102 + t.Errorf("tag ordering/case affected hash: %s vs %s", a, b) 103 + } 104 + }
+39
internal/markdown/normalize.go
··· 1 + // Package markdown holds the deterministic transforms shared by the 2 + // publish and build flows: text normalization, frontmatter canonicalization, 3 + // AST parsing, and content hashing. 4 + // 5 + // Determinism is the contract. Same source on disk -> same bytes on PDS, 6 + // across runs and across machines. 7 + package markdown 8 + 9 + import "strings" 10 + 11 + // Normalize returns the canonical form of a markdown source string for 12 + // hashing and for embedding into a PDS record's content.text field. 13 + // It strips a leading BOM, converts CRLF and bare CR to LF, and ensures 14 + // the result ends with exactly one trailing newline (or stays empty if 15 + // the input was empty). 16 + func Normalize(s string) string { 17 + if s == "" { 18 + return "" 19 + } 20 + 21 + // 1. Strip leading BOM. 22 + const bom = "\ufeff" 23 + s = strings.TrimPrefix(s, bom) 24 + 25 + // 2. CRLF -> LF, then bare CR -> LF. Order matters: CRLF must be 26 + // handled first or "\r\n" becomes "\n\n". 27 + s = strings.ReplaceAll(s, "\r\n", "\n") 28 + s = strings.ReplaceAll(s, "\r", "\n") 29 + 30 + // 3. Collapse trailing newlines to exactly one. After step 2 the 31 + // string may end with "\n\n\n..." — strip back to bare body, then 32 + // re-add a single newline. 33 + s = strings.TrimRight(s, "\n") 34 + if s == "" { 35 + // Input was all whitespace / line endings. Treat as empty. 36 + return "" 37 + } 38 + return s + "\n" 39 + }
+83
internal/markdown/normalize_test.go
··· 1 + package markdown_test 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/markdown" 8 + ) 9 + 10 + func TestNormalize_StripsBOM(t *testing.T) { 11 + bom := "\ufeff" 12 + got := markdown.Normalize(bom + "hello\n") 13 + if strings.HasPrefix(got, bom) { 14 + t.Errorf("BOM not stripped: %q", got) 15 + } 16 + if got != "hello\n" { 17 + t.Errorf("got %q, want %q", got, "hello\n") 18 + } 19 + } 20 + 21 + func TestNormalize_ConvertsCRLFToLF(t *testing.T) { 22 + got := markdown.Normalize("a\r\nb\r\nc\r\n") 23 + want := "a\nb\nc\n" 24 + if got != want { 25 + t.Errorf("got %q, want %q", got, want) 26 + } 27 + } 28 + 29 + func TestNormalize_ConvertsBareCRToLF(t *testing.T) { 30 + // Old-Mac line endings, rare but real 31 + got := markdown.Normalize("a\rb\rc\r") 32 + want := "a\nb\nc\n" 33 + if got != want { 34 + t.Errorf("got %q, want %q", got, want) 35 + } 36 + } 37 + 38 + func TestNormalize_EnsuresTrailingNewline(t *testing.T) { 39 + got := markdown.Normalize("hello") 40 + if got != "hello\n" { 41 + t.Errorf("got %q, want trailing newline", got) 42 + } 43 + } 44 + 45 + func TestNormalize_NoDoubleTrailingNewline(t *testing.T) { 46 + got := markdown.Normalize("hello\n\n\n") 47 + // Trailing newlines collapsed to a single one. 48 + if got != "hello\n" { 49 + t.Errorf("got %q, want %q", got, "hello\n") 50 + } 51 + } 52 + 53 + func TestNormalize_PreservesInternalBlankLines(t *testing.T) { 54 + got := markdown.Normalize("a\n\nb\n") 55 + if got != "a\n\nb\n" { 56 + t.Errorf("got %q, blank line was eaten", got) 57 + } 58 + } 59 + 60 + func TestNormalize_Idempotent(t *testing.T) { 61 + cases := []string{ 62 + "", 63 + "\n", 64 + "hello\n", 65 + "\ufeffhello\r\n", 66 + "a\r\n\r\nb\r\n", 67 + } 68 + for _, c := range cases { 69 + once := markdown.Normalize(c) 70 + twice := markdown.Normalize(once) 71 + if once != twice { 72 + t.Errorf("not idempotent: input %q -> %q -> %q", c, once, twice) 73 + } 74 + } 75 + } 76 + 77 + func TestNormalize_EmptyString(t *testing.T) { 78 + // Empty in, empty out — don't synthesize a newline from nothing 79 + got := markdown.Normalize("") 80 + if got != "" { 81 + t.Errorf("got %q, want empty", got) 82 + } 83 + }
+59
internal/markdown/parser.go
··· 1 + package markdown 2 + 3 + import ( 4 + "sync" 5 + 6 + "github.com/yuin/goldmark" 7 + "github.com/yuin/goldmark/extension" 8 + "github.com/yuin/goldmark/parser" 9 + "github.com/yuin/goldmark/renderer/html" 10 + ) 11 + 12 + // Parser returns the shared, configured goldmark.Markdown used by every 13 + // publish/build pipeline. 14 + // 15 + // Hash stability note: the content hash is over the *markdown source* 16 + // (post-normalize, post-image-rewrite), NOT goldmark's rendered HTML. 17 + // So adding/removing extensions here only affects rendered HTML output, 18 + // not stored hashes. Consumers (e.g., Leaflet) re-render from source so 19 + // they may see different HTML than us depending on their config. 20 + // 21 + // Active extensions: 22 + // - GFM (tables, strikethrough, task lists, autolinks) 23 + // - Footnote — markdown [^1] syntax produces <sup><a href="#fn1"> 24 + // and a footnote list at the bottom 25 + // - DefinitionList — "Term\n: Definition" markdown produces <dl> 26 + // 27 + // Active parser options: 28 + // - WithAutoHeadingID — anchor IDs derived from heading text, stable 29 + // across renders, useful for permalink fragments 30 + // 31 + // Active renderer options: 32 + // - WithUnsafe — pass HTML in the source through unmodified. The publish flow 33 + // contains hand-authored <blockquote data-bluesky-uri> markers that 34 + // the build flow rewrites; this also lets posts use raw HTML widgets 35 + // (<details>, <kbd>, <progress>, etc.) goldmark doesn't natively 36 + // emit. 37 + func Parser() goldmark.Markdown { 38 + parserOnce.Do(func() { 39 + sharedParser = goldmark.New( 40 + goldmark.WithExtensions( 41 + extension.GFM, 42 + extension.Footnote, 43 + extension.DefinitionList, 44 + ), 45 + goldmark.WithParserOptions( 46 + parser.WithAutoHeadingID(), 47 + ), 48 + goldmark.WithRendererOptions( 49 + html.WithUnsafe(), 50 + ), 51 + ) 52 + }) 53 + return sharedParser 54 + } 55 + 56 + var ( 57 + parserOnce sync.Once 58 + sharedParser goldmark.Markdown 59 + )
+117
internal/markdown/parser_test.go
··· 1 + package markdown_test 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + "aparker.io/fair/internal/markdown" 10 + gmast "github.com/yuin/goldmark/ast" 11 + gmtext "github.com/yuin/goldmark/text" 12 + ) 13 + 14 + func TestParser_RendersBasicMarkdown(t *testing.T) { 15 + in := "# Hello\n\nworld\n" 16 + var buf bytes.Buffer 17 + if err := markdown.Parser().Convert([]byte(in), &buf); err != nil { 18 + t.Fatalf("Convert: %v", err) 19 + } 20 + got := buf.String() 21 + if got == "" { 22 + t.Fatal("got empty render") 23 + } 24 + // Auto-heading-IDs add `id=...` to <h1>, so check the prefix only. 25 + for _, want := range []string{"<h1", "Hello", "<p>", "world"} { 26 + if !bytes.Contains(buf.Bytes(), []byte(want)) { 27 + t.Errorf("output missing %q:\n%s", want, got) 28 + } 29 + } 30 + } 31 + 32 + func TestParser_RendersGFMTable(t *testing.T) { 33 + in := "| col |\n|---|\n| a |\n" 34 + var buf bytes.Buffer 35 + if err := markdown.Parser().Convert([]byte(in), &buf); err != nil { 36 + t.Fatalf("Convert: %v", err) 37 + } 38 + if !bytes.Contains(buf.Bytes(), []byte("<table>")) { 39 + t.Errorf("GFM table not rendered:\n%s", buf.String()) 40 + } 41 + } 42 + 43 + func TestParser_RendersGFMStrikethrough(t *testing.T) { 44 + in := "~~struck~~\n" 45 + var buf bytes.Buffer 46 + if err := markdown.Parser().Convert([]byte(in), &buf); err != nil { 47 + t.Fatalf("Convert: %v", err) 48 + } 49 + if !bytes.Contains(buf.Bytes(), []byte("<del>")) { 50 + t.Errorf("strikethrough not rendered:\n%s", buf.String()) 51 + } 52 + } 53 + 54 + func TestParser_RendersGFMAutolink(t *testing.T) { 55 + in := "Visit https://aparker.io for posts.\n" 56 + var buf bytes.Buffer 57 + if err := markdown.Parser().Convert([]byte(in), &buf); err != nil { 58 + t.Fatalf("Convert: %v", err) 59 + } 60 + if !bytes.Contains(buf.Bytes(), []byte(`href="https://aparker.io"`)) { 61 + t.Errorf("autolink not rendered:\n%s", buf.String()) 62 + } 63 + } 64 + 65 + func TestParser_ParsesRealFixturesWithoutError(t *testing.T) { 66 + entries, err := os.ReadDir("testdata/posts") 67 + if err != nil { 68 + t.Fatalf("read testdata: %v", err) 69 + } 70 + if len(entries) == 0 { 71 + t.Skip("no fixtures present") 72 + } 73 + 74 + for _, e := range entries { 75 + t.Run(e.Name(), func(t *testing.T) { 76 + src, err := os.ReadFile(filepath.Join("testdata/posts", e.Name())) 77 + if err != nil { 78 + t.Fatal(err) 79 + } 80 + // Strip frontmatter so we feed just the body to goldmark. 81 + _, body, err := markdown.Split(string(src)) 82 + if err != nil { 83 + t.Fatalf("Split: %v", err) 84 + } 85 + 86 + // AST parse should not panic or error. 87 + reader := gmtext.NewReader([]byte(body)) 88 + doc := markdown.Parser().Parser().Parse(reader) 89 + if doc == nil { 90 + t.Fatal("nil doc") 91 + } 92 + // Sanity: at least one block-level child. 93 + if doc.ChildCount() == 0 { 94 + t.Errorf("no block children parsed") 95 + } 96 + // Walk to confirm no unexpected node types. 97 + err = gmast.Walk(doc, func(_ gmast.Node, entering bool) (gmast.WalkStatus, error) { 98 + if !entering { 99 + return gmast.WalkContinue, nil 100 + } 101 + return gmast.WalkContinue, nil 102 + }) 103 + if err != nil { 104 + t.Errorf("walk: %v", err) 105 + } 106 + 107 + // Render for good measure. 108 + var buf bytes.Buffer 109 + if err := markdown.Parser().Convert([]byte(body), &buf); err != nil { 110 + t.Errorf("Convert: %v", err) 111 + } 112 + if buf.Len() == 0 { 113 + t.Errorf("empty render for %s", e.Name()) 114 + } 115 + }) 116 + } 117 + }
+28
internal/markdown/testdata/posts/small-software.md
··· 1 + --- 2 + title: "The Future Of Software Is Small" 3 + date: "2025-08-03" 4 + --- 5 + 6 + The dominance of SaaS platforms in business and pleasure today is a cyclic one. If you went back in time to the 1980's and told them that 21% of the industry was using the [same CRM platform](https://www.salesforce.com/news/stories/idc-crm-market-share-ranking-2025/) they'd probably nod serenely, knowing that nobody ever got fired for buying IBM. 7 + 8 + You might surprise them, though that these platforms were all available over the internet and run in a web browser (admittedly, they wouldn't know what that is [because it hadn't been invented yet](https://en.wikipedia.org/wiki/Web_browser#History)). Jump forward to the late 90s and the idea that over [60% of Americans got their news, weather, chat, and online forums](https://en.wikipedia.org/wiki/Facebook#Userbase) from a single provider would also have probably been met with an impressed nod towards the survivability of America Online. 9 + 10 + Of course, the dominant platforms of today aren't the same ones that seemed eternal thirty-odd years ago. Things do change, and I think the cyclic nature of these changes is worth ruminating on. I tend to subscribe to the idea that the driver behind these changes is mostly an economic, rather than technical, one. Wal-Mart didn't decide to in-house their IT and build a world-class logistics platform because it was a good idea, they did it because it was cheaper to do so than the alternative -- at least, in net. AOL didn't fall out of favor just because the World Wide Web opened the doors to a world of publishing and self-expression, but because the economics of building the experience you wanted on the Internet outweighed the network effect of email and chat. These aren't 100% pure rational effects, obviously. I'm not an economist, there's a lot more here than the surface level, but I think it's worth remembering that these trends were shaped, ultimately, by tradeoffs around time. 11 + 12 + ## Time, Tides, and Abstractions 13 + 14 + It's been quipped that 'all code is technical debt', which is true. Every LOC you add increases your maintenance burden, and expands the surface area required to operate software at scale. Small issues or flaws in interface design, architecture, abstraction boundaries, etc. will accrete over time like barnacles on a ship's hull. As an industry, we've made this worse through decades of convenient abstractions (especially around hardware!), trading away understanding for ever-faster delivery of features. Little wonder, perhaps, that [people keep blowing off their foot with surprise charges](https://www.reddit.com/r/nextjs/comments/12dngvg/small_mistake_leads_to_3000_bill_from_vercel_and/). We've made it very easy for anyone to build economically useful stuff, but we've traded away our ability to really own what we build, and how we run it. For what it's worth, I think it's good that we've made hardware easy! It's good that we've made it easier to program. There's value in abstraction, there's value in making the internet and applications more accessible. What I think is a _mistake_, though, is that we've become over-reliant on platforms rather than on the underlying protocols. We're building, as an industry, at the wrong layer of abstraction. 15 + 16 + This loss of ownership shows up most dramatically in discovery. The easiest example is, of course, the Apple App Store -- in most markets, if you're not on the App Store, your application simply doesn't exist. While the glory days of Facebook Games may have passed, a significant amount of many products strategy goes straight thru Meta's policy and APIs. If you're making games, [better hope payment processors don't get pressured into thinking your content is smut](https://www.ign.com/articles/mastercard-denies-it-pressured-steam-itchio-to-delist-adult-games) or else you're shit out of luck because -- again -- you don't exist without Steam or Itch or any other marketplace. Similarly, if you're a B2B application, enjoy the _thrilling_ experience of the AWS/GCP/Azure Marketplace and pray to god that a bored PM doesn't decide that they're gonna directly compete with your solution. Want to innovate in the world of CRM? Better hope whatever you're doing integrates with Salesforce! 17 + 18 + To simplify, we've made it easier than ever to _do something_ but we've made it harder to really interpret what's going on. We've built all of these abstractions, but they're all built on top of middlemen who would like their 30% cut, please. It's not great! 19 + 20 + ## The Part About AI 21 + 22 + Remember where I said that the economic incentive behind these earlier shifts was driven by time tradeoffs? If you were Wal-Mart, it was worth your time to [build your own retail logistics platform](https://anthonysmoak.com/2016/07/21/more-than-you-want-to-know-about-wal-marts-technology-strategy-part-1/) so you could put the screws to suppliers, optimize your logistics, and eventually come to dominate American retail. If you were an internet user, why pay for AOL when [you could get Netscape or IE for free?](https://thehistoryoftheweb.com/browser-wars/) _and_ go to all sorts of pages? Did AOL have the Hamster Dance? I think not. I would argue that platforms rise and fall based on this implicit (or explicit) time economics. When time is expensive, platforms do better; When time is cheap, they do worse. 23 + 24 + AI makes time very, very, _very_ cheap. It's not unreasonable to expect that within ~5 years, we'll have consumer-grade hardware with onboard capabilities that rival current state-of-the-art models (Claude 4, etc.) that are _also_ faster and cheaper than those models are today. This is an absolute sea change in terms of capability at an OS level; Custom applications _will_ become the norm, not the exception. Why try to grapple with fitting my life into Notion (or whatever) when I can just have the computer build me bespoke applications that work on all my devices and are catered to my precise needs? Why do I need planet-scale infrastructure to share baby photos with, like, 5 people? 25 + 26 + This goes for business as well; Why do I need a legion of Salesforce consultants to make their shit work with my shit when I can just have the AI write all the reports I'll ever need? Same thing for HRIS, ERP, and dozens of other fields. 27 + 28 + The great vibe shift in software is going to take place on these battlegrounds - not the locked-down platforms of today, but the ocean of data management and access of tomorrow. In this, I believe we'll see small start to win again. Small, custom programs for individuals, families, teams -- with data sharing, discovery, and management built on open protocols. Things like [ATProto](https://atproto.com/) and some of the other interesting outgrowths of crypto are lights in the darkness here, imo. There's other stuff too -- Tailscale, for instance, and the ease of creating small private networks. There's more to be done; Discovery is a huge one, indexing is another, private content is a third. We also, critically, need standards work and protocol work to be elevated in both speed _and_ visibility. This is an area where we, as technologists, can have a huge impact -- it's time for us to act like it and act accordingly.
+36
internal/markdown/testdata/posts/wide-events.md
··· 1 + --- 2 + title: "Wide Events, Personal Software, and You." 3 + date: "2025-04-17" 4 + --- 5 + 6 + I recently built a fun little project called [777-BSKY](https://777bsky.fly.dev). It looks at Bluesky trending topics, does some math, figures out what's most popular and slaps some TTS on it. You can call a phone number and have the output read back to you, kinda like Moviefone except all of the movies are talking about the twilight of the American experiment. 7 + 8 + That said, I'm actually not writing this to talk about the project, but a realization I had while writing it about observability. Specifically, this project made me realize the value of wide events and where they fit into software. 9 + 10 + ## What's a Wide Event? 11 + 12 + It's what it sounds like, more or less. A single structured log with tens, hundreds, or thousands of dimensions and practically infinite cardinality on those dimensions. I've long been skeptical of wide events for production/line of business systems for a few reasons: 13 + 14 + 1. Most production systems are _complex_, and understanding performance requires understanding the relationship between dependencies. The interesting stuff in your system is often obscured through layers of abstraction, even in a single service, and a single event per service often misses useful stuff. 15 + 16 + 2. Data hygiene matters a lot in production. Semantic drift is a real pain to deal with, and standardizing metadata on events either requires that you own your entire stack (vanishingly likely unless you're in an extremely large organization that can dedicate people to internal framework development), or that you don't really care about what's happening outside of your team (which is a Conway's Law shaped problem). When other people own your instrumentation, you don't really get a chance to say what they should use, and you can't rely on everyone else adopting your data model. 17 + 18 + 3. Events, by themselves, don't imply relationships. I think a lot of folks would like to have their cake and eat it too when it comes to the relationship between spans and events. Spans have some very explicit guarantees around both duration and heirarchies that events, by themselves, lack. When you're working with a distributed system, these are very nice guarantees to have! 19 + 20 + There's a secret, bonus, fourth thing that I think really devalues wide events -- they're good in prod and bad in dev. Even for highly async code, your mental model of a program is usually a linear one; Maybe a tree, with branches flying this way and that, but fundamentally you think of things as beginning and ending. You start a loop, you call some functions, there's an order to it that's extremely appealing to the part of my brain that likes lining up all of the sheets of paper in a stack. Wide events, more or less, smoosh this stack down into one (or a handful) of aggregates. I don't need a billion dimensions when I'm writing software on my laptop; Most of those dimensions are known, because I control them! Logging's enduring popularity is buttressed by this local development loop. Traces are somewhat better here, although the local development experience with them is still pretty bad -- however, you can more easily realize value from it through local visualizations. Wide Events? They sit in a weird spot in this heirarchy. If you think of them as spans and traces, then why go wide? Create them at logical boundaries in the code rather than at the oh-so-arbitrary cliff of a 'service'. If you think of them as structured logs, then it's a little better -- but you're not really getting all of the benefits since your debugging data is locked behind debug or trace level loginfo that will never get turned on in production. 21 + 22 + I could probably write a whole book about the failures of the observability tooling space and their inability to solve developer pain points, but that's a different blog. 23 + 24 + ## I Write Events Not Tragedies 25 + 26 + Wide Events are a painterly construct more than an industrial one. Consider that electrification took decades to become truly ubitquious; Industrial adoption of electricity was concomitant with the production line. Thankfully we have a very good example of this in the software industry today -- vibe coding, and the return of personal software. 27 + 28 + What do I mean by 'personal software'? What it says on the tin. Software that you write for yourself to solve your problems. I think in many ways we'll see the early 2020's as the apex of Big Platform -- massive, sprawling, centralized suites that you lived your digital life out of. AI lowers the barrier to entry dramatically for individuals to write software that solves _their_ needs, fit to _their_ use cases, and built to _their_ requirements. 29 + 30 + This does mean, however, that we'll need better ways to observe that software. We'll have an entire new generation of developers, running code and dealing with operations for the first time. We'll need clear, explainable, and idiomatic ways of describing what this software does and how it fits together. This, I think, is where we'll discover the value of wide events. 31 + 32 + If you look at 777-BSKY, it uses tracing, but not in the way you'd expect. It's not building deep and complex traces for every operation; Most of them only emit a single span. It has detailed logging for local development as well. I think it's a lot more useful this way, though! I didn't need to putz around with the AI and have it create metrics, or complex traces. It was actually a lot easier to tell it "hey, I just want a single span per operation" and it went and created a little helper library for it. Ironically enough, it probably had a better idea of how to create 'wide events' because what writing exists on it is much more focused. 33 + 34 + ## Putting It Together 35 + 36 + Honestly, I have more questions than answers at the end of this project. I'm more concerned than I was before about the accessibility of observability tooling to new developers. I believe the real challenge arising from AI assistance is going to be around deployment and operation of code, moreso than the creation and maintenence of it. I'm increasingly convinced that we're in the twilight of platforms for everything from social networking to business suites and CRMs. I still don't think wide events are that useful for most business software -- but I think they might be, because business software is also going to change. Whatever comes next, it's gonna be interesting.
+104
internal/ops/doctor.go
··· 1 + package ops 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "crypto/x509" 7 + "fmt" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "aparker.io/fair/internal/atproto" 13 + "aparker.io/fair/internal/config" 14 + "aparker.io/fair/internal/publish" 15 + ) 16 + 17 + // Check is one row of the doctor output. OK reports whether the check 18 + // passed; Detail explains what was checked or what failed. 19 + type Check struct { 20 + Name string 21 + OK bool 22 + Detail string 23 + } 24 + 25 + // DoctorOptions configure what Doctor inspects. Pass the same store + 26 + // rootCAs the rest of the CLI uses so the auth check exercises the 27 + // real session. 28 + type DoctorOptions struct { 29 + Profile string 30 + Cfg *config.Config 31 + Store atproto.TokenStore 32 + RootCAs *x509.CertPool 33 + StateFile string 34 + } 35 + 36 + // Doctor runs a battery of health checks against the active profile. 37 + // Returns the list of checks performed; callers print + decide overall 38 + // exit status. 39 + func Doctor(ctx context.Context, opts DoctorOptions) []Check { 40 + checks := []Check{} 41 + 42 + // 1. Config sanity. Already loaded so this is a tautology, but 43 + // surfacing as a step gives operators a clear progress feed. 44 + checks = append(checks, Check{ 45 + Name: "config loaded", 46 + OK: true, 47 + Detail: fmt.Sprintf("profile=%s pds=%s domain=%s", opts.Profile, opts.Cfg.PDSURL, opts.Cfg.Domain), 48 + }) 49 + 50 + hc := &http.Client{Timeout: 10 * time.Second} 51 + if opts.RootCAs != nil { 52 + t := http.DefaultTransport.(*http.Transport).Clone() 53 + t.TLSClientConfig = &tls.Config{RootCAs: opts.RootCAs} 54 + hc.Transport = t 55 + } 56 + 57 + // 2. PDS reachable. 58 + pdsHealth := strings.TrimRight(opts.Cfg.PDSURL, "/") + "/xrpc/_health" 59 + checks = append(checks, httpCheck(ctx, hc, "PDS reachable", pdsHealth)) 60 + 61 + // 3. Publication record present. 62 + pubURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=site.standard.publication&rkey=self", 63 + strings.TrimRight(opts.Cfg.PDSURL, "/"), opts.Cfg.DID) 64 + checks = append(checks, httpCheck(ctx, hc, "publication record exists", pubURL)) 65 + 66 + // 4. Auth session present and not expired. 67 + session, err := opts.Store.Load(opts.Profile) 68 + switch { 69 + case err != nil: 70 + checks = append(checks, Check{Name: "auth session", OK: false, Detail: "no session — run `fair auth login`"}) 71 + case session.IsExpired(time.Now()): 72 + checks = append(checks, Check{Name: "auth session", OK: false, Detail: "session expired — re-run `fair auth login`"}) 73 + default: 74 + checks = append(checks, Check{Name: "auth session", OK: true, Detail: fmt.Sprintf("did=%s expires=%s", session.DID, session.ExpiresAt.Format(time.RFC3339))}) 75 + } 76 + 77 + // 5. State.json consistency. 78 + state, err := publish.LoadState(opts.StateFile) 79 + if err != nil { 80 + checks = append(checks, Check{Name: "local state", OK: false, Detail: err.Error()}) 81 + } else { 82 + checks = append(checks, Check{Name: "local state", OK: true, Detail: fmt.Sprintf("%d entries", len(state.Entries))}) 83 + } 84 + 85 + return checks 86 + } 87 + 88 + // httpCheck performs a HEAD/GET against url and reports the result as 89 + // a Check. 90 + func httpCheck(ctx context.Context, hc *http.Client, name, url string) Check { 91 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 92 + if err != nil { 93 + return Check{Name: name, OK: false, Detail: err.Error()} 94 + } 95 + resp, err := hc.Do(req) 96 + if err != nil { 97 + return Check{Name: name, OK: false, Detail: err.Error()} 98 + } 99 + defer resp.Body.Close() 100 + if resp.StatusCode >= 200 && resp.StatusCode < 300 { 101 + return Check{Name: name, OK: true, Detail: fmt.Sprintf("HTTP %d", resp.StatusCode)} 102 + } 103 + return Check{Name: name, OK: false, Detail: fmt.Sprintf("HTTP %d", resp.StatusCode)} 104 + }
+207
internal/ops/ls.go
··· 1 + // Package ops implements the operational livability commands the 2 + // publish/build flow needs to be pleasant: ls (PDS vs local diff), 3 + // doctor (health check), pull (PDS -> markdown recovery), unpublish 4 + // (delete a record), rename (atomic re-rkey). 5 + package ops 6 + 7 + import ( 8 + "context" 9 + "encoding/json" 10 + "errors" 11 + "fmt" 12 + "io/fs" 13 + "os" 14 + "path/filepath" 15 + "sort" 16 + 17 + "aparker.io/fair/internal/atproto" 18 + "aparker.io/fair/internal/markdown" 19 + "aparker.io/fair/internal/publish" 20 + ) 21 + 22 + // LsRow is one row of the `fair ls` output. Status reflects whether 23 + // each post is on PDS only, local only, in both, or in conflict. 24 + type LsRow struct { 25 + Status string // "synced" | "pds-only" | "local-only" | "conflict" 26 + Rkey string // rkey on PDS (empty for local-only) 27 + ID string // ULID from frontmatter or PDS record (empty when missing) 28 + Title string // best-known title 29 + Path string // local file path (empty for pds-only) or PDS path 30 + Detail string // free-form note (e.g., conflict description) 31 + } 32 + 33 + // Ls compares the local blog-posts/ tree against the PDS 34 + // site.standard.document collection. Source of truth for identity is 35 + // the ULID id in frontmatter (local) and the matching state.json entry 36 + // (PDS-side rkey lookup). 37 + // 38 + // Returned rows are sorted: synced first, then conflicts, pds-only, 39 + // local-only. 40 + func Ls(ctx context.Context, client *atproto.Client, blogPosts, stateFile string) ([]LsRow, error) { 41 + state, err := publish.LoadState(stateFile) 42 + if err != nil { 43 + return nil, err 44 + } 45 + 46 + // Load PDS records. 47 + listing, err := client.ListRecords(ctx, "site.standard.document", "", 100) 48 + if err != nil { 49 + return nil, fmt.Errorf("listRecords: %w", err) 50 + } 51 + pdsByRkey := map[string]pdsDoc{} 52 + for _, item := range listing.Records { 53 + var v struct { 54 + Title string `json:"title"` 55 + Path string `json:"path"` 56 + } 57 + _ = json.Unmarshal(item.Value, &v) 58 + rkey := rkeyFromAtURI(item.URI) 59 + entry, _ := state.Entries[rkey] 60 + pdsByRkey[rkey] = pdsDoc{ 61 + URI: item.URI, 62 + Title: v.Title, 63 + Path: v.Path, 64 + ID: entry.ID, 65 + } 66 + } 67 + 68 + // Walk blog-posts/. 69 + localByID := map[string]localPost{} 70 + localUnclaimed := []localPost{} // no id in frontmatter yet 71 + if blogPosts != "" { 72 + err := filepath.WalkDir(blogPosts, func(p string, d fs.DirEntry, err error) error { 73 + if err != nil { 74 + return err 75 + } 76 + if d.IsDir() || filepath.Base(p) != "index.md" { 77 + return nil 78 + } 79 + src, readErr := os.ReadFile(p) 80 + if readErr != nil { 81 + return nil 82 + } 83 + fm, _, parseErr := markdown.Parse(string(src)) 84 + if parseErr != nil { 85 + // Not a valid post; skip 86 + return nil 87 + } 88 + lp := localPost{Path: p, Title: fm.Title, ID: fm.ID} 89 + if fm.ID == "" { 90 + localUnclaimed = append(localUnclaimed, lp) 91 + } else { 92 + localByID[fm.ID] = lp 93 + } 94 + return nil 95 + }) 96 + if err != nil && !errors.Is(err, fs.ErrNotExist) { 97 + return nil, fmt.Errorf("walk blog-posts: %w", err) 98 + } 99 + } 100 + 101 + rows := []LsRow{} 102 + 103 + // Cross-reference by ID via state.json: state.Entries[rkey].ID 104 + // gives us the PDS record's ID; localByID gives us the local file's ID. 105 + for rkey, doc := range pdsByRkey { 106 + if doc.ID == "" { 107 + // PDS has the record but we don't have the ID locally 108 + // (state.json missing or older than this record). Show as 109 + // pds-only with a hint; doctor / pull would reconcile. 110 + rows = append(rows, LsRow{ 111 + Status: "pds-only", 112 + Rkey: rkey, 113 + Title: doc.Title, 114 + Path: doc.Path, 115 + Detail: "no local state entry — run `fair doctor` to rebuild state", 116 + }) 117 + continue 118 + } 119 + local, hasLocal := localByID[doc.ID] 120 + if hasLocal { 121 + rel, _ := filepath.Rel(blogPosts, local.Path) 122 + rows = append(rows, LsRow{ 123 + Status: "synced", 124 + Rkey: rkey, 125 + ID: doc.ID, 126 + Title: doc.Title, 127 + Path: rel, 128 + }) 129 + delete(localByID, doc.ID) 130 + } else { 131 + rows = append(rows, LsRow{ 132 + Status: "pds-only", 133 + Rkey: rkey, 134 + ID: doc.ID, 135 + Title: doc.Title, 136 + Path: doc.Path, 137 + Detail: "local source missing — `fair pull` to recover or `fair unpublish` to delete", 138 + }) 139 + } 140 + } 141 + 142 + // Anything left in localByID is local-only (id was assigned but 143 + // the record isn't on PDS, e.g., never published or deleted). 144 + for id, lp := range localByID { 145 + rel, _ := filepath.Rel(blogPosts, lp.Path) 146 + rows = append(rows, LsRow{ 147 + Status: "local-only", 148 + ID: id, 149 + Title: lp.Title, 150 + Path: rel, 151 + Detail: "id present but no PDS record — run `fair publish`", 152 + }) 153 + } 154 + for _, lp := range localUnclaimed { 155 + rel, _ := filepath.Rel(blogPosts, lp.Path) 156 + rows = append(rows, LsRow{ 157 + Status: "local-only", 158 + Title: lp.Title, 159 + Path: rel, 160 + Detail: "no id assigned — first `fair publish` will create one", 161 + }) 162 + } 163 + 164 + sort.SliceStable(rows, func(i, j int) bool { 165 + return statusRank(rows[i].Status) < statusRank(rows[j].Status) 166 + }) 167 + 168 + return rows, nil 169 + } 170 + 171 + func statusRank(s string) int { 172 + switch s { 173 + case "synced": 174 + return 0 175 + case "conflict": 176 + return 1 177 + case "pds-only": 178 + return 2 179 + case "local-only": 180 + return 3 181 + } 182 + return 99 183 + } 184 + 185 + type pdsDoc struct { 186 + URI string 187 + Title string 188 + Path string 189 + ID string 190 + } 191 + 192 + type localPost struct { 193 + Path string 194 + Title string 195 + ID string 196 + } 197 + 198 + // rkeyFromAtURI returns the rkey portion of an at:// URI like 199 + // at://did:.../site.standard.document/<rkey>. 200 + func rkeyFromAtURI(uri string) string { 201 + for i := len(uri) - 1; i >= 0; i-- { 202 + if uri[i] == '/' { 203 + return uri[i+1:] 204 + } 205 + } 206 + return uri 207 + }
+31
internal/ops/migrate.go
··· 1 + package ops 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "aparker.io/fair/internal/atproto" 8 + ) 9 + 10 + // CleanupWhtwnd deletes all com.whtwnd.blog.entry records in the 11 + // authenticated user's repo. Used after the cutover to standard.site 12 + // is verified — leaves the PDS clean of the prior lexicon. 13 + // 14 + // Safe to run multiple times; deleteRecord is idempotent on missing. 15 + // Returns the count of records that were deleted. 16 + func CleanupWhtwnd(ctx context.Context, client *atproto.Client) (int, error) { 17 + listing, err := client.ListRecords(ctx, "com.whtwnd.blog.entry", "", 100) 18 + if err != nil { 19 + return 0, fmt.Errorf("listRecords: %w", err) 20 + } 21 + 22 + count := 0 23 + for _, item := range listing.Records { 24 + rkey := rkeyFromAtURI(item.URI) 25 + if err := client.DeleteRecord(ctx, "com.whtwnd.blog.entry", rkey); err != nil { 26 + return count, fmt.Errorf("delete %s: %w", rkey, err) 27 + } 28 + count++ 29 + } 30 + return count, nil 31 + }
+35
internal/ops/unpublish.go
··· 1 + package ops 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "aparker.io/fair/internal/atproto" 8 + "aparker.io/fair/internal/publish" 9 + ) 10 + 11 + // Unpublish deletes the site.standard.document record at rkey from the 12 + // PDS and removes the corresponding entry from state.json. The local 13 + // markdown file is NOT touched — by convention the source is a working 14 + // copy and the user decides what to do with it. 15 + // 16 + // Idempotent: deleting a missing record is silently OK. 17 + func Unpublish(ctx context.Context, client *atproto.Client, rkey, stateFile string) error { 18 + if rkey == "" { 19 + return fmt.Errorf("unpublish: rkey required") 20 + } 21 + if err := client.DeleteRecord(ctx, "site.standard.document", rkey); err != nil { 22 + return fmt.Errorf("delete %s: %w", rkey, err) 23 + } 24 + state, err := publish.LoadState(stateFile) 25 + if err != nil { 26 + return err 27 + } 28 + if _, ok := state.Entries[rkey]; ok { 29 + delete(state.Entries, rkey) 30 + if err := state.Save(stateFile); err != nil { 31 + return fmt.Errorf("update state: %w", err) 32 + } 33 + } 34 + return nil 35 + }
+101
internal/publish/identity.go
··· 1 + // Package publish implements the markdown -> standard.site PDS record 2 + // pipeline. The orchestrator (Publish in publish.go) drives the 13-step 3 + // flow documented in docs/design-plans/2026-04-29-blog-v2026.md §3. 4 + package publish 5 + 6 + import ( 7 + "crypto/rand" 8 + "fmt" 9 + "os" 10 + "strings" 11 + "time" 12 + 13 + "aparker.io/fair/internal/markdown" 14 + "github.com/oklog/ulid/v2" 15 + ) 16 + 17 + // EnsureID returns the post's stable ULID identity, writing it back into 18 + // the source markdown frontmatter if missing. This is the only place the 19 + // CLI mutates files in blog-posts/ and the only thing it writes there. 20 + // 21 + // The ID is load-bearing for rename detection: identity follows the ID, 22 + // not the file path. Once written, the file is "owned" by the publish 23 + // pipeline; subsequent runs use the same ID even if the file moves. 24 + func EnsureID(path string) (string, error) { 25 + src, err := os.ReadFile(path) 26 + if err != nil { 27 + return "", fmt.Errorf("read %s: %w", path, err) 28 + } 29 + fm, body, err := markdown.Parse(string(src)) 30 + if err != nil { 31 + return "", fmt.Errorf("parse %s: %w", path, err) 32 + } 33 + if fm.ID != "" { 34 + return fm.ID, nil 35 + } 36 + 37 + id, err := generateULID() 38 + if err != nil { 39 + return "", err 40 + } 41 + updated, err := writeBackID(string(src), body, id) 42 + if err != nil { 43 + return "", err 44 + } 45 + // Atomic write: temp file + rename in same dir. 46 + tmp := path + ".tmp" 47 + if err := os.WriteFile(tmp, []byte(updated), 0o644); err != nil { 48 + return "", fmt.Errorf("write %s: %w", tmp, err) 49 + } 50 + if err := os.Rename(tmp, path); err != nil { 51 + _ = os.Remove(tmp) 52 + return "", fmt.Errorf("rename %s -> %s: %w", tmp, path, err) 53 + } 54 + return id, nil 55 + } 56 + 57 + // generateULID produces a Crockford-base32 ULID using the current 58 + // monotonic time + random entropy. 59 + func generateULID() (string, error) { 60 + t := ulid.Timestamp(time.Now()) 61 + id, err := ulid.New(t, rand.Reader) 62 + if err != nil { 63 + return "", fmt.Errorf("generate ULID: %w", err) 64 + } 65 + return id.String(), nil 66 + } 67 + 68 + // writeBackID inserts an `id:` line into the YAML frontmatter of src. 69 + // The body is the previously-parsed body slice so we can reconstruct 70 + // the file deterministically. 71 + // 72 + // The frontmatter is reconstructed by splitting the original src around 73 + // the closing fence and inserting "id: <ULID>" as the first frontmatter 74 + // line. We don't re-marshal via yaml.v3 because that would lose comment 75 + // preservation and quote-style choices the user made. 76 + func writeBackID(src, body, id string) (string, error) { 77 + const fence = "---\n" 78 + rest, ok := strings.CutPrefix(src, fence) 79 + if !ok { 80 + // Some files might use \r\n; try that too. 81 + rest, ok = strings.CutPrefix(src, "---\r\n") 82 + if !ok { 83 + return "", fmt.Errorf("source does not start with --- frontmatter fence") 84 + } 85 + } 86 + closingIdx := strings.Index(rest, "\n---") 87 + if closingIdx < 0 { 88 + return "", fmt.Errorf("missing closing frontmatter fence") 89 + } 90 + fmYAML := rest[:closingIdx] 91 + tail := rest[closingIdx:] // starts with "\n---" 92 + 93 + // Insert id as the first key. Trailing newline ensured. 94 + newYAML := "id: " + id + "\n" + fmYAML 95 + if !strings.HasSuffix(newYAML, "\n") { 96 + newYAML += "\n" 97 + } 98 + _ = body // body unused — we keep the original tail so closing fence 99 + // and any blank line between fence and body stay byte-identical. 100 + return fence + newYAML + tail + "\n", nil 101 + }
+106
internal/publish/identity_test.go
··· 1 + package publish_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "aparker.io/fair/internal/publish" 10 + ) 11 + 12 + func TestEnsureID_GeneratesWhenMissing(t *testing.T) { 13 + dir := t.TempDir() 14 + path := filepath.Join(dir, "post.md") 15 + if err := os.WriteFile(path, []byte(`--- 16 + title: "Hello" 17 + date: "2025-04-17" 18 + --- 19 + 20 + body 21 + `), 0o644); err != nil { 22 + t.Fatal(err) 23 + } 24 + 25 + id, err := publish.EnsureID(path) 26 + if err != nil { 27 + t.Fatalf("EnsureID: %v", err) 28 + } 29 + if id == "" { 30 + t.Fatal("got empty id") 31 + } 32 + // ULID is 26 chars, Crockford base32 33 + if len(id) != 26 { 34 + t.Errorf("id length: got %d, want 26", len(id)) 35 + } 36 + 37 + // File now contains the id 38 + got, _ := os.ReadFile(path) 39 + if !strings.Contains(string(got), "id: "+id) { 40 + t.Errorf("frontmatter missing 'id: %s' after write-back:\n%s", id, string(got)) 41 + } 42 + } 43 + 44 + func TestEnsureID_PreservesExisting(t *testing.T) { 45 + dir := t.TempDir() 46 + path := filepath.Join(dir, "post.md") 47 + original := `--- 48 + id: 01HXEXISTING000000000000000 49 + title: Hello 50 + date: "2025-04-17" 51 + --- 52 + 53 + body 54 + ` 55 + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { 56 + t.Fatal(err) 57 + } 58 + 59 + id, err := publish.EnsureID(path) 60 + if err != nil { 61 + t.Fatal(err) 62 + } 63 + if id != "01HXEXISTING000000000000000" { 64 + t.Errorf("id changed: got %q, want existing", id) 65 + } 66 + 67 + // File is byte-identical (no rewrite when id was present) 68 + got, _ := os.ReadFile(path) 69 + if string(got) != original { 70 + t.Errorf("file rewritten unnecessarily:\nwant:\n%s\ngot:\n%s", original, string(got)) 71 + } 72 + } 73 + 74 + func TestEnsureID_WriteBackPreservesBody(t *testing.T) { 75 + dir := t.TempDir() 76 + path := filepath.Join(dir, "post.md") 77 + body := "body line 1\nbody line 2\n\nfinal paragraph\n" 78 + if err := os.WriteFile(path, []byte(`--- 79 + title: "Hello" 80 + date: "2025-04-17" 81 + --- 82 + 83 + `+body), 0o644); err != nil { 84 + t.Fatal(err) 85 + } 86 + 87 + if _, err := publish.EnsureID(path); err != nil { 88 + t.Fatal(err) 89 + } 90 + 91 + got, _ := os.ReadFile(path) 92 + if !strings.Contains(string(got), body) { 93 + t.Errorf("body lost during write-back:\n%s", string(got)) 94 + } 95 + } 96 + 97 + func TestEnsureID_RejectsMissingTitle(t *testing.T) { 98 + dir := t.TempDir() 99 + path := filepath.Join(dir, "post.md") 100 + os.WriteFile(path, []byte("---\ndate: \"2025-04-17\"\n---\n\nbody\n"), 0o644) 101 + 102 + _, err := publish.EnsureID(path) 103 + if err == nil { 104 + t.Fatal("expected error for missing title") 105 + } 106 + }
+143
internal/publish/images.go
··· 1 + package publish 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "net/url" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + // RewriteImages finds inline images in the markdown body, copies their 13 + // source files to <mirrorDir>/<slug>/<basename>, and replaces the 14 + // references in the body with /images/<slug>/<basename>. 15 + // 16 + // Already-absolute URLs (http:// or https://) and other non-relative 17 + // paths are left untouched. The returned body is byte-stable across 18 + // repeated calls with the same inputs (idempotent). 19 + // 20 + // Image refs are detected by simple regex-style scanning of `![alt](path)` 21 + // — the markdown AST would catch them more cleanly but goldmark doesn't 22 + // emit position offsets for in-place rewriting. The pattern is well- 23 + // defined enough that the regex matches what goldmark sees. 24 + func RewriteImages(body, slug, sourceDir, mirrorDir string) (string, error) { 25 + // Walk the body looking for ![alt](url) patterns. We do this byte- 26 + // by-byte rather than with regexp.ReplaceAllStringFunc because we 27 + // want to error on unfindable images (regex would silently keep them). 28 + var out strings.Builder 29 + out.Grow(len(body)) 30 + 31 + i := 0 32 + for i < len(body) { 33 + // Look for an image start: "![" preceded by neither "\\" nor "!". 34 + startMarker := strings.Index(body[i:], "![") 35 + if startMarker < 0 { 36 + out.WriteString(body[i:]) 37 + break 38 + } 39 + startMarker += i 40 + 41 + // Find the matching ']' for the alt text. 42 + altEnd := strings.IndexByte(body[startMarker+2:], ']') 43 + if altEnd < 0 || startMarker+2+altEnd+1 >= len(body) || body[startMarker+2+altEnd+1] != '(' { 44 + // Not an image expression — emit literally and continue past "!". 45 + out.WriteString(body[i : startMarker+1]) 46 + i = startMarker + 1 47 + continue 48 + } 49 + altText := body[startMarker+2 : startMarker+2+altEnd] 50 + 51 + // Find the matching ')' for the URL. 52 + urlStart := startMarker + 2 + altEnd + 2 // past "](" 53 + urlEnd := strings.IndexByte(body[urlStart:], ')') 54 + if urlEnd < 0 { 55 + out.WriteString(body[i:startMarker]) 56 + out.WriteString(body[startMarker:]) 57 + i = len(body) 58 + break 59 + } 60 + rawURL := body[urlStart : urlStart+urlEnd] 61 + 62 + // Emit text up to the start of this image. 63 + out.WriteString(body[i:startMarker]) 64 + 65 + // Decide whether to rewrite. 66 + if isAbsoluteURL(rawURL) { 67 + // Untouched. 68 + out.WriteString(body[startMarker : urlStart+urlEnd+1]) 69 + } else { 70 + rewritten, err := copyAndRewrite(rawURL, altText, slug, sourceDir, mirrorDir) 71 + if err != nil { 72 + return "", err 73 + } 74 + out.WriteString(rewritten) 75 + } 76 + i = urlStart + urlEnd + 1 77 + } 78 + return out.String(), nil 79 + } 80 + 81 + // isAbsoluteURL returns true if u parses as an absolute URL with a scheme. 82 + // Relative paths and bare anchors return false. 83 + func isAbsoluteURL(u string) bool { 84 + parsed, err := url.Parse(u) 85 + if err != nil { 86 + return false 87 + } 88 + return parsed.IsAbs() 89 + } 90 + 91 + // copyAndRewrite resolves a relative image ref against sourceDir, copies 92 + // it to mirrorDir/slug/<basename>, and returns the rewritten markdown 93 + // "![alt](/images/slug/basename)" snippet. 94 + func copyAndRewrite(rawURL, altText, slug, sourceDir, mirrorDir string) (string, error) { 95 + src := filepath.Join(sourceDir, rawURL) 96 + if _, err := os.Stat(src); err != nil { 97 + return "", fmt.Errorf("image %q not found at %s: %w", rawURL, src, err) 98 + } 99 + base := filepath.Base(rawURL) 100 + destDir := filepath.Join(mirrorDir, slug) 101 + if err := os.MkdirAll(destDir, 0o755); err != nil { 102 + return "", fmt.Errorf("ensure mirror dir %s: %w", destDir, err) 103 + } 104 + dest := filepath.Join(destDir, base) 105 + if err := copyFileIdempotent(src, dest); err != nil { 106 + return "", err 107 + } 108 + return fmt.Sprintf("![%s](/images/%s/%s)", altText, slug, base), nil 109 + } 110 + 111 + // copyFileIdempotent copies src to dest. If dest already exists with 112 + // identical contents, it's a no-op (preserves mtime so dist re-deploys 113 + // don't think every image changed). Otherwise the copy overwrites. 114 + func copyFileIdempotent(src, dest string) error { 115 + srcData, err := os.ReadFile(src) 116 + if err != nil { 117 + return fmt.Errorf("read %s: %w", src, err) 118 + } 119 + if existing, err := os.ReadFile(dest); err == nil && bytesEqual(existing, srcData) { 120 + return nil // identical — skip 121 + } 122 + if err := os.WriteFile(dest, srcData, 0o644); err != nil { 123 + return fmt.Errorf("write %s: %w", dest, err) 124 + } 125 + return nil 126 + } 127 + 128 + // bytesEqual is a thin wrapper to keep the io.ReadAll-based shape if we 129 + // ever switch to streaming for large files. 130 + func bytesEqual(a, b []byte) bool { 131 + if len(a) != len(b) { 132 + return false 133 + } 134 + for i := range a { 135 + if a[i] != b[i] { 136 + return false 137 + } 138 + } 139 + return true 140 + } 141 + 142 + // readAll is kept around as a placeholder for streaming if needed later. 143 + var _ = io.ReadAll
+128
internal/publish/images_test.go
··· 1 + package publish_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "aparker.io/fair/internal/publish" 10 + ) 11 + 12 + func writePNG(t *testing.T, path string, content string) { 13 + t.Helper() 14 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 15 + t.Fatal(err) 16 + } 17 + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { 18 + t.Fatal(err) 19 + } 20 + } 21 + 22 + func TestRewriteImages_RelativeImageCopiedAndRewritten(t *testing.T) { 23 + sourceDir := t.TempDir() 24 + mirrorDir := t.TempDir() 25 + writePNG(t, filepath.Join(sourceDir, "images/foo.png"), "fake-png") 26 + 27 + body := "Look: ![alt](images/foo.png) end\n" 28 + got, err := publish.RewriteImages(body, "wide-events", sourceDir, mirrorDir) 29 + if err != nil { 30 + t.Fatalf("RewriteImages: %v", err) 31 + } 32 + if !strings.Contains(got, "/images/wide-events/foo.png") { 33 + t.Errorf("body not rewritten:\n%s", got) 34 + } 35 + if strings.Contains(got, "images/foo.png") && !strings.Contains(got, "/images/wide-events/foo.png") { 36 + t.Errorf("original path not replaced:\n%s", got) 37 + } 38 + 39 + mirrored := filepath.Join(mirrorDir, "wide-events", "foo.png") 40 + if _, err := os.Stat(mirrored); err != nil { 41 + t.Errorf("expected mirror at %s: %v", mirrored, err) 42 + } 43 + } 44 + 45 + func TestRewriteImages_AbsoluteURLsUntouched(t *testing.T) { 46 + body := "External: ![alt](https://example.com/foo.png)\n" 47 + got, err := publish.RewriteImages(body, "slug", t.TempDir(), t.TempDir()) 48 + if err != nil { 49 + t.Fatal(err) 50 + } 51 + if !strings.Contains(got, "https://example.com/foo.png") { 52 + t.Errorf("absolute URL changed:\n%s", got) 53 + } 54 + } 55 + 56 + func TestRewriteImages_MissingSourceFile_Errors(t *testing.T) { 57 + body := "![alt](missing/nope.png)\n" 58 + _, err := publish.RewriteImages(body, "slug", t.TempDir(), t.TempDir()) 59 + if err == nil { 60 + t.Fatal("expected error for missing image") 61 + } 62 + if !strings.Contains(err.Error(), "missing/nope.png") { 63 + t.Errorf("error %q should mention missing path", err.Error()) 64 + } 65 + } 66 + 67 + func TestRewriteImages_MultipleImages_AllCopied(t *testing.T) { 68 + sourceDir := t.TempDir() 69 + mirrorDir := t.TempDir() 70 + writePNG(t, filepath.Join(sourceDir, "images/a.png"), "a") 71 + writePNG(t, filepath.Join(sourceDir, "images/b.png"), "b") 72 + 73 + body := "![](images/a.png) and ![](images/b.png)\n" 74 + got, err := publish.RewriteImages(body, "post", sourceDir, mirrorDir) 75 + if err != nil { 76 + t.Fatal(err) 77 + } 78 + for _, want := range []string{"/images/post/a.png", "/images/post/b.png"} { 79 + if !strings.Contains(got, want) { 80 + t.Errorf("missing %s in:\n%s", want, got) 81 + } 82 + } 83 + for _, want := range []string{"a.png", "b.png"} { 84 + if _, err := os.Stat(filepath.Join(mirrorDir, "post", want)); err != nil { 85 + t.Errorf("missing mirror file %s: %v", want, err) 86 + } 87 + } 88 + } 89 + 90 + func TestRewriteImages_DuplicateReference_OneCopy(t *testing.T) { 91 + sourceDir := t.TempDir() 92 + mirrorDir := t.TempDir() 93 + writePNG(t, filepath.Join(sourceDir, "images/foo.png"), "x") 94 + 95 + body := "![](images/foo.png) and again ![](images/foo.png)\n" 96 + got, err := publish.RewriteImages(body, "post", sourceDir, mirrorDir) 97 + if err != nil { 98 + t.Fatal(err) 99 + } 100 + count := strings.Count(got, "/images/post/foo.png") 101 + if count != 2 { 102 + t.Errorf("expected 2 rewrites, got %d:\n%s", count, got) 103 + } 104 + // Only one mirror file 105 + entries, _ := os.ReadDir(filepath.Join(mirrorDir, "post")) 106 + if len(entries) != 1 { 107 + t.Errorf("expected 1 mirrored file, got %d", len(entries)) 108 + } 109 + } 110 + 111 + func TestRewriteImages_Idempotent(t *testing.T) { 112 + sourceDir := t.TempDir() 113 + mirrorDir := t.TempDir() 114 + writePNG(t, filepath.Join(sourceDir, "images/foo.png"), "content") 115 + 116 + body := "![](images/foo.png)\n" 117 + first, err := publish.RewriteImages(body, "post", sourceDir, mirrorDir) 118 + if err != nil { 119 + t.Fatal(err) 120 + } 121 + second, err := publish.RewriteImages(body, "post", sourceDir, mirrorDir) 122 + if err != nil { 123 + t.Fatalf("second call: %v", err) 124 + } 125 + if first != second { 126 + t.Errorf("non-idempotent:\nfirst: %s\nsecond: %s", first, second) 127 + } 128 + }
+301
internal/publish/publish.go
··· 1 + package publish 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "errors" 8 + "fmt" 9 + "io/fs" 10 + "mime" 11 + "os" 12 + "path/filepath" 13 + "strings" 14 + "time" 15 + 16 + "aparker.io/fair/internal/atproto" 17 + "aparker.io/fair/internal/markdown" 18 + ) 19 + 20 + // Options control the publish flow's behavior. 21 + type Options struct { 22 + // DryRun: parse, hash, and report what would happen, but never 23 + // upload blobs, putRecord, or update state.json. Source markdown 24 + // is still mutated to write back the ULID id (id assignment is 25 + // load-bearing across runs). 26 + DryRun bool 27 + 28 + // Force: bypass the content-hash short-circuit and re-put the 29 + // record even when nothing changed. Advances updatedAt. 30 + Force bool 31 + 32 + // Logger receives operator-facing progress strings. nil silences output. 33 + Logger func(format string, args ...any) 34 + } 35 + 36 + // Config bundles the per-call publish-flow inputs. 37 + type Config struct { 38 + // BlogPosts: root directory containing post directories (used as 39 + // the resolution base for inline image references). 40 + BlogPosts string 41 + 42 + // StateFile: path to state.<profile>.json. 43 + StateFile string 44 + 45 + // ImagesMirror: staging directory for inline images. RewriteImages 46 + // copies into <ImagesMirror>/<slug>/<basename>. 47 + ImagesMirror string 48 + 49 + // PublicationURI: the publication record's AT-URI, e.g. 50 + // at://did:plc:.../site.standard.publication/self. 51 + PublicationURI string 52 + 53 + // MaxCoverSize: byte cap on cover images. The site.standard.document 54 + // spec recommends 1MB; we enforce it at the client because PDS may 55 + // accept larger blobs but external consumers truncate. 56 + MaxCoverSize int64 57 + } 58 + 59 + // Result describes the outcome of one Publish call. 60 + type Result struct { 61 + Rkey string 62 + DID string 63 + URI string // at://did/collection/rkey from putRecord 64 + CID string 65 + Skipped bool // true when content hash was unchanged & not --force 66 + Reason string // why skipped, if applicable 67 + BlobUploaded bool 68 + UpdatedAt bool // true when updatedAt was advanced 69 + } 70 + 71 + // Publish runs the 13-step flow on a single markdown file. See 72 + // docs/design-plans/2026-04-29-blog-v2026.md §3 (publish flow) for the 73 + // step ordering rationale. 74 + func Publish(ctx context.Context, client *atproto.Client, sourcePath string, cfg Config, opts Options) (Result, error) { 75 + log := opts.Logger 76 + if log == nil { 77 + log = func(string, ...any) {} 78 + } 79 + 80 + // 1. Identity (writes id back to source if missing). 81 + id, err := EnsureID(sourcePath) 82 + if err != nil { 83 + return Result{}, fmt.Errorf("ensure id: %w", err) 84 + } 85 + 86 + // 2. Reread the (possibly mutated) source. 87 + src, err := os.ReadFile(sourcePath) 88 + if err != nil { 89 + return Result{}, fmt.Errorf("read %s: %w", sourcePath, err) 90 + } 91 + fm, body, err := markdown.Parse(string(src)) 92 + if err != nil { 93 + return Result{}, fmt.Errorf("parse: %w", err) 94 + } 95 + 96 + // 3. Slug + grammar. 97 + slug := fm.Slug 98 + if slug == "" { 99 + slug, err = SlugFromPath(sourcePath) 100 + if err != nil { 101 + return Result{}, err 102 + } 103 + } 104 + if err := ValidateSlug(slug); err != nil { 105 + return Result{}, err 106 + } 107 + 108 + // 4. State + rename detection. 109 + state, err := LoadState(cfg.StateFile) 110 + if err != nil { 111 + return Result{}, err 112 + } 113 + if existing, ok := state.RkeyByID(id); ok && existing != slug { 114 + return Result{}, fmt.Errorf("rename detected: id %s was published as rkey %q, now derives %q. Run `fair rename %s %s` to atomically delete-and-recreate", id, existing, slug, existing, slug) 115 + } 116 + 117 + // 5. Body normalization + image rewriting. 118 + body = markdown.Normalize(body) 119 + sourceDir := filepath.Dir(sourcePath) 120 + rewrittenBody, err := RewriteImages(body, slug, sourceDir, cfg.ImagesMirror) 121 + if err != nil { 122 + return Result{}, err 123 + } 124 + 125 + // 6. Description fallback + textContent. 126 + description := fm.Description 127 + if description == "" { 128 + description = ExtractDescription(rewrittenBody) 129 + } 130 + textContent := ExtractTextContent(rewrittenBody) 131 + 132 + // 7. Cover bytes + size check + source hash (pre-upload, so no 133 + // wasted upload on a no-op publish). 134 + var coverHash string 135 + var coverData []byte 136 + var coverMime string 137 + if fm.Cover != "" { 138 + coverPath := filepath.Join(sourceDir, fm.Cover) 139 + coverData, err = os.ReadFile(coverPath) 140 + if err != nil { 141 + return Result{}, fmt.Errorf("read cover %s: %w", coverPath, err) 142 + } 143 + if cfg.MaxCoverSize > 0 && int64(len(coverData)) > cfg.MaxCoverSize { 144 + return Result{}, fmt.Errorf("cover %s is %d bytes; max is %d. Convert/downscale and try again", coverPath, len(coverData), cfg.MaxCoverSize) 145 + } 146 + sum := sha256.Sum256(coverData) 147 + coverHash = hex.EncodeToString(sum[:]) 148 + coverMime = mime.TypeByExtension(strings.ToLower(filepath.Ext(coverPath))) 149 + if coverMime == "" { 150 + coverMime = "application/octet-stream" 151 + } 152 + } 153 + 154 + // 8. Compute hash + short-circuit. 155 + hash := markdown.Hash(markdown.HashInput{ 156 + NormalizedBody: rewrittenBody, 157 + Frontmatter: fm, 158 + CoverSourceHash: coverHash, 159 + }) 160 + prevEntry, hadEntry := state.Entries[slug] 161 + if hadEntry && prevEntry.LastHash == hash && !opts.Force { 162 + log("skip %s (hash unchanged)", slug) 163 + return Result{ 164 + Rkey: slug, 165 + DID: client.Session().DID, 166 + Skipped: true, 167 + Reason: "hash unchanged", 168 + }, nil 169 + } 170 + 171 + if opts.DryRun { 172 + log("would publish %s (force=%v, cover=%v, images-mirrored)", slug, opts.Force, fm.Cover != "") 173 + return Result{Rkey: slug, DID: client.Session().DID}, nil 174 + } 175 + 176 + // 9. Upload cover blob if size/contents changed. 177 + var coverBlob *atproto.BlobRef 178 + if coverData != nil { 179 + uploadNeeded := !hadEntry || prevEntry.LastCoverHash != coverHash || opts.Force 180 + if uploadNeeded { 181 + log("uploading cover (%d bytes, %s)", len(coverData), coverMime) 182 + coverBlob, err = client.UploadBlob(ctx, coverMime, coverData) 183 + if err != nil { 184 + return Result{}, fmt.Errorf("upload cover: %w", err) 185 + } 186 + } 187 + } 188 + 189 + // 10. Build record. 190 + publishedAt, err := normalizeRFC3339(fm.Date) 191 + if err != nil { 192 + return Result{}, fmt.Errorf("normalize publishedAt: %w", err) 193 + } 194 + record := map[string]any{ 195 + "$type": "site.standard.document", 196 + "site": cfg.PublicationURI, 197 + "title": fm.Title, 198 + "publishedAt": publishedAt, 199 + "path": "/" + slug, 200 + "content": map[string]any{ 201 + "$type": "site.standard.content.markdown", 202 + "text": rewrittenBody, 203 + "version": "1.0", 204 + }, 205 + "textContent": textContent, 206 + } 207 + if description != "" { 208 + record["description"] = description 209 + } 210 + if coverBlob != nil { 211 + record["coverImage"] = coverBlob 212 + } 213 + if len(fm.Tags) > 0 { 214 + record["tags"] = canonicalTags(fm.Tags) 215 + } 216 + updatedAtAdvanced := false 217 + if hadEntry && prevEntry.LastHash != hash { 218 + record["updatedAt"] = time.Now().UTC().Format(time.RFC3339) 219 + updatedAtAdvanced = true 220 + } 221 + 222 + // 11. PutRecord. 223 + log("putRecord %s/%s", "site.standard.document", slug) 224 + ref, err := client.PutRecord(ctx, "site.standard.document", slug, record) 225 + if err != nil { 226 + return Result{}, err 227 + } 228 + 229 + // 12. Persist state. (Image mirror copies happened in step 5; 230 + // orphan files on a put-success/state-save fail are harmless.) 231 + state.Entries[slug] = StateEntry{ 232 + ID: id, 233 + LastHash: hash, 234 + LastCoverHash: coverHash, 235 + LastPublishedAt: time.Now().UTC(), 236 + } 237 + if err := state.Save(cfg.StateFile); err != nil { 238 + return Result{}, fmt.Errorf("published OK but local state save failed: %w. Run `fair doctor` to recover", err) 239 + } 240 + 241 + return Result{ 242 + Rkey: slug, 243 + DID: client.Session().DID, 244 + URI: ref.URI, 245 + CID: ref.CID, 246 + BlobUploaded: coverBlob != nil, 247 + UpdatedAt: updatedAtAdvanced, 248 + }, nil 249 + } 250 + 251 + // canonicalTags returns a copy of tags lowercased, trimmed, deduped, 252 + // and sorted. Matches the Canonical()/Hash() normalization so the same 253 + // tag set always produces the same on-PDS list. 254 + func canonicalTags(in []string) []string { 255 + seen := map[string]struct{}{} 256 + out := make([]string, 0, len(in)) 257 + for _, t := range in { 258 + t = strings.ToLower(strings.TrimSpace(t)) 259 + if t == "" { 260 + continue 261 + } 262 + if _, ok := seen[t]; ok { 263 + continue 264 + } 265 + seen[t] = struct{}{} 266 + out = append(out, t) 267 + } 268 + // Caller may want stable order across runs; sort. 269 + for i := 1; i < len(out); i++ { 270 + for j := i; j > 0 && out[j-1] > out[j]; j-- { 271 + out[j-1], out[j] = out[j], out[j-1] 272 + } 273 + } 274 + return out 275 + } 276 + 277 + // normalizeRFC3339 accepts gray-matter-style date strings ("2025-04-17", 278 + // "2025-04-17T12:00:00Z", and a few common variations) and returns an 279 + // RFC3339 datetime suitable for the site.standard.document.publishedAt 280 + // field. Bare dates default to midnight UTC. 281 + func normalizeRFC3339(s string) (string, error) { 282 + s = strings.TrimSpace(s) 283 + if s == "" { 284 + return "", errors.New("empty date") 285 + } 286 + // Try a few accepted formats in order. 287 + for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02", "2006-01-02 15:04:05"} { 288 + if t, err := time.Parse(layout, s); err == nil { 289 + return t.UTC().Format(time.RFC3339), nil 290 + } 291 + } 292 + return "", fmt.Errorf("unrecognized date format %q (want RFC3339 or YYYY-MM-DD)", s) 293 + } 294 + 295 + // errIsNotExist is a small helper kept around to avoid shadowing the 296 + // stdlib name in places that don't import errors. 297 + func errIsNotExist(err error) bool { 298 + return errors.Is(err, fs.ErrNotExist) 299 + } 300 + 301 + var _ = errIsNotExist // future use: tightening cover-missing error class
+70
internal/publish/slug.go
··· 1 + package publish 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "path/filepath" 7 + "slices" 8 + "strings" 9 + ) 10 + 11 + // ReservedSlugs are paths/rkeys that would collide with site routing or 12 + // PDS conventions. ValidateSlug rejects them. 13 + var ReservedSlugs = []string{"self", "index", "feed", "sitemap", ".well-known"} 14 + 15 + // ValidateSlug returns nil if s matches the publish-flow slug grammar: 16 + // non-empty, lowercase ASCII letters/digits/hyphens, no double-hyphens, 17 + // no leading/trailing hyphen, not a reserved name. The slug doubles as 18 + // the rkey on the PDS so we keep grammar tighter than ATProto's general 19 + // rkey rules — clean URL paths. 20 + func ValidateSlug(s string) error { 21 + if s == "" { 22 + return errors.New("slug is empty") 23 + } 24 + if slices.Contains(ReservedSlugs, s) { 25 + return fmt.Errorf("slug %q is reserved", s) 26 + } 27 + if strings.HasPrefix(s, "-") { 28 + return fmt.Errorf("slug %q has leading hyphen", s) 29 + } 30 + if strings.HasSuffix(s, "-") { 31 + return fmt.Errorf("slug %q has trailing hyphen", s) 32 + } 33 + if strings.Contains(s, "--") { 34 + return fmt.Errorf("slug %q has double hyphen", s) 35 + } 36 + for _, r := range s { 37 + switch { 38 + case r >= 'a' && r <= 'z': 39 + case r >= '0' && r <= '9': 40 + case r == '-': 41 + default: 42 + return fmt.Errorf("slug %q has invalid character %q (only [a-z0-9-] allowed)", s, r) 43 + } 44 + } 45 + return nil 46 + } 47 + 48 + // SlugFromPath derives the slug from a markdown file path: it's the name 49 + // of the file's parent directory. blog-posts/wide-events/index.md 50 + // produces "wide-events". Top-level markdown files (no parent dir under 51 + // the content root) are rejected — we want a directory-per-post layout 52 + // so images can live next to the index.md. 53 + // 54 + // The returned slug is NOT validated against grammar; pass it through 55 + // ValidateSlug separately. 56 + func SlugFromPath(path string) (string, error) { 57 + dir := filepath.Dir(path) 58 + base := filepath.Base(dir) 59 + if base == "" || base == "." || base == "/" { 60 + return "", fmt.Errorf("cannot derive slug from path %q (no parent directory)", path) 61 + } 62 + // Reject when the parent looks like a generic root (heuristic: "blog-posts", 63 + // "posts", "content"). User should put each post in its own directory. 64 + for _, generic := range []string{"blog-posts", "posts", "content"} { 65 + if base == generic { 66 + return "", fmt.Errorf("cannot derive slug from %q: file is at content root, expected blog-posts/<slug>/index.md", path) 67 + } 68 + } 69 + return base, nil 70 + }
+96
internal/publish/slug_test.go
··· 1 + package publish_test 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/publish" 8 + ) 9 + 10 + func TestValidateSlug_Acceptable(t *testing.T) { 11 + cases := []string{ 12 + "hello", 13 + "wide-events-and-personal-software", 14 + "2025-04-17-my-post", 15 + "a", 16 + "abc123", 17 + "123-numeric-prefix", 18 + } 19 + for _, s := range cases { 20 + t.Run(s, func(t *testing.T) { 21 + if err := publish.ValidateSlug(s); err != nil { 22 + t.Errorf("got %v, want nil", err) 23 + } 24 + }) 25 + } 26 + } 27 + 28 + func TestValidateSlug_RejectsBadGrammar(t *testing.T) { 29 + cases := []struct { 30 + slug string 31 + why string 32 + }{ 33 + {"", "empty"}, 34 + {"Hello", "uppercase"}, 35 + {"hello world", "space"}, 36 + {"hello/path", "slash"}, 37 + {"hello.md", "dot"}, 38 + {"-leading-dash", "leading dash"}, 39 + {"trailing-dash-", "trailing dash"}, 40 + {"double--dash", "double dash"}, 41 + {"emoji-🚀", "non-ascii"}, 42 + {"under_score", "underscore"}, 43 + } 44 + for _, tc := range cases { 45 + t.Run(tc.why, func(t *testing.T) { 46 + err := publish.ValidateSlug(tc.slug) 47 + if err == nil { 48 + t.Errorf("expected error for %q (%s)", tc.slug, tc.why) 49 + } 50 + }) 51 + } 52 + } 53 + 54 + func TestValidateSlug_RejectsReservedNames(t *testing.T) { 55 + for _, name := range []string{"self", "index", "feed", "sitemap", ".well-known"} { 56 + t.Run(name, func(t *testing.T) { 57 + err := publish.ValidateSlug(name) 58 + if err == nil { 59 + t.Fatalf("expected error for reserved name %q", name) 60 + } 61 + if !strings.Contains(strings.ToLower(err.Error()), "reserved") { 62 + t.Errorf("error %q should mention 'reserved'", err.Error()) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestSlugFromPath_DerivesFromDir(t *testing.T) { 69 + cases := []struct { 70 + path string 71 + want string 72 + }{ 73 + {"/repo/blog-posts/wide-events/index.md", "wide-events"}, 74 + {"/repo/blog-posts/small-software/index.md", "small-software"}, 75 + {"./hello-world/index.md", "hello-world"}, 76 + } 77 + for _, tc := range cases { 78 + t.Run(tc.path, func(t *testing.T) { 79 + got, err := publish.SlugFromPath(tc.path) 80 + if err != nil { 81 + t.Fatal(err) 82 + } 83 + if got != tc.want { 84 + t.Errorf("got %q, want %q", got, tc.want) 85 + } 86 + }) 87 + } 88 + } 89 + 90 + func TestSlugFromPath_RejectsTopLevelMarkdown(t *testing.T) { 91 + // blog-posts/foo.md (no directory) — slug ambiguous, force user to put it in a dir 92 + _, err := publish.SlugFromPath("/repo/blog-posts/foo.md") 93 + if err == nil { 94 + t.Fatal("expected error for top-level markdown") 95 + } 96 + }
+95
internal/publish/state.go
··· 1 + package publish 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path/filepath" 10 + "time" 11 + ) 12 + 13 + // StateEntry is the per-rkey idempotency cache. LastHash short-circuits 14 + // re-publishing identical content; LastCoverHash decides whether the 15 + // cover blob needs re-uploading; ID anchors rename detection (slug 16 + // changes but ID stays). 17 + type StateEntry struct { 18 + ID string `json:"id"` 19 + LastHash string `json:"last_hash"` 20 + LastCoverHash string `json:"last_cover_hash,omitempty"` 21 + LastPublishedAt time.Time `json:"last_published_at"` 22 + } 23 + 24 + // State is the on-disk publish-flow cache. Indexed by rkey (slug); 25 + // callers needing reverse lookup by ID use RkeyByID. 26 + type State struct { 27 + Entries map[string]StateEntry `json:"entries"` 28 + } 29 + 30 + // LoadState reads state JSON from path. A missing file returns an empty, 31 + // non-nil State; the publish flow treats "no state file yet" as "first 32 + // publish ever". Corrupt JSON is an error — the user should run `fair 33 + // doctor` to rebuild. 34 + func LoadState(path string) (*State, error) { 35 + data, err := os.ReadFile(path) 36 + if err != nil { 37 + if errors.Is(err, fs.ErrNotExist) { 38 + return &State{Entries: map[string]StateEntry{}}, nil 39 + } 40 + return nil, fmt.Errorf("read state %s: %w", path, err) 41 + } 42 + var s State 43 + if err := json.Unmarshal(data, &s); err != nil { 44 + return nil, fmt.Errorf("parse state %s: %w", path, err) 45 + } 46 + if s.Entries == nil { 47 + s.Entries = map[string]StateEntry{} 48 + } 49 + return &s, nil 50 + } 51 + 52 + // Save writes state JSON to path atomically (temp + rename) at 0600. 53 + // The temp file is created in the same directory as path so the rename 54 + // is atomic on the same filesystem. 55 + func (s *State) Save(path string) error { 56 + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 57 + return fmt.Errorf("ensure state dir: %w", err) 58 + } 59 + data, err := json.MarshalIndent(s, "", " ") 60 + if err != nil { 61 + return fmt.Errorf("marshal state: %w", err) 62 + } 63 + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") 64 + if err != nil { 65 + return fmt.Errorf("create temp: %w", err) 66 + } 67 + tmpName := tmp.Name() 68 + defer os.Remove(tmpName) 69 + if err := tmp.Chmod(0o600); err != nil { 70 + tmp.Close() 71 + return fmt.Errorf("chmod temp: %w", err) 72 + } 73 + if _, err := tmp.Write(data); err != nil { 74 + tmp.Close() 75 + return fmt.Errorf("write temp: %w", err) 76 + } 77 + if err := tmp.Close(); err != nil { 78 + return fmt.Errorf("close temp: %w", err) 79 + } 80 + return os.Rename(tmpName, path) 81 + } 82 + 83 + // RkeyByID returns the rkey whose StateEntry has the given ID, or false 84 + // when not found. Used by the publish flow's local-first rename 85 + // detection: if the source markdown has id X and the on-disk state maps 86 + // rkey Y to id X, but the slug derived from the file path is Z, that's 87 + // a rename (Y -> Z). 88 + func (s *State) RkeyByID(id string) (string, bool) { 89 + for rkey, entry := range s.Entries { 90 + if entry.ID == id { 91 + return rkey, true 92 + } 93 + } 94 + return "", false 95 + }
+127
internal/publish/state_test.go
··· 1 + package publish_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "runtime" 7 + "testing" 8 + "time" 9 + 10 + "aparker.io/fair/internal/publish" 11 + ) 12 + 13 + func TestState_RoundTrip(t *testing.T) { 14 + path := filepath.Join(t.TempDir(), "state.json") 15 + 16 + want := &publish.State{ 17 + Entries: map[string]publish.StateEntry{ 18 + "wide-events": { 19 + ID: "01HXTEST", 20 + LastHash: "abc123", 21 + LastCoverHash: "cover456", 22 + LastPublishedAt: time.Date(2025, 4, 17, 0, 0, 0, 0, time.UTC), 23 + }, 24 + }, 25 + } 26 + 27 + if err := want.Save(path); err != nil { 28 + t.Fatalf("Save: %v", err) 29 + } 30 + 31 + got, err := publish.LoadState(path) 32 + if err != nil { 33 + t.Fatalf("LoadState: %v", err) 34 + } 35 + entry, ok := got.Entries["wide-events"] 36 + if !ok { 37 + t.Fatal("entry not found after round-trip") 38 + } 39 + if entry.ID != "01HXTEST" { 40 + t.Errorf("ID: %q", entry.ID) 41 + } 42 + if entry.LastHash != "abc123" { 43 + t.Errorf("LastHash: %q", entry.LastHash) 44 + } 45 + if entry.LastCoverHash != "cover456" { 46 + t.Errorf("LastCoverHash: %q", entry.LastCoverHash) 47 + } 48 + } 49 + 50 + func TestLoadState_MissingReturnsEmpty(t *testing.T) { 51 + got, err := publish.LoadState(filepath.Join(t.TempDir(), "missing.json")) 52 + if err != nil { 53 + t.Fatalf("missing state should not error: %v", err) 54 + } 55 + if got == nil { 56 + t.Fatal("got nil State") 57 + } 58 + if got.Entries == nil { 59 + t.Error("Entries should be non-nil map") 60 + } 61 + } 62 + 63 + func TestState_Save_Atomic_NoTempFiles(t *testing.T) { 64 + dir := t.TempDir() 65 + path := filepath.Join(dir, "state.json") 66 + 67 + s := &publish.State{ 68 + Entries: map[string]publish.StateEntry{ 69 + "foo": {ID: "01HX"}, 70 + }, 71 + } 72 + if err := s.Save(path); err != nil { 73 + t.Fatal(err) 74 + } 75 + 76 + entries, _ := os.ReadDir(dir) 77 + for _, e := range entries { 78 + if e.Name() == filepath.Base(path) { 79 + continue 80 + } 81 + t.Errorf("temp file leaked: %s", e.Name()) 82 + } 83 + } 84 + 85 + func TestState_FilePerms(t *testing.T) { 86 + if runtime.GOOS == "windows" { 87 + t.Skip("perms model differs on windows") 88 + } 89 + path := filepath.Join(t.TempDir(), "state.json") 90 + (&publish.State{Entries: map[string]publish.StateEntry{}}).Save(path) 91 + info, err := os.Stat(path) 92 + if err != nil { 93 + t.Fatal(err) 94 + } 95 + if info.Mode().Perm() != 0o600 { 96 + t.Errorf("got %o, want 0600", info.Mode().Perm()) 97 + } 98 + } 99 + 100 + func TestState_LookupByID(t *testing.T) { 101 + s := &publish.State{ 102 + Entries: map[string]publish.StateEntry{ 103 + "slug-a": {ID: "ID-A"}, 104 + "slug-b": {ID: "ID-B"}, 105 + "slug-c": {ID: "ID-A"}, // duplicate id (shouldn't happen but test the behavior) 106 + }, 107 + } 108 + 109 + rkey, found := s.RkeyByID("ID-B") 110 + if !found || rkey != "slug-b" { 111 + t.Errorf("got %q, %v; want slug-b, true", rkey, found) 112 + } 113 + 114 + _, found = s.RkeyByID("ID-DOES-NOT-EXIST") 115 + if found { 116 + t.Error("expected not-found for nonexistent ID") 117 + } 118 + } 119 + 120 + func TestLoadState_CorruptJSONReturnsError(t *testing.T) { 121 + path := filepath.Join(t.TempDir(), "state.json") 122 + os.WriteFile(path, []byte("{not json"), 0o600) 123 + _, err := publish.LoadState(path) 124 + if err == nil { 125 + t.Fatal("expected parse error") 126 + } 127 + }
+174
internal/publish/text.go
··· 1 + package publish 2 + 3 + import ( 4 + "strings" 5 + 6 + "aparker.io/fair/internal/markdown" 7 + "github.com/rivo/uniseg" 8 + "github.com/yuin/goldmark/ast" 9 + "github.com/yuin/goldmark/text" 10 + ) 11 + 12 + // MaxDescriptionGraphemes caps the description field at the 13 + // site.standard.document spec limit of 3000 graphemes. 14 + const MaxDescriptionGraphemes = 3000 15 + 16 + // MaxTextContentGraphemes caps the textContent field at 30000 graphemes 17 + // per the spec. 18 + const MaxTextContentGraphemes = 30000 19 + 20 + // ExtractDescription returns the body's first paragraph as plain text, 21 + // truncated to MaxDescriptionGraphemes. If no paragraph appears among 22 + // the first 5 top-level AST blocks, an empty string is returned. 23 + // 24 + // Used as a fallback when the frontmatter has no explicit description. 25 + func ExtractDescription(body string) string { 26 + doc := markdown.Parser().Parser().Parse(text.NewReader([]byte(body))) 27 + 28 + // Walk top-level children; skip up to 4 non-paragraph blocks before 29 + // giving up. 30 + count := 0 31 + for child := doc.FirstChild(); child != nil; child = child.NextSibling() { 32 + count++ 33 + if count > 5 { 34 + return "" 35 + } 36 + if p, ok := child.(*ast.Paragraph); ok { 37 + // Skip paragraphs whose only top-level inline content is an 38 + // image — common pattern for cover images / figures. Look at 39 + // the paragraph's direct children for any non-Image node. 40 + if isImageOnlyParagraph(p) { 41 + continue 42 + } 43 + text := nodeText(p, []byte(body)) 44 + text = collapseWhitespace(text) 45 + return TruncateGraphemes(text, MaxDescriptionGraphemes) 46 + } 47 + } 48 + return "" 49 + } 50 + 51 + // ExtractTextContent returns a plaintext rendering of the body suitable 52 + // for the site.standard.document.textContent field — used by indexers 53 + // for full-text search. Code fences are stripped (their content 54 + // pollutes search), alt text from images is preserved, whitespace is 55 + // collapsed. 56 + // 57 + // Truncated to MaxTextContentGraphemes. Truncation is grapheme-aware 58 + // to avoid splitting multi-byte characters mid-rune. 59 + func ExtractTextContent(body string) string { 60 + doc := markdown.Parser().Parser().Parse(text.NewReader([]byte(body))) 61 + 62 + var b strings.Builder 63 + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 64 + if !entering { 65 + return ast.WalkContinue, nil 66 + } 67 + switch v := n.(type) { 68 + case *ast.FencedCodeBlock, *ast.CodeBlock: 69 + // Skip both the fenced ``` blocks and indented code blocks. 70 + return ast.WalkSkipChildren, nil 71 + case *ast.Image: 72 + // Take the alt text (children of Image are alt-text nodes). 73 + b.WriteString(nodeText(v, []byte(body))) 74 + b.WriteByte(' ') 75 + return ast.WalkSkipChildren, nil 76 + case *ast.Text: 77 + b.Write(v.Segment.Value([]byte(body))) 78 + if v.SoftLineBreak() || v.HardLineBreak() { 79 + b.WriteByte(' ') 80 + } 81 + case *ast.Paragraph, *ast.Heading, *ast.ListItem, *ast.Blockquote: 82 + // Block boundaries — append a separator after the children 83 + // have been visited. Done in the !entering branch is cleaner 84 + // but we approximate here by adding a newline now. 85 + } 86 + return ast.WalkContinue, nil 87 + }) 88 + return TruncateGraphemes(collapseWhitespace(b.String()), MaxTextContentGraphemes) 89 + } 90 + 91 + // isImageOnlyParagraph returns true when the paragraph's only non- 92 + // trivial inline content is one or more Image nodes. Used to skip 93 + // cover-image-only paragraphs in description extraction. 94 + // 95 + // Heuristic: presence of any non-Image child node means "has prose". 96 + // A pure whitespace Text node between two Images is rare in practice; 97 + // when it occurs, treating the paragraph as image-only is the 98 + // reasonable default (skip and look for prose lower in the post). 99 + func isImageOnlyParagraph(p *ast.Paragraph) bool { 100 + hasImage := false 101 + for c := p.FirstChild(); c != nil; c = c.NextSibling() { 102 + switch c.(type) { 103 + case *ast.Image: 104 + hasImage = true 105 + default: 106 + return false 107 + } 108 + } 109 + return hasImage 110 + } 111 + 112 + // nodeText concatenates all *ast.Text descendants of n into a single 113 + // string, using source as the underlying byte buffer. 114 + func nodeText(n ast.Node, source []byte) string { 115 + var b strings.Builder 116 + _ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) { 117 + if !entering { 118 + return ast.WalkContinue, nil 119 + } 120 + if t, ok := node.(*ast.Text); ok { 121 + b.Write(t.Segment.Value(source)) 122 + } 123 + return ast.WalkContinue, nil 124 + }) 125 + return b.String() 126 + } 127 + 128 + // collapseWhitespace replaces runs of whitespace (including newlines) 129 + // with a single space, then trims leading/trailing whitespace. 130 + func collapseWhitespace(s string) string { 131 + var b strings.Builder 132 + b.Grow(len(s)) 133 + prevSpace := true // start as space so we trim leading 134 + for _, r := range s { 135 + if r == ' ' || r == '\n' || r == '\r' || r == '\t' { 136 + if !prevSpace { 137 + b.WriteByte(' ') 138 + prevSpace = true 139 + } 140 + continue 141 + } 142 + b.WriteRune(r) 143 + prevSpace = false 144 + } 145 + out := b.String() 146 + return strings.TrimRight(out, " ") 147 + } 148 + 149 + // TruncateGraphemes returns s truncated to at most max grapheme clusters. 150 + // Splitting at grapheme boundaries (not bytes or runes) avoids the 151 + // classic emoji/combining-character corruption. 152 + func TruncateGraphemes(s string, max int) string { 153 + if max <= 0 || s == "" { 154 + return s 155 + } 156 + g := uniseg.NewGraphemes(s) 157 + count := 0 158 + end := 0 159 + for g.Next() { 160 + count++ 161 + if count > max { 162 + return s[:end] 163 + } 164 + // Position after the latest grapheme cluster. 165 + _, end = g.Positions() 166 + } 167 + return s 168 + } 169 + 170 + // GraphemeCount returns the number of grapheme clusters in s. Exported 171 + // for tests; internal callers can use uniseg.GraphemeClusterCount. 172 + func GraphemeCount(s string) int { 173 + return uniseg.GraphemeClusterCount(s) 174 + }
+95
internal/publish/text_test.go
··· 1 + package publish_test 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "aparker.io/fair/internal/publish" 8 + ) 9 + 10 + func TestExtractDescription_FirstParagraph(t *testing.T) { 11 + body := "This is the first paragraph.\n\nSecond paragraph not used.\n" 12 + got := publish.ExtractDescription(body) 13 + if got != "This is the first paragraph." { 14 + t.Errorf("got %q", got) 15 + } 16 + } 17 + 18 + func TestExtractDescription_SkipsHeadingThenFindsParagraph(t *testing.T) { 19 + body := "# Heading\n\nThe actual first paragraph here.\n" 20 + got := publish.ExtractDescription(body) 21 + if got != "The actual first paragraph here." { 22 + t.Errorf("got %q", got) 23 + } 24 + } 25 + 26 + func TestExtractDescription_SkipsImageThenFindsParagraph(t *testing.T) { 27 + body := "![cover](images/foo.png)\n\nSecond block — first paragraph for description.\n" 28 + got := publish.ExtractDescription(body) 29 + if got != "Second block — first paragraph for description." { 30 + t.Errorf("got %q", got) 31 + } 32 + } 33 + 34 + func TestExtractDescription_NoParagraph_ReturnsEmpty(t *testing.T) { 35 + body := "# Heading only\n\n## Another heading\n" 36 + got := publish.ExtractDescription(body) 37 + if got != "" { 38 + t.Errorf("got %q, want empty", got) 39 + } 40 + } 41 + 42 + func TestExtractDescription_GivesUpAfterFirstFiveBlocks(t *testing.T) { 43 + // 5+ non-paragraph blocks, then a paragraph — description should be empty 44 + body := "# h1\n\n## h2\n\n### h3\n\n#### h4\n\n##### h5\n\nFinally text.\n" 45 + got := publish.ExtractDescription(body) 46 + if got != "" { 47 + t.Errorf("got %q, want empty (gave up after 5 blocks)", got) 48 + } 49 + } 50 + 51 + func TestExtractDescription_TruncatedAtGraphemeBoundary(t *testing.T) { 52 + long := strings.Repeat("a", 4000) // exceeds 3000-grapheme cap 53 + body := long + "\n" 54 + got := publish.ExtractDescription(body) 55 + // uniseg cluster count, not byte count 56 + if uniLen := publish.GraphemeCount(got); uniLen > 3000 { 57 + t.Errorf("description not truncated: %d graphemes", uniLen) 58 + } 59 + } 60 + 61 + func TestExtractTextContent_StripsCodeFences(t *testing.T) { 62 + body := "Hello.\n\n```go\nfunc main() {}\n```\n\nWorld.\n" 63 + got := publish.ExtractTextContent(body) 64 + if strings.Contains(got, "func main") { 65 + t.Errorf("code fence text leaked: %q", got) 66 + } 67 + if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") { 68 + t.Errorf("paragraphs missing: %q", got) 69 + } 70 + } 71 + 72 + func TestExtractTextContent_KeepsAltText(t *testing.T) { 73 + body := "Look: ![alt text](foo.png)\n" 74 + got := publish.ExtractTextContent(body) 75 + if !strings.Contains(got, "alt text") { 76 + t.Errorf("alt text dropped: %q", got) 77 + } 78 + } 79 + 80 + func TestExtractTextContent_NormalizesWhitespace(t *testing.T) { 81 + body := "Line 1\n\n\n\nLine 2.\n" 82 + got := publish.ExtractTextContent(body) 83 + // No multiple consecutive newlines 84 + if strings.Contains(got, "\n\n\n") { 85 + t.Errorf("excessive whitespace: %q", got) 86 + } 87 + } 88 + 89 + func TestExtractTextContent_TruncatedAt30000Graphemes(t *testing.T) { 90 + long := strings.Repeat("hello world. ", 5000) // ~65000 chars 91 + got := publish.ExtractTextContent(long) 92 + if c := publish.GraphemeCount(got); c > 30000 { 93 + t.Errorf("not truncated: %d graphemes", c) 94 + } 95 + }
+12
sandbox/.env.example
··· 1 + # Copy to sandbox/.env and fill in. Each value is a random hex string. 2 + # `make sandbox-init` (or `sandbox/seed.sh`) generates these for you. 3 + 4 + # 32 bytes (64 hex chars) — used to sign tokens issued by this PDS. 5 + PDS_JWT_SECRET= 6 + 7 + # Anything memorable; only used as a host-side admin auth for `goat`. 8 + PDS_ADMIN_PASSWORD= 9 + 10 + # 32-byte k256 (secp256k1) private key, hex-encoded. The PDS uses this to 11 + # sign DID:plc rotations during account creation. 12 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=
+102
sandbox/README.md
··· 1 + # Local sandbox 2 + 3 + End-to-end testing of `fair` without touching your real PDS or the 4 + public ATProto network. Runs an isolated `bluesky-social/pds` Docker 5 + container behind a Caddy TLS proxy on a real public hostname 6 + (`pds.localtest.me`, which always resolves to 127.0.0.1). 7 + 8 + ## Why the TLS dance? 9 + 10 + The PDS image hardcodes `https://` for its OAuth issuer URL and rejects 11 + loopback hosts (`localhost`, `127.0.0.1`). To work around this without 12 + ngrok, we use: 13 + 14 + - `pds.localtest.me` — public DNS that always resolves to 127.0.0.1. 15 + Non-loopback per the PDS validator, but lives entirely on your laptop. 16 + - `mkcert` — generates an OS-trusted local CA and a cert for 17 + `pds.localtest.me`. 18 + - Caddy — terminates TLS in front of the PDS using the mkcert cert. 19 + 20 + The PDS thinks it lives at `https://pds.localtest.me:8443`. The Go CLI 21 + talks to it via real HTTPS. No federation, no public traffic. 22 + 23 + ## What's here 24 + 25 + | File | Purpose | 26 + |---|---| 27 + | `compose.yaml` | PDS + Caddy services | 28 + | `caddy/Caddyfile` | Caddy reverse-proxy config | 29 + | `caddy/certs/` | (generated) mkcert-issued cert + key | 30 + | `.env.example` | Template for required PDS secrets | 31 + | `seed.sh` | First-time bootstrap: cert + docker up + test account + client metadata | 32 + | `reset.sh` | Wipe everything: docker volume, local state, session file | 33 + | `public/` | (generated) `oauth-client-metadata.json` for the OAuth flow | 34 + 35 + ## Prerequisites 36 + 37 + - Docker (Orbstack, Docker Desktop, or native engine — anything with `docker compose`) 38 + - Go ≥ 1.26 (for building `fair`) 39 + - `mkcert` (`brew install mkcert`) 40 + - macOS or Linux. Windows requires WSL2. 41 + 42 + **One-time:** run `sudo mkcert -install` to add the local CA to the 43 + system trust store. Without this, browsers (which the OAuth flow opens 44 + during login) will warn about the cert. The Go CLI trusts the CA 45 + explicitly regardless. 46 + 47 + The PDS image bundles `goat`, the official admin CLI; no separate install. 48 + 49 + ## Workflow 50 + 51 + ```bash 52 + # First time, or after a reset: 53 + sandbox/seed.sh 54 + 55 + # Iterate locally — replace these with the real subcommands as Phase 3 lands: 56 + fair --profile sandbox emit-client-metadata --out sandbox/public/ 57 + # fair --profile sandbox sandbox serve (TODO Phase 3f) 58 + # fair --profile sandbox auth login (TODO Phase 3f) 59 + # fair --profile sandbox publish post.md (TODO Phase 4) 60 + 61 + # When you want a true reset: 62 + sandbox/reset.sh 63 + ``` 64 + 65 + ## Expected ports 66 + 67 + | Endpoint | Where | 68 + |---|---| 69 + | PDS XRPC | https://pds.localtest.me:8443/xrpc/... | 70 + | Health | https://pds.localtest.me:8443/xrpc/_health | 71 + | OAuth client metadata | https://pds.localtest.me:8443/.well-known/oauth-client-metadata.json (served by `fair sandbox serve` — TODO Phase 3f) | 72 + | Loopback callback | http://127.0.0.1:&lt;random&gt;/callback (per `fair auth login` invocation) | 73 + 74 + ## Profile config 75 + 76 + You'll need `~/.config/fair/config.sandbox.toml`. The DID is filled in 77 + after `seed.sh` creates the test account: 78 + 79 + ```toml 80 + pds_url = "https://pds.localtest.me:8443" 81 + did = "did:plc:..." # printed by seed.sh 82 + domain = "https://pds.localtest.me:8443" 83 + publication_name = "Sandbox Blog" 84 + blog_posts = "/Users/austinparker/code/blog-posts" 85 + state_file = ".fair/state.sandbox.json" 86 + images_mirror = "images-mirror.sandbox/" 87 + dist_dir = "dist.sandbox/" 88 + ``` 89 + 90 + (No `cloudflare_project` — sandbox cannot deploy.) 91 + 92 + ## Known limitations 93 + 94 + - Federation is intentionally off. Records on this PDS aren't visible 95 + outside the laptop, but they're still real ATProto records — the same 96 + putRecord/listRecords/getRecord calls that hit prod will work here. 97 + - The public PLC directory (plc.directory) is still consulted for DID 98 + generation. If you need true offline mode, run a local PLC mirror and 99 + override `PDS_DID_PLC_URL` in compose.yaml. 100 + - Lexicon validation is "optimistic" by default — unknown NSIDs (like 101 + `site.standard.*`) are accepted as opaque CBOR. Production behavior 102 + matches as long as records are well-formed.
+44
sandbox/caddy/Caddyfile
··· 1 + # Caddy reverse proxy for the local sandbox PDS. 2 + # 3 + # Why: bluesky-social/pds:0.4 hardcodes https:// for its OAuth issuer URL 4 + # and rejects loopback hosts (127.0.0.1, localhost). We work around this 5 + # by giving the PDS a non-loopback hostname (pds.localtest.me, which 6 + # resolves publicly to 127.0.0.1) and terminating real TLS in front of 7 + # it via mkcert-issued certs that the OS trust store accepts. 8 + # 9 + # Port 443 (not 8443): the haileyok OAuth library's Validate() rejects 10 + # auth-server issuer URLs that carry a port. So Caddy must listen on the 11 + # default HTTPS port and the PDS_HOSTNAME has no port either. 12 + # 13 + # Generated certs are mounted at /etc/caddy/certs by sandbox/compose.yaml. 14 + # The OAuth client metadata document is served from /srv (mount of 15 + # sandbox/public) so the auth server can fetch it during login. 16 + 17 + { 18 + auto_https disable_redirects 19 + admin off 20 + # Log access to stderr — useful for debugging PAR failures during 21 + # OAuth login flow development. Remove once flow is stable. 22 + log { 23 + output stderr 24 + format console 25 + level INFO 26 + } 27 + } 28 + 29 + pds.localtest.me { 30 + tls /etc/caddy/certs/pds.localtest.me.pem /etc/caddy/certs/pds.localtest.me-key.pem 31 + 32 + # Serve the OAuth client metadata document directly from disk so the 33 + # PDS auth server can fetch it during the OAuth handshake. Order 34 + # matters in Caddy: more-specific handle blocks beat the catch-all. 35 + handle /.well-known/oauth-client-metadata.json { 36 + root * /srv 37 + file_server 38 + } 39 + 40 + # Everything else proxies to the PDS. 41 + handle { 42 + reverse_proxy pds:3000 43 + } 44 + }
+84
sandbox/compose.yaml
··· 1 + # Local sandbox PDS for fair. 2 + # 3 + # How it works: 4 + # - The PDS runs at PDS_HOSTNAME=pds.localtest.me:8443. localtest.me is 5 + # a public DNS entry that always resolves to 127.0.0.1, so this 6 + # hostname lives entirely on your laptop while not triggering the 7 + # PDS's loopback-rejection in OAuth URL validation. 8 + # - Caddy terminates TLS using mkcert-issued certs that the OS trust 9 + # store accepts (after `sudo mkcert -install` once). 10 + # - The fair CLI talks to https://pds.localtest.me:8443 like a real PDS. 11 + # 12 + # Bring up: sandbox/seed.sh 13 + # Tear down: docker compose -f sandbox/compose.yaml --env-file sandbox/.env down 14 + # Wipe data: sandbox/reset.sh 15 + 16 + name: fair-sandbox 17 + 18 + services: 19 + pds: 20 + container_name: fair-sandbox-pds 21 + image: ghcr.io/bluesky-social/pds:0.4 22 + restart: unless-stopped 23 + expose: 24 + - "3000" # internal only — Caddy routes to it 25 + environment: 26 + # Required. 27 + # PDS_HOSTNAME has no port — the haileyok OAuth client library 28 + # rejects auth-server issuer URLs that carry a port (Validate() in 29 + # types.go). Caddy listens on 443 to match. 30 + PDS_HOSTNAME: "pds.localtest.me" 31 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 32 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 33 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX} 34 + 35 + # Local-dev convenience. 36 + PDS_INVITE_REQUIRED: "false" 37 + PDS_RATE_LIMITS_ENABLED: "false" 38 + PDS_BLOB_UPLOAD_LIMIT: "5242880" # 5MB 39 + PDS_DATA_DIRECTORY: "/pds" 40 + PDS_BLOBSTORE_DISK_LOCATION: "/pds/blocks" 41 + # Allow handles like alice.pds.localtest.me. Without this, only 42 + # full-DID logins work; goat account create requires a handle. 43 + PDS_SERVICE_HANDLE_DOMAINS: ".pds.localtest.me" 44 + # PDS uses pino; enable logging to stdout so docker logs shows 45 + # request errors during sandbox debugging. 46 + LOG_ENABLED: "true" 47 + LOG_LEVEL: "debug" 48 + 49 + # PLC: still talks to the public directory for DID generation. 50 + # If you want fully-offline mode, run a local PLC mirror and override. 51 + PDS_DID_PLC_URL: "https://plc.directory" 52 + 53 + # AppView intentionally unset — sandbox doesn't federate. 54 + volumes: 55 + - pds_data:/pds 56 + networks: 57 + - sandbox 58 + 59 + caddy: 60 + container_name: fair-sandbox-caddy 61 + image: caddy:2 62 + restart: unless-stopped 63 + ports: 64 + # Standard 443. Required because the OAuth library forbids ports 65 + # in the auth-server issuer URL. Container's internal Caddy also 66 + # listens on 443; Docker maps host:443 -> container:443. 67 + - "443:443" 68 + depends_on: 69 + - pds 70 + volumes: 71 + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro 72 + - ./caddy/certs:/etc/caddy/certs:ro 73 + # Bind sandbox/public into the container so Caddy can serve the 74 + # OAuth client metadata document at /.well-known/oauth-client-metadata.json 75 + # (the auth server fetches this URL during the OAuth handshake). 76 + - ./public:/srv:ro 77 + networks: 78 + - sandbox 79 + 80 + networks: 81 + sandbox: 82 + 83 + volumes: 84 + pds_data:
+33
sandbox/reset.sh
··· 1 + #!/usr/bin/env bash 2 + # Wipe the sandbox: nukes the docker volume, local state, generated 3 + # client metadata, and any keychain/session entries scoped to the 4 + # sandbox profile. Use when you want a true clean-slate test. 5 + # 6 + # Idempotent. Will not touch the prod profile. 7 + 8 + set -euo pipefail 9 + 10 + cd "$(dirname "$0")" 11 + 12 + echo "stopping PDS and removing volume..." 13 + docker compose -f compose.yaml --env-file .env down -v 2>/dev/null || true 14 + 15 + echo "removing generated client metadata..." 16 + rm -rf public/.well-known 17 + 18 + echo "removing local sandbox state..." 19 + rm -rf ../.fair/state.sandbox.json 20 + rm -rf ../images-mirror.sandbox/ 21 + rm -rf ../dist.sandbox/ 22 + 23 + # Best-effort sandbox session cleanup. If the binary is built and the 24 + # session-file fallback was used, this will remove it. Keychain entries 25 + # for the sandbox profile (if Phase 3b lands) should also be cleared 26 + # here when the keychain backend is added. 27 + SESSION_FILE="${HOME}/.config/fair/session.sandbox.json" 28 + if [[ -f "$SESSION_FILE" ]]; then 29 + echo "removing sandbox session file at $SESSION_FILE..." 30 + rm -f "$SESSION_FILE" 31 + fi 32 + 33 + echo "sandbox reset. Run sandbox/seed.sh to bring it back up."
+163
sandbox/seed.sh
··· 1 + #!/usr/bin/env bash 2 + # Bootstrap the local sandbox PDS for fair. 3 + # 4 + # What it does: 5 + # 1. Pre-flight: docker, openssl, curl, mkcert, fair binary 6 + # 2. Verify mkcert root CA is trusted by the system 7 + # 3. Generate sandbox/.env if missing (random secrets) 8 + # 4. Generate TLS cert for pds.localtest.me if missing 9 + # 5. docker compose up -d (PDS + Caddy) 10 + # 6. Poll https://pds.localtest.me/xrpc/_health until healthy 11 + # 7. Create test account via `goat` (bundled in the PDS image) 12 + # 8. Emit OAuth client metadata into sandbox/public/.well-known/ 13 + # 14 + # Idempotent. Safe to re-run; only generates secrets/certs/account on 15 + # first run. 16 + 17 + set -euo pipefail 18 + 19 + cd "$(dirname "$0")" 20 + 21 + # --- Pre-flight ------------------------------------------------------------- 22 + 23 + require() { 24 + if ! command -v "$1" >/dev/null 2>&1; then 25 + echo "missing prerequisite: $1" >&2 26 + if [[ -n "${2:-}" ]]; then 27 + echo " install: $2" >&2 28 + fi 29 + exit 1 30 + fi 31 + } 32 + 33 + require docker 34 + require openssl 35 + require curl 36 + require mkcert "brew install mkcert" 37 + 38 + case "$(uname -s)" in 39 + Darwin|Linux) ;; 40 + *) echo "sandbox supports macOS and Linux only" >&2; exit 1 ;; 41 + esac 42 + 43 + # Verify mkcert root CA is in the system trust store. Without this, 44 + # `fair auth login` will fail because the browser won't trust the 45 + # pds.localtest.me cert when the PDS redirects to its auth page. 46 + CAROOT="$(mkcert -CAROOT)" 47 + if [[ ! -f "$CAROOT/rootCA.pem" ]]; then 48 + echo "mkcert has not generated a CA yet" >&2 49 + echo "run: sudo mkcert -install" >&2 50 + exit 1 51 + fi 52 + 53 + # Check whether the CA is trusted by browsers. The Go CLI can trust 54 + # mkcert's CA explicitly without sudo, but the OAuth flow opens a 55 + # browser, and unverified-cert warnings there are a UX papercut. 56 + # Warn but don't fail. 57 + case "$(uname -s)" in 58 + Darwin) 59 + if ! security find-certificate -c "mkcert" /Library/Keychains/System.keychain >/dev/null 2>&1; then 60 + echo "WARN: mkcert root CA not in system trust store." >&2 61 + echo " Run once for browser trust: sudo mkcert -install" >&2 62 + echo " The Go CLI will still work (it trusts the CA explicitly)." >&2 63 + echo 64 + fi 65 + ;; 66 + esac 67 + 68 + # Resolve the fair binary; build if missing. 69 + FAIR_BIN="${FAIR_BIN:-$(realpath ../fair 2>/dev/null || echo "")}" 70 + if [[ -z "$FAIR_BIN" || ! -x "$FAIR_BIN" ]]; then 71 + echo "building fair binary..." 72 + (cd .. && go build -o fair ./cmd/fair) 73 + FAIR_BIN="$(realpath ../fair)" 74 + fi 75 + 76 + # --- Generate .env if missing ----------------------------------------------- 77 + 78 + if [[ ! -f .env ]]; then 79 + echo "generating sandbox/.env with random secrets..." 80 + cat > .env <<EOF 81 + PDS_JWT_SECRET=$(openssl rand -hex 32) 82 + PDS_ADMIN_PASSWORD=$(openssl rand -hex 16) 83 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(openssl rand -hex 32) 84 + EOF 85 + chmod 600 .env 86 + fi 87 + 88 + # Pull admin password into our shell for the goat invocation later. 89 + # shellcheck disable=SC1091 90 + set -a; source .env; set +a 91 + 92 + # --- Generate TLS cert if missing ------------------------------------------- 93 + 94 + mkdir -p caddy/certs 95 + if [[ ! -f caddy/certs/pds.localtest.me.pem ]]; then 96 + echo "generating TLS cert for pds.localtest.me..." 97 + (cd caddy && mkcert -cert-file certs/pds.localtest.me.pem -key-file certs/pds.localtest.me-key.pem pds.localtest.me) 98 + fi 99 + 100 + # --- Start PDS + Caddy ------------------------------------------------------ 101 + 102 + echo "starting PDS + Caddy..." 103 + docker compose -f compose.yaml --env-file .env up -d 104 + 105 + echo -n "waiting for PDS to be healthy " 106 + HEALTHY="" 107 + # Trust mkcert's CA explicitly so curl works whether or not the user 108 + # has run `sudo mkcert -install`. The Go CLI does the same. 109 + ROOT_CA="$CAROOT/rootCA.pem" 110 + for _ in $(seq 1 60); do 111 + if curl -sfo /dev/null --cacert "$ROOT_CA" https://pds.localtest.me/xrpc/_health; then 112 + HEALTHY="yes" 113 + echo " ready" 114 + break 115 + fi 116 + echo -n "." 117 + sleep 1 118 + done 119 + 120 + if [[ -z "$HEALTHY" ]]; then 121 + echo >&2 122 + echo "PDS did not become healthy at https://pds.localtest.me" >&2 123 + echo "Check logs: docker compose -f sandbox/compose.yaml --env-file sandbox/.env logs" >&2 124 + exit 1 125 + fi 126 + 127 + # --- Create test account (idempotent) --------------------------------------- 128 + 129 + # `test`, `admin`, etc. are on the PDS's reserved-handle list. 130 + HANDLE="${SANDBOX_HANDLE:-aparker.pds.localtest.me}" 131 + PDS_HANDLE_DOMAIN_NO_DOT=".pds.localtest.me" 132 + EMAIL="${SANDBOX_EMAIL:-aparker@aparker.io}" 133 + PASSWORD="${SANDBOX_PASSWORD:-sandbox}" 134 + 135 + if docker exec fair-sandbox-pds goat pds account list 2>/dev/null | grep -q "$HANDLE"; then 136 + echo "account $HANDLE exists, skipping create" 137 + else 138 + echo "creating test account $HANDLE..." 139 + docker exec fair-sandbox-pds goat pds admin account create \ 140 + --admin-password "$PDS_ADMIN_PASSWORD" \ 141 + --handle "$HANDLE" \ 142 + --email "$EMAIL" \ 143 + --password "$PASSWORD" 144 + echo 145 + echo "Test account ready:" 146 + echo " handle: $HANDLE" 147 + echo " password: $PASSWORD" 148 + fi 149 + 150 + # --- Emit OAuth client metadata -------------------------------------------- 151 + 152 + mkdir -p public 153 + "$FAIR_BIN" --profile sandbox emit-client-metadata --out public 154 + 155 + # --- Done ------------------------------------------------------------------- 156 + 157 + echo 158 + echo "sandbox ready. Endpoints:" 159 + echo " PDS XRPC: https://pds.localtest.me" 160 + echo " Health: https://pds.localtest.me/xrpc/_health" 161 + echo " Client metadata: file://$(pwd)/public/.well-known/oauth-client-metadata.json" 162 + echo 163 + echo "Next: \`fair --profile sandbox auth login\` (Phase 3f, in progress)."