spec: Woodstar → Carloku migration — delegated wildcards, wildcard aliases, platform-tenant cutover
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+294
−0
docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md+294 −0
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| new file mode 100644 |
| index 000000000..52cfbab4b |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md |
| @@ -0,0 +1,294 @@ |
| +# Woodstar → Carloku migration |
| + |
| +**Date:** 2026-08-12 · **Status:** draft (design approved in conversation |
| +2026-08-12) · **Requested by:** Paul |
| + |
| +**Supersedes:** the plan half of |
| +`docs/superpowers/plans/2026-08-02-plan-woodstar-flagship.md` (its recon |
| +remains the authoritative description of where Woodstar lives today; its |
| +task list is replaced by the plan that follows this spec). |
| + |
| +**Builds on:** route records (#137), route hostnames/aliases (#151), |
| +domain attach (#82), wildcard certs (2026-08-05 — the DNS-01 machinery |
| +this spec extends), and the kass/keymail platform-tenant migrations |
| +(the playbook). |
| + |
| +## 1. Goal |
| + |
| +Woodstar's production — `woodstar.app`, `status.woodstar.app`, and |
| +working `*.woodstar.app` TLS + routing — serves from the Carloku |
| +flagship box (`i-092c0c1eea75723cb` / 99.81.104.219), riding carlos |
| +release channels. Carloku IS the CARLOS platform flagship deployment |
| +(Paul, 2026-08-12); "migrate to carloku" means onto that box. |
| + |
| +Hostnames survive byte-identical: passkeys (RP ID = page domain) and the |
| +iOS AASA binding (`J5NDF5PCD8.app.woodstar.ios`) are domain-bound. The |
| +devbox (`dev.woodstar.app`, `i-0c388ec47cf98067a`) stays put — it is the |
| +dev/canary workflow and is not migrating. |
| + |
| +Two deliberate product outcomes ride along: |
| + |
| +- The platform gains **delegated-challenge wildcard certs** (§4): the |
| + standing answer to "wildcard TLS on a customer domain whose DNS |
| + provider the platform must not know". Woodstar is treated as an |
| + untrusted third-party customer for this feature even though it is a |
| + platform tenant. |
| +- The platform gains **wildcard aliases** (§5): `*.<domain>` resolving |
| + to a primary route, preserving Woodstar's "any subdomain serves the |
| + app" contract and enabling the Bluesky handle story |
| + (`@paul.woodstar.app` → the user's woodstar profile; atproto handle |
| + verification needs `https://paul.woodstar.app/.well-known/atproto-did` |
| + to answer over valid TLS). |
| + |
| +## 2. Tenancy shape |
| + |
| +**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`: |
| + 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). |
| +- 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 |
| + runtime env; no secrets, no SSM staging, no IMDS allowance (the Go |
| + 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}` |
| + (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. |
| + |
| +## 3. Sequencing — after the custom-domain track |
| + |
| +Gate: **#137 merged and deployed, then #151, then #82 — live on the |
| +flagship** — before rehearsal-onward steps. `woodstar.app` and |
| +`status.woodstar.app` then attach through the product surface |
| +end-to-end: |
| + |
| +- The relay's primary route exists as a **declared route record** |
| + (#137 vocabulary); `woodstar.app` attaches as an **alias** to it via |
| + the #82/#151 door. |
| +- `status.woodstar.app` attaches for `woodstar-status` (alias to its |
| + primary route, or first-route static attach — whichever the landed |
| + door supports for the shape at hand). |
| +- No `carlos add` over SSM for the public hostnames. |
| + |
| +Work that does NOT wait (no code-path overlap with the track): W0 in |
| +the woodstar repo (§6), and the two platform features (§4, §5) up to |
| +review — their claim-shape integration point is coordinated with the |
| +#151/#82 sessions (§10). |
| + |
| +## 4. Delegated-challenge wildcard certs |
| + |
| +The platform must hold no customer DNS credentials and never know the |
| +customer's DNS provider. Wildcard issuance is DNS-01, which normally |
| +requires writing TXT records into the customer zone — squared by |
| +**ACME challenge delegation via CNAME** (the Cloudflare-for-SaaS |
| +pattern): |
| + |
| +1. The customer creates, once, in their own zone with any provider: |
| + `_acme-challenge.<domain>` CNAME → |
| + `_acme-challenge.<domain>.<delegation-domain>`, where the delegation |
| + domain is a platform-controlled zone (under `oncarlos.com`; exact |
| + label fixed in the plan). |
| +2. At issuance the edge publishes the challenge TXT **in its own zone** |
| + at the delegation target, via the DNSimple provider it already has. |
| +3. Let's Encrypt follows the CNAME from the customer zone into ours, |
| + sees the TXT, issues `*.<domain>`. |
| + |
| +Properties: no customer credentials; revocable by the customer deleting |
| +one record; the CNAME's existence is standing proof of domain control — |
| +strictly stronger evidence than #82's DNS-pointing ruling. This |
| +retires part of the "bring-your-own apps domain / DNS delegation" price |
| +tag #82 §9 left unpaid. |
| + |
| +Mechanics (all provider-blind, the `dnsProvider` seam is untouched): |
| + |
| +- `wildcard.go` computes `challengeFQDN = _acme-challenge.<parent>` |
| + 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 |
| + as it derives `<acct>.<apps-domain>` parents from the route table — |
| + no bare env list. |
| +- **Gate:** the attach surface verifies the delegation CNAME exists |
| + before enabling (the #82 `pointing`/`elsewhere` stamp spirit) — a |
| + missing CNAME is an honest stamp, not a silent hourly issuance |
| + failure. |
| +- Every failure degrades to per-host issuance, like today's wildcard |
| + failures — never a dead edge. |
| + |
| +## 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: |
| + |
| +- `*.<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. |
| +- 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. |
| + |
| +## 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): |
| + |
| +- 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`. |
| +- 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). |
| + |
| +## 7. Rehearsal, cutover, rollback |
| + |
| +**Playbook constraints (inherited, standing):** no DNS changes and no |
| +touching running woodstar services until the gated steps; the flip is |
| +HELD for Paul's explicit go; rehearsals only ever read COPIES — never |
| +point a litestream at the live `prod/` prefixes (lineage fork); never |
| +run old-box and flagship stacks against the same replication prefixes |
| +at once; no `carlos bootstrap` on the live flagship. |
| + |
| +**Old-box access:** the prod box (`ec2-user@52.30.232.124`, SSH-only, |
| +no SSM) gets an **allow rule for the executing session's SSH** so the |
| +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). |
| +- 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. |
| +- Attach `woodstar.app` + `status.woodstar.app` through the door; |
| + declare the wildcard alias. The relay's primary route record is |
| + materialized and adopted, and all alias entries are derived box-side, |
| + pre-flip. Per-host certs for the two named hosts mint on first |
| + 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. |
| + |
| +**Cutover (HELD for Paul's explicit go; quiet hour; TTLs already 300):** |
| + |
| +1. Old box (SSH): stop the four `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 |
| + 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, |
| + `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. |
| + |
| +**Rollback (any point before Paul blesses it):** stop flagship units + |
| +its litestream; restart the old box's units + litestream; flip the two |
| +A records back. Writes accepted by the flagship during the window live |
| +only in its lineage — keep the window short. Old box: units stopped, |
| +disk intact, frozen 30 days. |
| + |
| +**Named failure shapes:** |
| + |
| +- **Degraded-not-broken:** a misconfigured fan-out or alias leaves the |
| + site "working" while media/search/notify silently fail. The four-path |
| + checks run at rehearsal AND at cutover — never skipped. |
| +- **Founder trap:** never start units against an empty `/data/woodstar` |
| + reachable on the real hostname — the first registrant becomes |
| + founder. Restore is explicit; there is no restore-on-boot. |
| +- **SSE/WS through two proxied hops** (edge → relay → notify): verified |
| + in rehearsal, not assumed. |
| + |
| +## 8. Testing |
| + |
| +- Platform: the full gate (`go build/vet/test ./...`, gofmt, darwin |
| + cross-compile) plus delegated-wildcard unit tests (parent→target |
| + mapping; TXT published at the target, not the origin; cleanup; |
| + degradation to per-host on failure), wildcard-alias tests (one-label |
| + matching, named-route precedence, Host preserved to upstream), and |
| + parse goldens for the agreed claim-shape extension (additive rule — |
| + pre-change claims must still parse). |
| +- Woodstar: W0 TDD (proxy wiring, SSE flush, WS `/subscribe` |
| + untouched); `make test` green; e2e harness where practical. |
| +- Live verification is §7's rehearsal + cutover checks — verify against |
| + the thing changed, with the binary built. |
| + |
| +## 9. Post-cutover |
| + |
| +- Promote the W0 sha to stable after the 72 h supply-chain bake. |
| +- Repoint status publishing: `carlos ship -kind static` + promote |
| + replaces `scripts/status-publish.sh`. |
| +- 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). |
| + |
| +## 10. Open 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. |
| +- **Delegation-domain label** (§4): fixed in the plan (a name under |
| + `oncarlos.com`). |
| +- **status.woodstar.app attach shape** (§3): alias vs first-route |
| + static — decided against the landed #82 door. |
| + |
| +## 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. |
| +- 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. |
| +- Multi-provider DNS on one edge — delegation makes it unnecessary. |