spec: domains — account-sqid platform domains, custom-domain health, bucket-manifest accounts
Brainstormed with Paul 2026-08-02: <app>.<account-sqid>.<apps-domain> (vanity account names rejected — squattable; custom domains are the vanity mechanism), agent-reconciled minting incl. canary hosts, DNS/TLS health columns, console Domains section. BYO apps-domain named as future.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+145
−0
docs/superpowers/specs/2026-08-02-custom-domains-design.md+145 −0
diff --git a/docs/superpowers/specs/2026-08-02-custom-domains-design.md b/docs/superpowers/specs/2026-08-02-custom-domains-design.md| new file mode 100644 |
| index 000000000..5ee4cd22c |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-02-custom-domains-design.md |
| @@ -0,0 +1,145 @@ |
| +# Domains — platform domains, custom domains, and their health |
| + |
| +Every app has domains as a first-class concept: a **platform domain** it is |
| +born with, and any number of **custom domains** layered on top — each with |
| +visible DNS and TLS health in the console, and point-your-DNS-here guidance |
| +when a custom domain isn't connected yet. Until now domains existed only as |
| +hand-created routes (`carlos add kass.training …`); this names the concept |
| +the product already relies on. |
| + |
| +## 1. The scheme |
| + |
| + <app>.<account-sqid>.<apps-domain> kass.abs.oncarlos.com |
| + <canary>.<app>.<account-sqid>.<apps-domain> pricing.kass.abs.oncarlos.com |
| + |
| +- **The account carries the sqid, the app carries its name.** App names are |
| + unique per account, not globally — no global name land-grab. Account |
| + sqids are minted from the account's registry row id via a fixed sqids |
| + alphabet (lowercase, no lookalike characters). **Deliberately not |
| + configurable**: vanity account names in the platform domain invite |
| + squatting; custom domains are the vanity mechanism. |
| +- **`PLATFORM_APPS_DOMAIN` is per-deployment config** (flagship: |
| + `oncarlos.com`), never a constant. A deployment without it set mints no |
| + platform domains — the feature is inert, litestream-convention. |
| +- DNS: one wildcard A record (`*.<apps-domain>` → edge) covers every depth |
| + (RFC 4592 wildcards match multiple labels); LE issues per-host through |
| + the edge's existing ACME machinery, no wildcard certs needed. |
| +- A platform domain proxies to the same backend as the app's primary |
| + route. **Canonical-host caveat**, stated honestly: apps that pin their |
| + host (keymail's `-host keymail.dev`) will redirect or 404 on the |
| + platform domain — correct behavior. Rastrillo apps accept any granted |
| + host from day one; for legacy apps the platform domain is the |
| + pre-custom-domain bootstrap URL. |
| + |
| +## 2. Accounts |
| + |
| +- Registry gains an additive `accounts` table (`id INTEGER PRIMARY KEY, |
| + name TEXT UNIQUE`) and an `account` column on routes' app linkage. |
| + Account **#1 is `ops`**, created by migration — the deployment |
| + operator's built-in account. Every app belongs to exactly one account; |
| + CLI-added apps default to `ops`. |
| +- **Account truth originates in the console and travels through the |
| + bucket** — never a console→registry write. On claim, the console writes |
| + `apps/<app>/account.json` (`{"account": "<name>", "owner": |
| + "<address>"}`) to the deployment bucket via its existing store handle. |
| + The agent's reconcile pass reads these manifests and materializes |
| + accounts + linkage in the registry. A deployment with no console never |
| + has manifests: everything lives under `ops`, fully functional. |
| + |
| +## 3. The agent reconcile pass |
| + |
| +Runs on the agent's existing tick cadence (piggybacked, not a new timer); |
| +each step is idempotent and additive: |
| + |
| +1. **Account materialization**: list `apps/*/account.json`; upsert |
| + accounts and app→account linkage. |
| +2. **Platform-domain minting**: any app with ≥1 route but no platform |
| + route gets `<app>.<sqid>.<apps-domain>` minted — same upstream |
| + (Socket/Addr/backing) as the app's first ready route (ordered by host, |
| + deterministic), `platform=1`. |
| + Existing apps are backfilled on the first pass after rollout. |
| +3. **Canary hosts**: any `canary/<name>` channel pointer in the bucket |
| + whose app has no route following it gets |
| + `<name>.<app>.<sqid>.<apps-domain>` minted on that channel — closing |
| + console slice 3's hostless-canary gap with no console write authority. |
| + Retiring the channel (console or CLI) removes the minted route on a |
| + later pass (only routes with `platform=1` are ever auto-removed; |
| + custom domains are never touched by reconciliation). |
| +4. **DNS health**: resolve each route host (A/AAAA) and compare against |
| + the deployment's edge IPs (`PLATFORM_EDGE_IPS`, default: this box's |
| + public IP from IMDS). Stamp additive columns `dns_status` |
| + (`pointing` | `elsewhere` | `""` unknown — resolver failure is |
| + unknown, never a guess) and `dns_checked_at`. |
| +5. **TLS health**: stamp `cert_expires_at` from the ACME cache's cert for |
| + the host (absent → 0/unknown). The route's existing |
| + `status` (`issuing`/`ready`/`failed`) remains the issuance signal. |
| + |
| +New additive registry columns on routes: `platform INTEGER NOT NULL |
| +DEFAULT 0`, `dns_status TEXT NOT NULL DEFAULT ''`, `dns_checked_at |
| +INTEGER NOT NULL DEFAULT 0`, `cert_expires_at INTEGER NOT NULL DEFAULT |
| +0`, `account TEXT NOT NULL DEFAULT ''` (app-level linkage carried on the |
| +route rows, consistent with the registry's route-centric shape). |
| + |
| +## 4. Console: the Domains section |
| + |
| +Per app page, replacing the raw routes framing: |
| + |
| +| Domain | Type | DNS | TLS | |
| +|---|---|---|---| |
| +| kass.training | custom | pointing | ready · renews 2026-10-12 | |
| +| kass.abs.oncarlos.com | platform | pointing | ready | |
| + |
| +- Unknowns render as the em-dash-titled-unknown convention (status-page |
| + style), never guessed. |
| +- A custom domain with `dns_status=elsewhere` (or unknown) shows the |
| + guidance row: *"Point an A record for kass.training at `<edge-ip>` |
| + (all regions: `<ips>`). Checks run every few minutes."* |
| +- Canary rows keep their canary labeling and now always have a URL. |
| +- The console writes `apps/<app>/account.json` at claim time (new apps) |
| + and backfills manifests for already-claimed apps on first page view |
| + after rollout. |
| + |
| +## 5. Config surface |
| + |
| +- `PLATFORM_APPS_DOMAIN` — the apps domain (unset = no platform domains). |
| +- `PLATFORM_EDGE_IPS` — comma-separated edge IPs for DNS checks |
| + (default: this box's public IP via IMDS). |
| +- Flagship values: `oncarlos.com` + the EIP. Tito: operator's choice |
| + (likely unset initially — titogo owns go.tito.io wholesale). |
| + |
| +## 6. Testing & guard rails |
| + |
| +- Registry: round-trip tests for every new column; migration idempotence. |
| +- Sqids: fixed-alphabet stability test (id 1 → same sqid forever — a |
| + changed alphabet would re-home every platform domain: the test says so). |
| +- Reconciler: table-driven over a DirStore + fixture registry — minting |
| + (new app, backfill, canary), idempotence (second pass is a no-op), |
| + never-touch-custom-domains, removal only of platform-flagged routes for |
| + retired canaries, DNS stamping with a fake resolver (unknown on error), |
| + manifest materialization incl. malformed-manifest skip-with-log. |
| +- Console: Domains rendering with fixture routes covering all |
| + status combinations; guidance row only for non-pointing custom domains; |
| + claim writes the manifest. |
| +- e2e: claim → manifest → reconcile → platform route exists → edge serves |
| + it (DirStore, fake resolver). |
| + |
| +## 7. Rollout |
| + |
| +1. Code lands (registry, agent reconcile, console section) — green before |
| + deploy. |
| +2. Ship/adopt both deployments (inert until config). |
| +3. Flagship: set `PLATFORM_APPS_DOMAIN=oncarlos.com` + |
| + `PLATFORM_EDGE_IPS`; verify kass/keymail/console/platform-site get |
| + platform domains under the right accounts; certs mint on first hit. |
| +4. Tito: leave unset (inert) until wanted. |
| + |
| +## 8. Future work (named, not built) |
| + |
| +- **Bring-your-own apps domain**: an account supplies |
| + `*.platform.company.com` as its personal apps-domain (delegated via |
| + CNAME/NS), replacing `<sqid>.<apps-domain>` for its apps. Requires |
| + per-account ACME strategy and DNS-delegation verification. |
| +- **Attach custom domains from the console** (the write-path slice |
| + deliberately excluded here). |
| +- **Multi-region DNS checks** (per-region edge IP sets once the mesh |
| + ports land). |