spec: woodstar-carloku amendment — six-service re-recon, apbridge identity, agreed wildcard claim shape
Re-recon of woodstar main b61409a: unfurl + apbridge services added since the 2026-08-02 plan; apbridge.db joins the litestream set and apbridge.seed is the federation identity; the per-user-subdomain/Bluesky behavior is live prod Caddy behavior and becomes part of W0. Claim shape wildcard:true agreed with the #151 session (their fa5dc0b); HA entry-identity and metering-ordering notes pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+162
−68
docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md+162 −68
diff --git a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md| index 52cfbab4b..51f4b04a7 100644 |
| --- a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md |
| +++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md |
| @@ -1,7 +1,8 @@ |
| # Woodstar → Carloku migration |
| **Date:** 2026-08-12 · **Status:** draft (design approved in conversation |
| -2026-08-12) · **Requested by:** Paul |
| +2026-08-12; amended same day after re-recon — see §0) · **Requested by:** |
| +Paul |
| **Supersedes:** the plan half of |
| `docs/superpowers/plans/2026-08-02-plan-woodstar-flagship.md` (its recon |
| @@ -13,6 +14,44 @@ domain attach (#82), wildcard certs (2026-08-05 — the DNS-01 machinery |
| this spec extends), and the kass/keymail platform-tenant migrations |
| (the playbook). |
| +## 0. Amendment — the 2026-08-02 recon is stale (re-recon 2026-08-12) |
| + |
| +Read from woodstar main `b61409a` (`infra/prod-Caddyfile`, |
| +`infra/devbox-init.sh`, `cmd/`, `web/public/lib/api.js`); prod-box |
| +actuals still owed to the §7 pre-flight: |
| + |
| +- **Six services, not four.** Added since the old plan: `unfurl` (:7075, |
| + link-card fetcher; SQLite `unfurl.db` is a disposable cache, |
| + deliberately outside litestream) and `apbridge` (:7076, the |
| + ActivityPub/Bluesky bridge: webfinger, host-meta, `/users/*`, |
| + `/inbox`, `/ap/*`, and `/.well-known/atproto-did` on user subdomains). |
| +- **apbridge carries identity.** Its SQLite `apbridge.db` is in the |
| + litestream set (three replicated DBs, not two), and its `-seed` file |
| + (`apbridge.seed`) holds the bridge key material — the federation |
| + identity. The seed is a flat file NOT covered by litestream or the |
| + media timer; it must be copied at cutover, and §7's pre-flight |
| + verifies how (or whether) prod backs it up today. apbridge also takes |
| + a required `-domain` flag (`woodstar.app` on the flagship) — the old |
| + plan's "no canonical-host pinning anywhere" claim is no longer true — |
| + and dials out to `bsky.social` and Bridgy Fed (ordinary egress; fine). |
| +- **The per-user subdomain / Bluesky behavior is LIVE, not |
| + aspirational.** Prod Caddy's `*.woodstar.app` block today: |
| + `www` → apex (permanent redirect); `/.well-known/atproto-did` → |
| + apbridge (what makes `paul.woodstar.app` a Bluesky handle); everything |
| + else → temporary redirect to `https://woodstar.app/<leftmost-label>` |
| + (the profile). §5's woodstar-side half is therefore **existing prod |
| + behavior W0 must preserve at cutover**, not a follow-up issue. |
| +- **The fan-out set is wider** (§6 lists it). The reference |
| + prod-Caddyfile lacks the `/unfurl*` route the devbox has — box |
| + actuals to be verified in pre-flight; the migration includes it |
| + either way. |
| +- The reference Caddyfile's header names `34.241.196.33` while the |
| + 2026-08-02 recon verified EIP `52.30.232.124`; pre-flight confirms |
| + the live address before any SSH step. |
| + |
| +Where this amendment contradicts the sections below, the amendment |
| +wins; §§2, 5, 6, 7 have been updated in place to match. |
| + |
| ## 1. Goal |
| Woodstar's production — `woodstar.app`, `status.woodstar.app`, and |
| @@ -44,14 +83,19 @@ Two deliberate product outcomes ride along: |
| **Platform tenant**, the keymail/kass pattern: |
| -- Five carlos apps: `woodstar-relay`, `woodstar-media`, |
| - `woodstar-indexer`, `woodstar-notify` (kind binary, linux-arm64) and |
| - `woodstar-status` (kind static). Four binaries stay four apps — |
| - matches the repo's one-binary-per-service design value. |
| -- Four bespoke hardened units `woodstar-{relay,media,indexer,notify}.service`: |
| +- Seven carlos apps: `woodstar-relay`, `woodstar-media`, |
| + `woodstar-indexer`, `woodstar-notify`, `woodstar-unfurl`, |
| + `woodstar-apbridge` (kind binary, linux-arm64) and `woodstar-status` |
| + (kind static). Six binaries stay six apps — matches the repo's |
| + one-binary-per-service design value. |
| +- Six bespoke hardened units |
| + `woodstar-{relay,media,indexer,notify,unfurl,apbridge}.service`: |
| static system user `woodstar`, harden-host.sh sandbox drop-ins, |
| - loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify, |
| - data under `/data/woodstar` (0700; `/data` 0711). |
| + loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify / |
| + 8455 unfurl / 8456 apbridge, data under `/data/woodstar` (0700; |
| + `/data` 0711). apbridge runs with |
| + `-domain woodstar.app -seed /data/woodstar/apbridge.seed` plus |
| + relay/media upstream URLs; the seed file is migrated at cutover (§7). |
| - Env file `/etc/carlos/apps/woodstar.env` (0600 root) carrying |
| `WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` — present |
| **before** the DNS flip, or iOS passkey assertions break. No other |
| @@ -59,15 +103,16 @@ Two deliberate product outcomes ride along: |
| services contain zero AWS SDK; litestream and the media timer run as |
| root). |
| - Backups move to the platform data bucket: |
| - `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,media}` |
| + `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,apbridge,media}` |
| (host role already has RW — zero IAM/tofu change). The old |
| `woodstar-backups-271376211898` bucket keeps pre-migration history, |
| - frozen. Litestream config is extended root-side for the two DBs, NOT |
| - enabled until cutover; media backup is a root hourly additive |
| - `aws s3 sync` timer. |
| -- Headroom: the whole stack runs in a t4g.nano today (~7 MB data); the |
| - flagship verdict from the 2026-08-02 plan stands, re-checked at |
| - rehearsal. |
| + frozen. Litestream config is extended root-side for the THREE |
| + replicated DBs (relay, indexer, apbridge — unfurl's cache is |
| + deliberately excluded, per the repo's own rule), NOT enabled until |
| + cutover; media backup is a root hourly additive `aws s3 sync` timer. |
| +- Headroom: the whole stack runs in a t4g.nano today (data in the |
| + single-digit MB); the flagship verdict from the 2026-08-02 plan |
| + stands, re-checked at rehearsal with all six services. |
| ## 3. Sequencing — after the custom-domain track |
| @@ -119,10 +164,15 @@ Mechanics (all provider-blind, the `dnsProvider` seam is untouched): |
| today. Delegated parents get a parent → delegation-target mapping; |
| `solveDNS01` publishes at the target. `waitForTXT` polls the original |
| name — resolvers follow the CNAME, so it already works. |
| -- **Declaration** extends #82's claim vocabulary: a wildcard attach on |
| - a claimed domain (a field on the domain claim, or a sibling wildcard |
| - claim — shape settled with the #151/#82 sessions, who own it). The |
| - edge derives delegated wildcard parents from platform state exactly |
| +- **Declaration — shape AGREED with the #151 session (2026-08-12):** |
| + additive optional `wildcard: true` on the domain claim |
| + (`internal/domains.Claim` gains `Wildcard bool |
| + `json:"wildcard,omitempty"`` — landed on `worktree-route-hostnames` |
| + fa5dc0b; absent = today's behavior byte-identical; #151's converge |
| + path preserves the field on route repoints). The one field means |
| + both halves: "this domain is a delegated wildcard parent for cert |
| + issuance" (§4) and "derive a `*.<host>` alias entry to the primary" |
| + (§5). The edge derives delegated wildcard parents from claims exactly |
| as it derives `<acct>.<apps-domain>` parents from the route table — |
| no bare env list. |
| - **Gate:** the attach surface verifies the delegation CNAME exists |
| @@ -135,44 +185,70 @@ Mechanics (all provider-blind, the `dnsProvider` seam is untouched): |
| ## 5. Wildcard aliases |
| A wildcard cert alone leaves arbitrary subdomains handshaking into the |
| -edge's no-route response. Woodstar's current contract (Caddy wildcard) |
| -is that any subdomain serves the app, and the Bluesky story requires |
| -`paul.woodstar.app` to reach the relay. So: |
| +edge's no-route response. Woodstar's live contract (prod Caddy wildcard |
| +block, §0) is per-user subdomains: `paul.woodstar.app` answers |
| +`/.well-known/atproto-did` (the Bluesky handle) and redirects everything |
| +else to the apex profile. So: |
| - `*.<domain>` becomes a `route_aliases`-shaped entry resolving to a |
| named primary route: one-label matching only (a wildcard matches |
| exactly one label, mirroring the cert), explicit named routes/aliases |
| win over the wildcard. |
| +- Resolution is to the primary's upstream **entry** (the identical map |
| + value, not a copied address) — #151/#152's agreed rule — so wildcard |
| + traffic inherits multi-upstream health-checked routing the moment |
| + #152 lands, with zero wildcard-side changes. Derivation slots beside |
| + #151's `ReconcileAliases` (`internal/domains/aliases.go`), which |
| + ignores fields it doesn't know. |
| +- **Parent-derivation exclusion (#151's caution, pinned):** the edge's |
| + `currentHosts()` excludes alias-flagged entries from wildcard-cert |
| + parent derivation; the derived `*.<host>` entries must carry the same |
| + alias flag (`up.alias`) so DNS-01 parent derivation never |
| + double-counts a delegated domain. |
| - The upstream sees the **original `Host` header** — per-user host |
| interpretation is app logic, and the platform must not foreclose it. |
| - Per #151's pinned rule, content/backing derivation uses the primary's |
| - host; the request host is TLS/cache-key/metering only. |
| - |
| -The woodstar-side half — the relay interpreting |
| -`Host: <user>.woodstar.app` to serve that user's |
| -`/.well-known/atproto-did` and redirect/show their profile (DESIGN §11's |
| -`you.woodstar.app` made real, plus the Bluesky DID piece) — is a |
| -woodstar-repo feature filed as its own issue, NOT a migration blocker. |
| -The migration guarantees only: any `*.woodstar.app` request arrives at |
| -the relay with Host intact over valid TLS. |
| + host; the request host is TLS/cache-key/metering only. Metering |
| + consequence (flagged by the unit-economics session): each concrete |
| + subdomain becomes its own usage row attributed to the primary route's |
| + account/app — correct, provided alias resolution decides route |
| + identity before the metering wrap, which is #151's ordering. |
| + |
| +The woodstar-side half — the relay reproducing the Caddy wildcard |
| +block's host-aware behavior (`www` → apex permanent redirect, |
| +`/.well-known/atproto-did` → apbridge, anything else → temporary |
| +redirect to `https://woodstar.app/<leftmost-label>`) — is **live prod |
| +behavior and part of W0** (§6), not a follow-up. The platform |
| +guarantees: any `*.woodstar.app` request arrives at the relay with Host |
| +intact over valid TLS. |
| ## 6. W0 — relay path fan-out (the one woodstar code change) |
| -The edge routes by Host only; the deployed client expects `/media*`, |
| -`/api/*`, `/stream/*` on one origin (Caddy path-routes today). Fix is |
| -relay-side (W0a from the 2026-08-02 plan): |
| +The edge routes by Host only; the deployed client and the federated |
| +world expect Caddy's path fan-out on one origin. Fix is relay-side |
| +(W0a from the 2026-08-02 plan, widened by §0's re-recon): |
| - Optional flags `-media-upstream`, `-indexer-upstream`, |
| - `-notify-upstream` on `cmd/relay`. When set, the relay |
| - reverse-proxies `/media` + `/media/*` → media, `/api/*` → indexer, |
| - `/stream/*` → notify — mirroring Caddy's `prod_routes` block exactly, |
| - including `POST /media` uploads. `httputil.ReverseProxy` passes WS |
| - upgrades and flushes `text/event-stream`. |
| + `-notify-upstream`, `-unfurl-upstream`, `-apbridge-upstream` on |
| + `cmd/relay`. When set, the relay reverse-proxies: |
| + `/media` + `/media/*` → media (including `POST /media` uploads); |
| + `/api/*` → indexer; `/stream/*` → notify; `/unfurl*` → unfurl; |
| + `/.well-known/webfinger`, `/.well-known/host-meta`, `/users/*`, |
| + `/inbox`, `/ap/*` → apbridge — the devbox Caddyfile's route set, |
| + which is the truthful superset of prod's. `httputil.ReverseProxy` |
| + passes WS upgrades and flushes `text/event-stream`. |
| +- **Host-aware wildcard behavior** (only when fan-out is enabled and |
| + the request Host is a one-label subdomain of the relay's domain, not |
| + the apex): `www` → permanent redirect to the apex with URI preserved; |
| + `/.well-known/atproto-did` → apbridge; everything else → temporary |
| + redirect to `https://<apex>/<leftmost-label>` — byte-matching the |
| + prod Caddy wildcard block. Apex requests are untouched. |
| - Unset = today's behavior byte-identical; dev/devbox unaffected (Caddy |
| keeps the fan-out there). |
| - TDD in the woodstar repo; lands via woodstar PR; cutover binaries are |
| the merged W0 sha (the dfe406a shipment already in the bucket is |
| - pipeline-validation only, superseded). |
| + pipeline-validation only, superseded — it predates unfurl and |
| + apbridge entirely). |
| ## 7. Rehearsal, cutover, rollback |
| @@ -189,9 +265,16 @@ session drives verification and cutover steps directly. |
| **Pre-flight (any time, no prod impact):** |
| -- Over SSH: `systemctl cat woodstar-relay` (surprise env/flags), real |
| - per-service RSS, `ls /opt/woodstar/data` (nothing beyond |
| - relay.db/indexer.db/media). |
| +- Over SSH (confirming the live address first — §0's IP discrepancy): |
| + `systemctl cat` on all six `woodstar-*` units (exact flags/env, |
| + especially apbridge's `-domain`/`-seed`/`-bridgy`/`-bsky-api`), real |
| + per-service RSS, `ls /opt/woodstar/data` (expect relay.db, |
| + indexer.db, apbridge.db, apbridge.seed, unfurl.db, media — anything |
| + else is a finding), the box's actual Caddyfile vs the repo reference |
| + (`/unfurl*` presence), and `/etc/litestream.yml` actuals (which DBs |
| + replicate, and whether `apbridge.seed` has ANY backup today — if |
| + not, copy it to the backup bucket immediately as its own pre-flight |
| + fix, it is the federation identity). |
| - Create the ACME delegation CNAME in Route53 (inert until used); |
| obtain and handshake-verify `*.woodstar.app` on the flagship |
| **before** the flip — issuance does not depend on the A records. |
| @@ -202,33 +285,41 @@ session drives verification and cutover steps directly. |
| post-flip hit. |
| **Rehearsal (copies only):** server-side CopyObject of the backups into |
| -`carlos-data-ie-…/woodstar-rehearsal/`; litestream restore both DBs + |
| -media sync on the flagship; `PRAGMA integrity_check`; transient units on |
| -rehearsal ports; an sslip.io rehearsal route. The point is the |
| -**four-path check through the edge**: feed renders restored posts, |
| -`/media/*` images load, `/api/search` returns, `/stream/*` SSE streams, |
| -`/subscribe` WS connects — plus woodstar's own WebKit |
| -`console-check.mjs`. Record freeze-to-serving time; tear down units, |
| -data, routes. |
| +`carlos-data-ie-…/woodstar-rehearsal/`; litestream restore the three |
| +DBs + media sync + a seed-file copy on the flagship; |
| +`PRAGMA integrity_check` on all three; transient units on rehearsal |
| +ports; an sslip.io rehearsal route. apbridge rehearses with `-bridgy` |
| +and `-bsky-api` pointed at throwaway values (its own flags exist for |
| +exactly this) so the rehearsal never dials the real federation. The |
| +point is the **path-fan-out check through the edge**: feed renders |
| +restored posts, `/media/*` images load, `/api/search` returns, |
| +`/stream/*` SSE streams, `/subscribe` WS connects, `/unfurl` answers, |
| +webfinger + `/users/*` respond from the bridge — plus woodstar's own |
| +WebKit `console-check.mjs`. Record freeze-to-serving time; tear down |
| +units, data, routes. |
| **Cutover (HELD for Paul's explicit go; quiet hour; TTLs already 300):** |
| -1. Old box (SSH): stop the four `woodstar-*` units → run one final |
| +1. Old box (SSH): stop the six `woodstar-*` units → run one final |
| media-backup pass → wait ~30 s for litestream's final WAL frames → |
| - stop litestream → verify newest backup timestamps are post-stop. |
| -2. Flagship: litestream restore both DBs from `woodstar-backups` + |
| - media sync into `/data/woodstar`; chown woodstar; |
| - `PRAGMA integrity_check`; start the four units; enable flagship |
| + stop litestream → copy `apbridge.seed` off the box → verify newest |
| + backup timestamps are post-stop. |
| +2. Flagship: litestream restore the three DBs from `woodstar-backups` + |
| + media sync + seed file into `/data/woodstar`; chown woodstar; |
| + `PRAGMA integrity_check`; start the six units; enable flagship |
| litestream config + media timer (fresh `carlos-data-ie` prefixes — |
| order is stop-old → restore-new → start-new). |
| -3. Smoke BEFORE DNS: loopback `/info` 200, AASA correct, |
| +3. Smoke BEFORE DNS: loopback `/info` 200, AASA correct, webfinger |
| + answers from the bridge, |
| `curl --resolve woodstar.app:443:99.81.104.219` through the edge. |
| 4. Flip Route53 (zone `Z07702842ZC4MUKZR07JB`, same account): |
| `woodstar.app` A and `*.woodstar.app` A → 99.81.104.219. Watch the |
| per-host certs issue. |
| 5. Paul's browser pass: passkey sign-in, post, image upload + display, |
| - search, live notification, DM, iOS app AASA validation, status page, |
| - and a `*.woodstar.app` subdomain handshake. |
| + search, live notification, DM, link-card unfurl, iOS app AASA |
| + validation, status page, a `paul.woodstar.app` profile redirect + |
| + its `/.well-known/atproto-did`, and federation (a webfinger lookup |
| + from a real Fediverse server or Bridgy Fed still resolving). |
| **Rollback (any point before Paul blesses it):** stop flagship units + |
| its litestream; restart the old box's units + litestream; flip the two |
| @@ -269,15 +360,17 @@ disk intact, frozen 30 days. |
| - Note woodstar `infra/` tofu drift (records + stopped units outside |
| its state); retire the old prod instance after the 30-day freeze; |
| zone, bucket, devbox, IAM stay. |
| -- Update woodstar's CLAUDE.md "The boxes" section post-retirement. |
| -- File the woodstar-repo per-user-host/Bluesky issue (§5). |
| +- Update woodstar's CLAUDE.md "The boxes" section post-retirement, and |
| + its `infra/prod-Caddyfile` reference-copy header (the file describes |
| + a box that no longer serves). |
| -## 10. Open coordination points |
| +## 10. Coordination points |
| -- **Claim-shape for wildcard/alias declaration** (§4, §5): owned by the |
| - #151/#82 sessions' vocabulary; this spec's features integrate with |
| - whatever field/record shape is agreed there. Ping those sessions |
| - before the plan is written. |
| +- **Claim-shape: SETTLED 2026-08-12.** `wildcard: true` additive on |
| + schema 1, agreed with the #151 session (their branch fa5dc0b carries |
| + the field; §4 has the detail). The #151 session pings this one when |
| + their console door + CLI land, so deploy-flow tasks can cite exact |
| + paths. |
| - **Delegation-domain label** (§4): fixed in the plan (a name under |
| `oncarlos.com`). |
| - **status.woodstar.app attach shape** (§3): alias vs first-route |
| @@ -286,8 +379,9 @@ disk intact, frozen 30 days. |
| ## 11. Out of scope, named |
| - Edge path-prefix routing (W0b) — relay fan-out covers it. |
| -- Woodstar's per-user-host + Bluesky app logic — woodstar repo, |
| - follow-up issue. |
| +- New per-user-subdomain features beyond parity with the live Caddy |
| + behavior (which W0 reproduces) — e.g. serving the full app on |
| + subdomains rather than redirecting — woodstar repo, later. |
| - Devbox migration — stays on Caddy wildcard DNS-01, untouched. |
| - DNS record automation — the delegation CNAME and the cutover flip are |
| operator/customer acts, per #82's ruling. |