| new file mode 100644 |
| index 000000000..6d4644eab |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-domain-attach.md |
| @@ -0,0 +1,757 @@ |
| +# Domain Attach (#82) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Self-serve custom-domain attach: console Settings UI, a shared |
| +attach/detach core, and `carlos deploy -host` auto-attach, per |
| +`docs/superpowers/specs/2026-08-12-domain-attach-design.md`. |
| + |
| +**Architecture:** The door writes a global create-only claim |
| +(`domains/<host>.json`); an app with a ready route gets an alias claim |
| +(#151's box-side machinery serves it), a routeless static app gets a |
| +claim plus a declared `provision.Record` that #137's reconciler |
| +materializes. One console-side core implements both; the browser door and |
| +the CLI-API door are thin wrappers. #151 (branch `worktree-route-hostnames`) |
| +owns the claim writer, the exported validation spot, the `/api/cli/.../domains` |
| +handlers and the `carlos domains` verbs — Tasks 1–2 build interim, |
| +clearly-marked stand-ins so work proceeds tonight; Task 8 swaps them for |
| +#151's exports when their ping arrives. |
| + |
| +**Tech Stack:** Go, stdlib `net/http` mux patterns, `release.Store` |
| +(create-only CAS via `Put(ctx, key, body, "")` → `release.ErrCASConflict`), |
| +html/template console templates. |
| + |
| +## Global Constraints |
| + |
| +- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/domain-attach`, branch `worktree-domain-attach` (stacked on `worktree-route-records`). Never touch the main checkout (a different session's dirty `unit-economics` state lives there). |
| +- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, sandbox disabled ("read-only file system" is the tell). |
| +- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1`, plus `gofmt -l` clean on touched files. |
| +- Wire shapes are additive; anything written before the change must still parse. |
| +- Any test that shells out to the carlos binary builds env via `carlosTestEnv(t, ...)` (`e2e_test.go:81`) AND sets `cmd.Dir` to a scratch dir. |
| +- Claim shape is FROZEN by cross-session agreement: `{schema, host, account, app, route, created_at, created_by}` — do not add fields. |
| +- Commit messages end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. |
| + |
| +--- |
| + |
| +### Task 1: Interim claim package `internal/domainclaim` |
| + |
| +**Files:** |
| +- Create: `internal/domainclaim/domainclaim.go` |
| +- Test: `internal/domainclaim/domainclaim_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `Claim` struct, `Key(host string) string`, |
| + `Create(ctx context.Context, s release.Store, c Claim) error`, |
| + `Get(ctx context.Context, s release.Store, host string) (Claim, string, error)`, |
| + `Delete(ctx context.Context, s release.Store, host string) error`, |
| + `ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error)`. |
| + Task 3 (core), Task 8 (swap) consume these. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/domainclaim/domainclaim_test.go |
| +package domainclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "carlosframework/platform/internal/release" // match the module path used by internal/provision imports |
| +) |
| + |
| +func TestCreateIsCreateOnly(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + c := Claim{Schema: 1, Host: "www.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC(), CreatedBy: "cli:paul@keymail.dev"} |
| + if err := Create(ctx, s, c); err != nil { |
| + t.Fatalf("first create: %v", err) |
| + } |
| + err := Create(ctx, s, Claim{Schema: 1, Host: "www.example.com", Account: "bab", App: "other", CreatedAt: time.Unix(1755000001, 0).UTC()}) |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create: got %v, want ErrCASConflict", err) |
| + } |
| +} |
| + |
| +func TestGetRoundTripsAndKeyShape(t *testing.T) { |
| + if got := Key("www.example.com"); got != "domains/www.example.com.json" { |
| + t.Fatalf("Key = %q", got) |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + in := Claim{Schema: 1, Host: "example.com", Account: "bdj", App: "story", Route: "story.bdj.oncarlos.com", CreatedAt: time.Unix(1755000000, 0).UTC()} |
| + if err := Create(ctx, s, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + out, etag, err := Get(ctx, s, "example.com") |
| + if err != nil || etag == "" { |
| + t.Fatalf("Get: %v etag=%q", err, etag) |
| + } |
| + if out != in { |
| + t.Fatalf("round trip: got %+v want %+v", out, in) |
| + } |
| +} |
| + |
| +func TestListForAppFiltersOtherOwners(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, c := range []Claim{ |
| + {Schema: 1, Host: "a.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + {Schema: 1, Host: "b.example.com", Account: "bdj", App: "blog", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + {Schema: 1, Host: "c.example.com", Account: "bab", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()}, |
| + } { |
| + if err := Create(ctx, s, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := ListForApp(ctx, s, "bdj", "story") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 || got[0].Host != "a.example.com" { |
| + t.Fatalf("ListForApp = %+v, want exactly a.example.com", got) |
| + } |
| +} |
| + |
| +func TestDeleteIsIdempotent(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := Delete(ctx, s, "never-existed.example.com"); err != nil { |
| + t.Fatalf("delete of absent claim: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +Before writing: open `internal/provision/provision.go:1-30` and copy the |
| +exact module import path it uses for `internal/release`. |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/domainclaim/ -count=1` |
| +Expected: FAIL — package does not exist / functions undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +```go |
| +// internal/domainclaim/domainclaim.go |
| +// Package domainclaim reads and writes the global custom-domain claim at |
| +// domains/<host>.json — the create-only record whose successful create IS |
| +// the fleet-wide uniqueness check for an attached hostname (first writer |
| +// wins; a lost create surfaces release.ErrCASConflict and the door answers |
| +// "host unavailable" without naming the holder). |
| +// |
| +// INTERIM (#82/#151 cross-session agreement, 2026-08-12): #151's branch |
| +// (worktree-route-hostnames) owns the canonical claim writer; this package |
| +// is the agreed shape built here so #82's doors can proceed, and it is |
| +// REPLACED by #151's export when the branches merge. The shape below is |
| +// frozen by that agreement — do not add fields here. |
| +package domainclaim |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "carlosframework/platform/internal/release" |
| +) |
| + |
| +// Claim is the wire shape at domains/<host>.json. Route names the primary |
| +// route this host aliases; empty Route means the host is (or will be) its |
| +// own primary route — the first-route static case. |
| +type Claim struct { |
| + Schema int `json:"schema"` |
| + Host string `json:"host"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Route string `json:"route,omitempty"` |
| + CreatedAt time.Time `json:"created_at"` |
| + CreatedBy string `json:"created_by,omitempty"` |
| +} |
| + |
| +const prefix = "domains/" |
| + |
| +// Key is the bucket key for a host's claim. |
| +func Key(host string) string { return prefix + host + ".json" } |
| + |
| +// Create writes the claim create-only: a lost race (any existing claim, |
| +// whoever holds it) is release.ErrCASConflict, never an overwrite. |
| +func Create(ctx context.Context, s release.Store, c Claim) error { |
| + body, err := json.MarshalIndent(c, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, Key(c.Host), body, "") |
| + return err |
| +} |
| + |
| +// Get reads one claim and its etag. |
| +func Get(ctx context.Context, s release.Store, host string) (Claim, string, error) { |
| + body, etag, err := s.Get(ctx, Key(host)) |
| + if err != nil { |
| + return Claim{}, "", err |
| + } |
| + var c Claim |
| + if err := json.Unmarshal(body, &c); err != nil { |
| + return Claim{}, "", fmt.Errorf("claim %s: malformed: %w", Key(host), err) |
| + } |
| + return c, etag, nil |
| +} |
| + |
| +// Delete releases a claim. Store.Delete is idempotent, so releasing an |
| +// already-gone claim is not an error. |
| +func Delete(ctx context.Context, s release.Store, host string) error { |
| + return s.Delete(ctx, Key(host)) |
| +} |
| + |
| +// ListForApp returns the claims held by one account+app pair. Claims that |
| +// fail to parse are skipped: a malformed claim is #151's reconciler's |
| +// problem to surface, not a reason to blank an app's Settings list. |
| +func ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error) { |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []Claim |
| + for _, k := range keys { |
| + if !strings.HasSuffix(k, ".json") { |
| + continue |
| + } |
| + host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + c, _, err := Get(ctx, s, host) |
| + if err != nil { |
| + continue |
| + } |
| + if c.Account == account && c.App == app { |
| + out = append(out, c) |
| + } |
| + } |
| + return out, nil |
| +} |
| +``` |
| + |
| +If `s.Get` on a missing key returns a distinct sentinel (check |
| +`internal/release/store.go` — provision's callers test it), have `Get` |
| +pass it through unwrapped so callers can distinguish absent from |
| +malformed. |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/domainclaim/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/domainclaim/ |
| +git add internal/domainclaim/ |
| +git commit -m "domainclaim: interim claim record package (agreed #151 shape) |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Attach policy validation |
| + |
| +**Files:** |
| +- Create: `internal/console/domainpolicy.go` |
| +- Test: `internal/console/domainpolicy_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `provision.ValidHostShape(host string) bool`. |
| +- Produces: `func attachHostRefusal(host, appsDomain string) string` — |
| + empty string means allowed; non-empty is the member-facing refusal |
| + text. Tasks 3–5 consume it. (Task 8 relocates the policy into #151's |
| + exported validation spot; the tests move with it.) |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/console/domainpolicy_test.go |
| +package console |
| + |
| +import "testing" |
| + |
| +func TestAttachHostRefusal(t *testing.T) { |
| + cases := []struct { |
| + name, host, appsDomain string |
| + wantAllowed bool |
| + }{ |
| + {"plain custom fqdn", "www.example.com", "oncarlos.com", true}, |
| + {"apex", "example.com", "oncarlos.com", true}, |
| + {"uppercase rejected", "WWW.example.com", "oncarlos.com", false}, |
| + {"empty host", "", "oncarlos.com", false}, |
| + {"traversal shape", "../control/serial", "oncarlos.com", false}, |
| + {"apps domain itself", "oncarlos.com", "oncarlos.com", false}, |
| + {"under apps domain", "story.bdj.oncarlos.com", "oncarlos.com", false}, |
| + {"apps domain unset allows customs", "www.example.com", "", true}, |
| + {"suffix but not label boundary", "notoncarlos.com", "oncarlos.com", true}, |
| + } |
| + for _, c := range cases { |
| + got := attachHostRefusal(c.host, c.appsDomain) |
| + if (got == "") != c.wantAllowed { |
| + t.Errorf("%s: attachHostRefusal(%q,%q) = %q, want allowed=%v", c.name, c.host, c.appsDomain, got, c.wantAllowed) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1` |
| +Expected: FAIL — `attachHostRefusal` undefined. |
| + |
| +- [ ] **Step 3: Write the implementation** |
| + |
| +```go |
| +// internal/console/domainpolicy.go |
| +// Attach-door host policy (#82). #151 exports the shared validation spot |
| +// for the domains door; when the branches merge these checks land there |
| +// (Task 8) — the rules themselves are this file's contract: |
| +// - hostname shape only (ValidHostShape — apexes allowed, lowercase), |
| +// - never platform space: an attached domain is a foreign domain by |
| +// definition, so anything equal to or under the deployment's apps |
| +// domain is refused before any claim is written. |
| +// First-writer-wins is NOT here: the claim's create-only write is the |
| +// entire uniqueness check, and its refusal is the door's neutral |
| +// "host unavailable". |
| +package console |
| + |
| +import ( |
| + "strings" |
| + |
| + "carlosframework/platform/internal/provision" |
| +) |
| + |
| +func attachHostRefusal(host, appsDomain string) string { |
| + if !provision.ValidHostShape(host) { |
| + return "not a valid hostname" |
| + } |
| + if appsDomain != "" { |
| + if host == appsDomain || strings.HasSuffix(host, "."+appsDomain) { |
| + return "hosts under the platform's apps domain are assigned by the platform, not attached" |
| + } |
| + } |
| + return "" |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainpolicy.go |
| +git add internal/console/domainpolicy.go internal/console/domainpolicy_test.go |
| +git commit -m "console: attach-door host policy — shape + never platform space |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Attach/detach core |
| + |
| +**Files:** |
| +- Create: `internal/console/domainattach.go` |
| +- Test: `internal/console/domainattach_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `domainclaim.Create/Get/Delete` (Task 1), |
| + `attachHostRefusal` (Task 2), `provision.CreateRecord` / |
| + `provision.DeleteRecord` / `provision.Record` |
| + (`internal/provision/provision.go`), `release.ChannelsPrefix(acct, app)` |
| + (`internal/release/release.go:207`), `registry.Open` + route listing |
| + (`internal/registry`). |
| +- Produces (Tasks 4, 5, 8 consume): |
| + |
| +```go |
| +type attachResult struct { |
| + Claim domainclaim.Claim |
| + FirstRoute bool // true when a declared record was written |
| + Channel string // channel the first-route record follows ("" for alias) |
| +} |
| +// attachDomain: kindFlag/channelFlag may be "" (meaning: infer). |
| +func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) |
| +func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal |
| +type doorRefusal struct { |
| + Status int // http status the doors translate to |
| + Msg string // member-facing text; NEVER names another account |
| +} |
| +``` |
| + |
| +Behavior contract (from spec §3–§4), which the tests below pin: |
| + |
| +1. `attachHostRefusal` first → 422 refusal. |
| +2. Ready-route lookup: open `s.cfg.Registry` (skip lookup when the path |
| + is empty — tests), list routes, find the app+account's first |
| + non-Platform ready route by host order (the `firstReadyUpstream` |
| + selection rule, `internal/domains/reconcile.go:617`, minus the |
| + upstream-copy part — only the HOST is needed). Found → alias claim |
| + `{Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: <that host>, CreatedAt: now, CreatedBy: principal}`; |
| + no record written; `FirstRoute: false`. |
| +3. No ready route → first-route path: `kindFlag` must be `""` or |
| + `"static"` (anything else → 422 "an app with no running route can only |
| + attach a static domain — deploy first, then attach"). Channel = |
| + `channelFlag`, or the app's sole channel from |
| + `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))` |
| + (strip prefix + `.json` for names); zero channels → 422 "nothing |
| + shipped yet — carlos ship first"; more than one and no flag → 422 |
| + naming them all. Then claim (Route empty) + `provision.CreateRecord` |
| + with `Record{Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedAt: now, CreatedBy: principal}`. |
| + If CreateRecord fails after the claim landed, best-effort |
| + `domainclaim.Delete` then return 500. |
| +4. `domainclaim.Create` losing the race (`errors.Is(err, release.ErrCASConflict)`) |
| + → 409 with exactly `"host unavailable"` — including when the caller's |
| + own account holds it (no owner leak; GET is where you see your own). |
| +5. Success: `s.audit(acct, app, principal, "domain-attach", ...)` + |
| + `s.bumpSerial(ctx)` inside the core (both doors get it for free); |
| + detach mirrors with `"domain-detach"`. |
| +6. `detachDomain`: `domainclaim.Get`; absent, or `Account != acct.Sqid`, |
| + or `App != app` → 404 refusal (byte-identical treatment comes from |
| + the doors calling `http.NotFound`). Claim's `Route == ""` (first-route) |
| + → `provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)` |
| + first, then `domainclaim.Delete`, audit, serial. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Write `internal/console/domainattach_test.go` in package `console`, |
| +following the existing server-test construction pattern — before writing, |
| +open `internal/console/apiinstances_test.go` (or the nearest test that |
| +builds a `*Server` with `cfg.Releases = release.NewDirStore(t.TempDir())`) |
| +and copy its server/fixture setup helper. Cover, minimum: |
| + |
| +```go |
| +// Names are the contract; bodies follow the fixture pattern you copied. |
| +func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) // registry fixture with a ready route → Claim.Route = that host, no record under RecordsPrefix, FirstRoute false |
| +func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) // no routes, one shipped channel → claim with empty Route AND a parseable provision.Record at RecordKey(sqid, app, host) with Kind static + that channel |
| +func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) // two channels, no channelFlag → 422, msg contains both names, NO claim written |
| +func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) // kindFlag "instance", no routes → 422, no claim |
| +func TestAttachHeldHostIsNeutral409(t *testing.T) // pre-created claim by another account → 409, msg exactly "host unavailable" |
| +func TestAttachPlatformSpaceRefused(t *testing.T) // host under cfg.AppsDomain → 422, no claim |
| +func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) |
| +func TestDetachForeignClaimIs404(t *testing.T) // claim held by another account+app → 404 refusal |
| +``` |
| + |
| +For the registry fixture: `reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))` |
| +then `reg.Upsert(registry.Route{Host: "story.bdj.oncarlos.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"})` |
| +and point `cfg.Registry` at that path (match field names against the |
| +`Config` struct at `internal/console/server.go:165`). |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1` |
| +Expected: FAIL — `attachDomain`/`detachDomain` undefined. |
| + |
| +- [ ] **Step 3: Implement `domainattach.go` to the behavior contract above** |
| + |
| +File header comment: name the spec |
| +(`docs/superpowers/specs/2026-08-12-domain-attach-design.md`) and the |
| +claim-then-record write order as the invariant (uniqueness is decided |
| +before anything else exists; a half-failed attach leaves at worst an |
| +unclaimed record-less claim that detach or retry clears). |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainattach.go |
| +git add internal/console/domainattach.go internal/console/domainattach_test.go |
| +git commit -m "console: attach/detach core — alias claim or claim+declared record |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Browser door — Settings attach/detach handlers |
| + |
| +**Files:** |
| +- Create: `internal/console/appdomains.go` |
| +- Modify: `internal/console/server.go` (route registration block, near the |
| + `POST /a/{sqid}/apps/{app}/rename` registration at `server.go:378-403`) |
| +- Test: `internal/console/appdomains_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `s.accountApp(w, r)` (`server.go:1533` — session + membership |
| + + app 404), the CSRF constant-time compare pattern and gate ORDER from |
| + `handleAppDelete` (`internal/console/appsettings.go:339`), |
| + `attachDomain`/`detachDomain` (Task 3). |
| +- Produces: `POST /a/{sqid}/apps/{app}/domains/attach` (form fields |
| + `csrf`, `host`, optional `channel`) and |
| + `POST /a/{sqid}/apps/{app}/domains/detach` (form fields `csrf`, `host`, |
| + `confirm` — must equal the host). Redirect back to |
| + `/a/{sqid}/apps/{app}/settings` on success; `doorRefusal` renders via |
| + `s.renderMessage` with the refusal's status. |
| + |
| +Gate order (copy `handleAppDelete`'s shape exactly, WITHOUT the |
| +`requireFreshAuth` step — spec §6: attach/detach are member-reversible, |
| +no passkey step-up; detach's protection is the typed-host confirm): |
| +session (`accountApp`) → CSRF (`subtle.ConstantTimeCompare`) → role must |
| +be `RoleOwner` else byte-identical `http.NotFound` → refusals → act. |
| + |
| +- [ ] **Step 1: Write failing handler tests** — follow the request/response |
| +test pattern of the nearest existing browser-door test (grep |
| +`internal/console/*_test.go` for `handleAppDelete` or `csrf` to find it; |
| +copy its session-fixture helper). Cover: happy attach (alias fixture from |
| +Task 3's test), CSRF mismatch → 403, non-owner role → 404 with a body |
| +byte-identical to an unknown app's 404, detach without matching `confirm` |
| +→ 422 and claim survives, detach happy path → redirect + claim gone. |
| + |
| +- [ ] **Step 2: Run to verify they fail** — `go test ./internal/console/ -run TestAppDomains -count=1` → FAIL, handlers undefined. |
| + |
| +- [ ] **Step 3: Implement handlers + register routes** |
| + |
| +```go |
| +// server.go, in the block with the other app POST routes: |
| +s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach) |
| +s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach) |
| +``` |
| + |
| +Handlers live in `appdomains.go`, thin over the Task 3 core; translate |
| +`doorRefusal{Status, Msg}` via `s.renderMessage(w, r, refusal.Status, "Refused", refusal.Msg)`. |
| + |
| +- [ ] **Step 4: Run to verify they pass** — same command, PASS. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/appdomains.go internal/console/server.go |
| +git add internal/console/appdomains.go internal/console/appdomains_test.go internal/console/server.go |
| +git commit -m "console: browser attach/detach door on app Settings |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Settings page UI — replace the honesty text |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/app_settings.html:49-57` (the |
| + "Add a custom domain … carlos add … on the platform's roadmap" block) |
| +- Modify: `internal/console/appsettings.go` (`assembleSettings` — add |
| + attached-domain rows) and/or `internal/console/view.go` if the data |
| + fits `DomainView` (`view.go:71`) better; implementer picks whichever |
| + keeps the template loop single. |
| +- Modify: `internal/console/appsettings.go:15-18` package comment — the |
| + "domains attach on the box today" sentence is now false; rewrite to |
| + name this door. |
| +- Test: extend `internal/console/appsettings_test.go` (or the file |
| + Task 4's fixture lives in). |
| + |
| +**Interfaces:** |
| +- Consumes: `domainclaim.ListForApp` (Task 1), `DomainView` + |
| + `domainView(rt, edgeIPs)` (`view.go:71,79`), `s.cfg.EdgeIPs`, |
| + the Task 4 POST endpoints. |
| + |
| +Behavior: |
| +- The existing Domains & TLS table (`app_settings.html:30-48`) keeps |
| + showing ROUTED hosts. Add claim-only rows for attached hosts that have |
| + no colocated registry row yet: Type `custom · attaching`, DNS column |
| + `""` (unknown), Guidance = the same A-record text `domainView` builds |
| + from `edgeIPs`. |
| +- Below the table: the attach form (text input `host`, optional |
| + `channel` input shown only when the app has no route, hidden `csrf`, |
| + submit "Attach domain") posting to `{{.Base}}/apps/{{.Detail.App}}/domains/attach`; |
| + per attached host a detach form with typed-host `confirm` input. |
| + Follow the rename form (`app_settings.html:15-21`) for markup/CSRF |
| + idiom; icons are vendored Lucide constants if any are used — no CDN. |
| +- Empty state line: "This app already answers at its platform domain; |
| + attaching adds addresses, it never removes one." |
| + |
| +- [ ] **Step 1: Failing test** — assert the settings page render (a) no |
| +longer contains the string `carlos add -host`, (b) contains |
| +`domains/attach`, and (c) with a claim fixture present, contains that |
| +host. Use the page-render test pattern already covering |
| +`app_settings` (grep for `app_settings` in `internal/console/*_test.go`). |
| +- [ ] **Step 2: Run to verify it fails** — render still shows the old text. |
| +- [ ] **Step 3: Implement template + assembly changes.** |
| +- [ ] **Step 4: Run to verify it passes** — plus eyeball |
| +`go test ./internal/console/ -count=1` for template-parse regressions. |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/console/templates/app_settings.html internal/console/appsettings.go internal/console/view.go internal/console/appsettings_test.go |
| +git commit -m "console: Settings attaches domains itself — honesty text retired |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: CLI client + `carlos deploy -host` auto-attach |
| + |
| +**Files:** |
| +- Modify: `apimode.go` (new apiSession methods), `deploy.go` |
| + (auto-attach step + honest DNS-failure text) |
| +- Test: `deploy_test.go` (or the file that already tests |
| + `deployTargets`/`watchVersion` — find with `grep -l deployTargets *_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: `apiSession.getJSON/postJSON` (`apimode.go:266,291`), |
| + `apiPath(a, app, tail)` (`apimode.go:217`), `deployTargets` |
| + (`deploy.go:180`), `staticDeployHosts` (`deploy.go:223`), |
| + `watchVersion` (`deploy.go:417`). |
| +- Produces (Task 8's e2e consumes; #151's verbs will share): |
| + |
| +```go |
| +type apiDomain struct { |
| + Host string `json:"host"` |
| + Route string `json:"route,omitempty"` |
| + DNS string `json:"dns,omitempty"` // "pointing" | "elsewhere" | "" |
| + Guidance string `json:"guidance,omitempty"` |
| +} |
| +func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomain, error) // GET .../domains |
| +func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, kind, channel string) (apiDomain, error) // POST .../domains/{host} |
| +``` |
| + |
| +Wire contract (agreed with #151; the server half arrives in Task 8): |
| +`POST /api/cli/apps/{account}/{app}/domains/{host}` body |
| +`{"kind":"static","channel":"canary/live"}` (both optional) → 201 with an |
| +`apiDomain`; 409 body contains `host unavailable`; 422 refusal text |
| +passes through to the user verbatim. |
| + |
| +Behavior in `runDeploy`: when `-host` is set and the resolved static |
| +target host is NOT in `sess.instanceList` results and not already |
| +claimed (a `domainList` miss), call `domainAttach` (kind from `-kind` |
| +flag when it is `static`, channel from the resolved deploy channel) |
| +BEFORE shipping; print `attached <host> (first route: static, channel <c>)` |
| +or the refusal and stop. After `watchVersion` timeout, if the last |
| +failure was connection/DNS-class, fetch `domainList` and print the |
| +guidance + stamp with exit text: |
| +`DNS for <host> is not pointing at this deployment yet. Create: <guidance>. Watch with 'carlos domains -app <app>'; re-run this deploy any time — the shipped release is already promoted.` |
| +(exit nonzero; never claim success). |
| + |
| +- [ ] **Step 1: Failing tests** — an `httptest.NewServer` fake console |
| +(follow the existing fake-console pattern; find it with |
| +`grep -rl "httptest.NewServer" *_test.go | head`) asserting: (a) |
| +`domainAttach` hits the right path+body, (b) deploy with `-host` on an |
| +unclaimed static target POSTs attach before ship, (c) the |
| +DNS-not-pointing exit message includes the fake's guidance text and the |
| +process exits nonzero. |
| +- [ ] **Step 2: Run to verify they fail.** |
| +- [ ] **Step 3: Implement client methods + deploy wiring.** |
| +- [ ] **Step 4: Run to verify they pass** — `go test . -run 'Deploy|Domain' -count=1`. |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add apimode.go deploy.go deploy_test.go |
| +git commit -m "cli: deploy -host auto-attaches an unclaimed custom domain |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Backfill golden — the story.carlosframework.com shape adopts |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile_test.go` (one new test beside the |
| + existing adopt-on-match tests — find them with |
| + `grep -n "adopt" internal/provision/reconcile_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: `Reconcile` + `ReconcileConfig` (`reconcile.go:29,57`), |
| + `shapeMismatch` semantics (`reconcile.go:143`), `CreateRecord`. |
| + |
| +- [ ] **Step 1: Write the test** (it should PASS already — #137 built |
| +adoption; this golden pins the #82 backfill path specifically): |
| +pre-Upsert the operator-added shape |
| +`registry.Route{Host: "story.carlosframework.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"}`, |
| +write the matching declared record |
| +(`Record{Schema: 1, Host: "story.carlosframework.com", App: "story", Account: "bdj", Channel: "canary/live", Kind: "static"}`), |
| +run `Reconcile`, assert the route is now `Provisioned` and its |
| +Socket/DBPath are still empty. Add a companion negative: same record but |
| +existing route `Kind: "instance"` → NOT adopted, still unprovisioned. |
| +- [ ] **Step 2: Run** — `go test ./internal/provision/ -run Adopt -count=1` |
| +(adjust `-run` to the names used). Expected: PASS. If the static shape |
| +does NOT adopt, that is a real #137 gap — STOP and report rather than |
| +patching around it. |
| +- [ ] **Step 3: Gate and commit** |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile_test.go |
| +git commit -m "provision: golden — declared static record adopts the story-shaped route |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: Integration with #151 (GATED — wait for their ping) |
| + |
| +**Files:** determined by #151's ping (their package path, exported names, |
| +door files, CLI verb file). Expected shape of the work: |
| +- Merge `origin/worktree-route-hostnames` into `worktree-domain-attach`. |
| +- Delete `internal/domainclaim/`; repoint Tasks 1–6's imports at #151's |
| + exported claim writer/reader (same frozen shape — mechanical rename). |
| +- Move `attachHostRefusal`'s checks + tests into #151's exported |
| + validation spot; delete `domainpolicy.go`. |
| +- Wire `attachDomain`'s first-route branch into #151's |
| + `POST /api/cli/.../domains/{host}` handler (their handler gates via the |
| + `apiInstancePrincipal` pattern; the core call slots where their |
| + alias-claim write happens), and `detachDomain` likewise; confirm their |
| + GET's response fields cover `apiDomain` (Task 6) — if their names |
| + differ, Task 6's client adapts to THEIRS (wire contract is theirs to |
| + pin). |
| +- e2e (new file `domains_e2e_test.go`, `carlosTestEnv` + `cmd.Dir` |
| + scratch): `carlos deploy -kind static -host <h>` against a real test |
| + console — attach happens, record exists, second attach from another |
| + account refuses neutrally; `carlos domains -app` lists it. |
| + |
| +**Steps:** |
| +- [ ] Merge their branch; resolve; full gate. |
| +- [ ] Swap stub → their exports (compile-driven: delete the stub, chase errors). |
| +- [ ] Wire door branches; run THEIR door tests + Task 3–6 tests together. |
| +- [ ] Write + run the e2e. |
| +- [ ] Gate and commit: |
| + |
| +```bash |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add -A |
| +git commit -m "domains: integrate #151 door — one claim writer, one validation spot |
| + |
| +Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" |
| +``` |
| + |
| +If the ping has not arrived when Tasks 1–7 are done: hold this task, |
| +proceed to Task 9's PR as DRAFT noting the gate, and check for the ping |
| +periodically — do not re-implement #151's door. |
| + |
| +--- |
| + |
| +### Task 9: Whole-branch review, push, PR |
| + |
| +- [ ] **Step 1: Whole-branch review** — reread the spec top to bottom |
| +against `git diff worktree-route-records...HEAD`; walk every §2 ruling |
| +and §8 test bullet to a test that exists. Fix inline. |
| +- [ ] **Step 2: Full gate one more time** (all packages, `-count=1`), plus |
| +`GOOS=darwin GOARCH=arm64 go build ./...` (the CI cross-compile). |
| +- [ ] **Step 3: Push and open the PR** |
| + |
| +```bash |
| +git push -u origin worktree-domain-attach |
| +gh pr create --repo carlosframework/platform --base main \ |
| + --title "Domain attach: self-serve custom domains from Settings, CLI and deploy (#82)" \ |
| + --body "<summary; note stacked-on-#137 + #151 integration state; closes #82> |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code)" |
| +``` |
| + |
| +Draft if Task 8 is still gated. Merging is Paul's action — never offer |
| +or take it. |