carlos / platform Public

Merge pull request #175 from carlosframework/worktree-domain-attach

Domain attach: self-serve custom domains from Settings, CLI and deploy (#82)
Paul Campbell c42ec0c332d16724aa19182cae0b4a1a4a78b02f parent 729cffbparent 7a1ea5a
20 files changed, +4720 −82
  • apimode.go +48 −6
  • deploy.go +330 −15
  • deploy_test.go +941 −7
  • docs/superpowers/plans/2026-08-12-domain-attach.md +757 −0
  • docs/superpowers/specs/2026-08-12-domain-attach-design.md +256 −0
  • domains_cli.go +49 −5
  • domains_cli_test.go +63 −19
  • domains_e2e_test.go +253 −0
  • internal/console/apidomains.go +69 −3
  • internal/console/apidomains_test.go +112 −0
  • internal/console/appdomains.go +106 −0
  • internal/console/appdomains_test.go +213 −0
  • internal/console/appsettings.go +98 −4
  • internal/console/appsettings_test.go +106 −0
  • internal/console/domainattach.go +424 −0
  • internal/console/domainattach_test.go +711 −0
  • internal/console/server.go +12 −1
  • internal/console/templates/app_settings.html +37 −17
  • internal/console/view.go +27 −5
  • internal/provision/reconcile_test.go +108 −0
diff --git a/apimode.go b/apimode.go
index 259a25cb3..a5ede5b1b 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1547,6 +1547,13 @@ type apiInstanceRecord struct {
Channel string `json:"channel"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
+ // Kind is the record's route class ("instance" | "service" | "static";
+ // empty means instance — provision.Record's own contract). The wire has
+ // carried it since the route-records design (the list embeds the whole
+ // record); the client decodes it so deploy can tell a first-route
+ // STATIC record (#82's attach writes one) from a real instance — a
+ // static record is a static-deploy target, not an instance to adopt.
+ Kind string `json:"kind"`
// StagedVersion is the console's additive staged_version join (Task 8,
// issue #78): whichever version a box's adopt pass last reported for
// this host, via status/boxes/<label>.json. Fresh-ish for as long as
@@ -1759,7 +1766,10 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
// Wildcard/Catchall are always present (true or false, never omitted — the
// door knows both for every claim); DelegationStatus is "delegated"/
// "missing", empty when the deployment has no delegation zone configured or
-// the claim doesn't have Wildcard.
+// the claim doesn't have Wildcard. Guidance is the point-your-DNS-here text
+// (the console Settings page's own wording), present only while DNSStatus
+// is not "pointing" — additive, so a console that predates it simply never
+// sends it.
type apiDomainRecord struct {
Host string `json:"host"`
Route string `json:"route"`
@@ -1769,8 +1779,27 @@ type apiDomainRecord struct {
Wildcard bool `json:"wildcard"`
Catchall bool `json:"catchall"`
DelegationStatus string `json:"delegation_status,omitempty"`
+ Guidance string `json:"guidance,omitempty"`
}
+// domainListError wraps a domainList failure with the one extra fact its
+// own signature has no room for: the HTTP status (0 when the request never
+// got an HTTP response at all — a transport failure). deploy.go's callers
+// need to tell apart three meanings a bare error string cannot carry: "this
+// console predates the /domains door" (404), "this credential is not
+// allowed to see it" (401/403 — a real problem, must stay fatal), and "the
+// door exists but is unwell right now" (a transport failure, or any other
+// 5xx — worth a warning, not aborting an otherwise-successful deploy over).
+// Error()/Unwrap() make it a transparent drop-in wherever domainList's
+// error was already being printed or compared.
+type domainListError struct {
+ status int
+ err error
+}
+
+func (e *domainListError) Error() string { return e.err.Error() }
+func (e *domainListError) Unwrap() error { return e.err }
+
// domainAttach asks the console to claim host for app, pointed at route (a
// route hostname, or "" to let the console default it to the app's single
// instance record). wildcard/catchall are the Task 5 capability flips — nil
@@ -1785,13 +1814,24 @@ type apiDomainRecord struct {
// hostname) surfaces as "hostname unavailable" verbatim, and a 422 (bad
// wildcard/catchall request) surfaces its own message verbatim too —
// apiErrorFromBody's json-first decode doing the work either way.
-func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) {
+//
+// kind and channel are `carlos deploy`'s first-route extras (#82): kind
+// "static" tells a console holding no ready route for this app to mint the
+// host AS the app's first route (a declared static record) instead of
+// refusing to default an alias target, and channel names the channel that
+// record follows. Both empty is the plain alias attach, and empty strings
+// are omitted from the body, so a console that predates the fields never
+// sees them. route and kind together are the door's 400 — an alias target
+// and a first-route mint are two different instructions.
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool) (created bool, resolvedRoute string, err error) {
in := struct {
Host string `json:"host"`
Route string `json:"route,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ Channel string `json:"channel,omitempty"`
Wildcard *bool `json:"wildcard,omitempty"`
Catchall *bool `json:"catchall,omitempty"`
- }{host, route, wildcard, catchall}
+ }{host, route, kind, channel, wildcard, catchall}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
if err != nil {
return false, "", err
@@ -1833,14 +1873,16 @@ func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host stri
}
// domainList reads the app's hostname claims, each joined to the fleet's
-// readings sweep for status/dns_status/cert_expires_at.
+// readings sweep for status/dns_status/cert_expires_at. Failures come back
+// wrapped in *domainListError so deploy's degrade paths can read the HTTP
+// status; the wrapper is transparent (Error/Unwrap) everywhere else.
func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) {
status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains"))
if err != nil {
- return nil, err
+ return nil, &domainListError{err: err}
}
if status != http.StatusOK {
- return nil, apiErrorFromBody(status, body)
+ return nil, &domainListError{status: status, err: apiErrorFromBody(status, body)}
}
var res struct {
Domains []apiDomainRecord `json:"domains"`
diff --git a/deploy.go b/deploy.go
index 14327c5ac..aea3d8402 100644
--- a/deploy.go
+++ b/deploy.go
@@ -74,6 +74,9 @@ func runDeploy(args []string) {
if line := deployingLine(hosts, channel); line != "" {
fmt.Fprintln(stdout, line)
}
+ if staticDeploy && *hostFlag != "" {
+ attachUnclaimedHost(ctx, sess, acct, *app, *hostFlag, *kind, channel, recs)
+ }
// printBanner=false: the "using ..." line already printed above, before
// the instance list — so a listing failure still names the console —
// and runShipAPI would otherwise print it a second time.
@@ -126,6 +129,9 @@ func runDeploy(args []string) {
logsHint: fmt.Sprintf("carlos logs -app %s -account %s -stream platform", *app, acct.Seg),
}
if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second, diag); err != nil {
+ if staticDeploy && *hostFlag != "" && isConnectionClassFailure(err) {
+ failDeployDNSGuidance(ctx, sess, acct, *app, *hostFlag)
+ }
log.Fatalf("deploy: %v", err)
}
}
@@ -177,16 +183,33 @@ func deployingLine(hosts []string, channel string) string {
// "no instance follows this channel — promoted, nothing to watch" shape.
// It keeps its old behaviour verbatim: this branch must not demand -host
// from a binary app, nor tell it about static recheck windows.
+//
+// "No records" is judged KIND-AWARE, not by len(recs) == 0: a first-route
+// domain attach (#82) writes a Kind=="static" provision record, so the very
+// first `deploy -kind static -host <h>` leaves a record behind and every
+// redeploy after it would otherwise fall off the static path — losing the
+// recheck note and the DNS-guidance exit, and refusing a SECOND static
+// host with "no instance with host". Static records ARE static-deploy
+// targets; only a real instance/service record pulls a -kind static deploy
+// onto the instance path.
func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
channels func() ([]apiChannel, error)) (channel string, hosts []string, static bool, err error) {
- if len(recs) == 0 && kind == "static" {
- hosts, err := staticDeployHosts(hostFlag)
+ if kind == "static" && staticOnlyRecords(recs) {
+ hosts, err := staticDeployHosts(hostFlag, recs)
if err != nil {
return "", nil, true, err
}
if channelFlag != "" {
return channelFlag, hosts, true, nil
}
+ // A static record that names the deploy's own -host already knows
+ // its channel — the record the first deploy's attach wrote. Same
+ // convenience the instance path has always had (resolveDeployChannel
+ // resolves from the -host record); a host with no record yet — the
+ // SECOND static domain — falls through to the channels listing.
+ if ch := staticRecordChannel(recs, hostFlag); ch != "" {
+ return ch, hosts, true, nil
+ }
chans, err := channels()
if err != nil {
return "", nil, true, fmt.Errorf("list channels: %w", err)
@@ -215,19 +238,84 @@ func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
return channel, hosts, false, err
}
-// staticDeployHosts resolves the hosts a NO-INSTANCE deploy watches. There is
-// nothing to resolve them FROM — that is the whole point — so the flag is
-// required, and its absence is refused before ship and promote run rather
-// than after, when the only remaining honest report would be "promoted,
-// nothing to watch".
-func staticDeployHosts(hostFlag string) ([]string, error) {
- if hostFlag == "" {
- return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
- "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
- "Name the hostname the site answers on, so the deploy can watch it:\n" +
- " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
- }
- return []string{hostFlag}, nil
+// staticOnlyRecords reports whether recs holds nothing but Kind=="static"
+// records — the condition under which a -kind static deploy stays on the
+// static path. Zero records is trivially true (the original no-records
+// static shape, issue #112); any instance/service record pulls the deploy
+// onto the instance path exactly as before this predicate existed.
+func staticOnlyRecords(recs []apiInstanceRecord) bool {
+ for _, r := range recs {
+ if r.Kind != "static" {
+ return false
+ }
+ }
+ return true
+}
+
+// staticRecordChannel resolves a static-path deploy's channel from the
+// records themselves — the record a first-route attach wrote already knows
+// what its domain follows, so a redeploy needs no -channel and no
+// channels-listing round trip. With -host, it is that host's record's
+// channel; without, it is the single distinct non-empty channel the records
+// agree on — the no-host redeploy is watching THOSE hosts, so their own
+// channel is the only one that keeps the promote and the watch pointed at
+// the same thing (a channels listing that happened to hold one OTHER
+// channel would promote onto it while watching hosts following another).
+// "" — host unrecorded, records disagreeing, or nothing recorded — sends
+// the caller to the channels listing (and its refusal names -channel).
+func staticRecordChannel(recs []apiInstanceRecord, host string) string {
+ if host != "" {
+ for _, r := range recs {
+ if r.Host == host {
+ return r.Channel
+ }
+ }
+ return ""
+ }
+ channel := ""
+ for _, r := range recs {
+ if r.Channel == "" {
+ continue
+ }
+ if channel == "" {
+ channel = r.Channel
+ } else if r.Channel != channel {
+ return "" // disagreeing records: a human picks, via -channel
+ }
+ }
+ return channel
+}
+
+// staticDeployHosts resolves the hosts a static-path deploy watches. -host
+// names one explicitly (an unrecorded host is fine — it is the SECOND
+// static domain, about to be attached on the way through). Without the
+// flag, the app's own static records are the next best source: each one
+// names a host this app serves, written by an earlier deploy's attach. Only
+// an app with neither — no flag, no records — has nothing to resolve a host
+// from, and that absence is refused before ship and promote run rather than
+// after, when the only remaining honest report would be "promoted, nothing
+// to watch".
+func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, error) {
+ if hostFlag != "" {
+ return []string{hostFlag}, nil
+ }
+ var hosts []string
+ for _, r := range recs {
+ // Static records only, so the helper holds its own invariant
+ // rather than trusting the caller's gate: an instance record's
+ // host is adopted and watched via the instance path, never here.
+ if r.Kind != "static" {
+ continue
+ }
+ hosts = append(hosts, r.Host)
+ }
+ if len(hosts) > 0 {
+ return hosts, nil
+ }
+ return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
+ "Name the hostname the site answers on, so the deploy can watch it:\n" +
+ " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
}
// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
@@ -262,6 +350,233 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
"pass -channel to pick one: %s", strings.Join(names, ", "))
}
+// attachUnclaimedHost auto-attaches -host before shipping, when this deploy
+// is targeting a fresh static host with no route of its own yet (self-serve
+// custom domains, #82/#151): the app's first deploy to a brand-new hostname
+// should not need a separate `carlos domains attach` first. recs is the
+// SAME instance list runDeploy already fetched for deployTargets — checked
+// again here rather than assumed empty, so this stays correct even if a
+// future caller ever reaches it with records present.
+//
+// domainList is read BEFORE any attach POST, and its answer is decisive:
+// re-attaching a host this app already holds is a harmless idempotent 200
+// on the merged door (same-owner converge — #151's claim-writer contract),
+// but it is still a write request this deploy has no business sending: the
+// list already proves there is nothing to do, and only a host held by a
+// DIFFERENT owner answers 409 "hostname unavailable".
+//
+// Since the kind-aware static gate, this runs on REDEPLOYS too (records
+// present), not just the first deploy — so a 401/403 on the GET aborting
+// the whole deploy is a question worth answering: the domains door sits
+// behind the SAME instanceGate as the instanceList runDeploy has already
+// passed, so a token that got this far can list domains too, and a 401/403
+// here means the credential genuinely lost authority mid-deploy — worth
+// stopping for, not degrading past.
+func attachUnclaimedHost(ctx context.Context, sess *apiSession, acct apiAcct, app, host, kind, channel string, recs []apiInstanceRecord) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ switch {
+ case domainsDoorMissing(err):
+ // A console that predates the /domains door (Task 8 wires the
+ // real one; not every box has adopted it yet). Silence is only
+ // honest when nothing was going to be attached anyway — an
+ // instance already answering this host means so, regardless of
+ // the door. A genuinely unclaimed host (no instance, and now no
+ // way to check domainList either) gets a note instead of a
+ // silent skip: without the door this deploy cannot attach it,
+ // and a member watching "deploying https://<host>" scroll by
+ // with no explanation would have no idea DNS was never even
+ // checked.
+ if !hostHasInstance(host, recs) {
+ mutedf(stdout, "this console has no domains door yet — attach %s to %s in the console before "+
+ "DNS can be checked; continuing without auto-attach\n", host, app)
+ }
+ return
+ case domainListDegradable(err):
+ // A transport failure or 5xx: the door exists but is unwell
+ // right now, not a reason to abort a deploy that would
+ // otherwise succeed. 401/403 do NOT take this branch — those
+ // mean the credential itself lacks authority, which is worth
+ // stopping for.
+ mutedf(stdout, "could not check custom domains for %s (%v) — continuing without auto-attach\n", host, err)
+ return
+ default:
+ log.Fatalf("deploy: list domains: %v", err)
+ }
+ }
+ if !domainNeedsAttach(host, recs, domains) {
+ return
+ }
+ // kind travels only when it is "static" — the one shape a first-route
+ // attach accepts (attachDomain, internal/console/domainattach.go); empty
+ // leaves the field out of the wire body entirely rather than sending a
+ // value the server would refuse. attachUnclaimedHost is only ever
+ // reached with kind == "static" (runDeploy gates the call on
+ // staticDeploy, which deployTargets only sets true for that kind), so
+ // this collapses to "static" in practice — kept as one variable rather
+ // than a second hardcoded literal in the printed line below, so the two
+ // can never drift apart and say different things.
+ attachKind := ""
+ if kind == "static" {
+ attachKind = kind
+ }
+ // The door, not this call's own request, decides alias-vs-first-route
+ // (spec §4's "one deliberate asymmetry"): resolvedRoute is the host
+ // itself on a genuine first-route attach, or a DIFFERENT route when the
+ // app already had one ready and the door aliased onto it instead —
+ // which can happen even though attachKind asked for "static", since a
+ // ready route always wins over minting a second one (§1's yes/no
+ // split lives door-side). Printing attachKind/channel unconditionally
+ // here would claim a first route was minted when the door actually
+ // aliased, so the two cases get two different lines, and the alias one
+ // never names a channel — an alias serves the primary's channel, not
+ // one of its own.
+ _, resolvedRoute, err := sess.domainAttach(ctx, acct, app, host, "", attachKind, channel, nil, nil)
+ if err != nil {
+ log.Fatalf("deploy: attach %s: %v", host, err)
+ }
+ if resolvedRoute == host {
+ fmt.Fprintf(stdout, "%s %s (first route: %s, channel %s)\n", colorFor(stdout).ok("attached"), host, attachKind, channel)
+ } else {
+ fmt.Fprintf(stdout, "%s %s (aliases %s)\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
+ }
+}
+
+// hostHasInstance reports whether host is already answered by one of the
+// app's instance records — the one fact attachUnclaimedHost can still trust
+// even when domainList itself cannot be read (the door is missing, or
+// unwell): an instance already serving this host never needed an attach in
+// the first place, regardless of what domainList would have said.
+func hostHasInstance(host string, recs []apiInstanceRecord) bool {
+ for _, r := range recs {
+ if r.Host == host {
+ return true
+ }
+ }
+ return false
+}
+
+// domainNeedsAttach reports whether host is a fresh target this deploy must
+// attach before shipping: present in NEITHER the app's instance records (an
+// existing instance already answers it) NOR its already-claimed domains (an
+// earlier deploy's attach, or a manual `carlos domains attach`). Either
+// match means false. The merged door would answer a same-owner re-attach
+// with an idempotent 200 (not the 409 an EARLIER contract draft promised),
+// so skipping here is POST-avoidance, not failure-avoidance: the list
+// already said there is nothing to do, and a repeated `carlos deploy` should
+// send zero attach writes, not one no-op per run.
+func domainNeedsAttach(host string, recs []apiInstanceRecord, domains []apiDomainRecord) bool {
+ if hostHasInstance(host, recs) {
+ return false
+ }
+ for _, d := range domains {
+ if d.Host == host {
+ return false
+ }
+ }
+ return true
+}
+
+// domainsDoorMissing reports whether err is a domainList 404 — the shape
+// apiErrorFromBody gives ANY 404 whose body carries no JSON "error" field,
+// which is exactly what an unregistered route (a console that predates the
+// /domains door, Task 8) answers. By the time attachUnclaimedHost calls
+// domainList, runDeploy has already listed this exact account+app's
+// instances successfully — so "unknown account/app" is already ruled out,
+// and a domainList 404 here can only mean the door itself does not exist
+// yet.
+func domainsDoorMissing(err error) bool {
+ var dle *domainListError
+ return errors.As(err, &dle) && dle.status == http.StatusNotFound
+}
+
+// domainListDegradable reports whether a domainList failure should stand
+// auto-attach down with a warning rather than aborting the whole deploy: a
+// transport-level failure (status 0 — the request never got an HTTP
+// response at all) or any 5xx. 401/403 are deliberately excluded — those
+// mean the credential itself lacks authority to see this app's domains,
+// which is worth stopping the deploy for, not silently continuing past. An
+// error that isn't even a *domainListError (never expected from
+// domainList, but defensive) is treated as NOT degradable, so an unknown
+// shape stays fatal rather than silently swallowed.
+func domainListDegradable(err error) bool {
+ var dle *domainListError
+ if !errors.As(err, &dle) {
+ return false
+ }
+ return dle.status == 0 || (dle.status >= 500 && dle.status < 600)
+}
+
+// isConnectionClassFailure reports whether a watchVersion timeout's error
+// text names a target that never even answered — the "unreachable: ..."
+// diag watchVersion emits when http.Client.Get itself failed (DNS
+// resolution, connection refused, TLS handshake: every shape a domain whose
+// DNS is not yet pointing here produces), as opposed to a reachable origin
+// that simply isn't serving the right version yet. watchVersion reports one
+// combined string, not a structured per-host reason, so a substring check is
+// what there is to go on here — precise enough because deploy only ever
+// reaches this with -host naming the single target that timed out.
+func isConnectionClassFailure(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "unreachable:")
+}
+
+// dnsGuidanceMessage decides the honest, spec'd exit text for a
+// connection-class watch timeout, from the app's domainList entry for
+// host — or reports ok=false when neither arm applies, telling the caller
+// to fall back to the plain watchVersion timeout report instead of
+// guessing. Two arms, keyed off domainList's own DNS verdict for the host:
+//
+// - DNSStatus == "pointing": DNS is right, so a connection-class failure
+// means something else is missing — the certificate, almost always,
+// since first-route attach mints the record before ACME has had a chance
+// to run. Says so, and to re-run SHORTLY (this should resolve itself).
+// - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance:
+// the DNS-not-pointing report, the console's own Create: text verbatim.
+//
+// A host absent from domains, or present with DNSStatus != "pointing" and
+// no guidance yet (checked, but the console has nothing to say), is
+// deliberately ok=false — a guessed-at guidance line this call cannot back
+// up would be worse than the generic timeout report already in hand.
+func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) {
+ for _, d := range domains {
+ if d.Host != host {
+ continue
+ }
+ switch {
+ case d.DNSStatus == "pointing":
+ return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
+ "Watch with 'carlos domains list -app %s -account %s'; re-run this deploy shortly — the shipped release is already promoted.",
+ host, app, acct.Seg), true
+ case d.DNSStatus != "pointing" && d.Guidance != "":
+ return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+
+ "Watch with 'carlos domains list -app %s -account %s'; re-run this deploy any time — the shipped release is already promoted.",
+ host, d.Guidance, app, acct.Seg), true
+ default:
+ return "", false
+ }
+ }
+ return "", false
+}
+
+// failDeployDNSGuidance turns a connection-class watch timeout into the
+// honest report the spec asks for: the deploy itself already succeeded
+// (shipped and promoted), so the exit text says what's still missing and
+// how to fix it, never "deploy failed". It still exits nonzero (log.Fatal,
+// same as every other refusal in this file): the URL does not yet serve the
+// release, and this must never read as success. Falls through silently
+// (letting the caller's own generic watch-error message stand) when
+// domainList itself cannot be read, or dnsGuidanceMessage finds neither arm
+// applies.
+func failDeployDNSGuidance(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ return
+ }
+ if msg, ok := dnsGuidanceMessage(domains, host, app, acct); ok {
+ log.Fatal(msg)
+ }
+}
+
// requireDeployAPISession resolves signed-in mode for deploy, refusing
// direct-bucket mode with deploy's OWN message — not instances_cli.go's
// requireAPISession, whose hardcoded text ("carlos instances %s works
diff --git a/deploy_test.go b/deploy_test.go
index 6ea48fdab..992aedc0c 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -2,9 +2,15 @@ package main
import (
"bytes"
+ "context"
+ "encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -68,18 +74,25 @@ func TestFilterChannelHosts(t *testing.T) {
}
}
-// TestStaticDeployHosts pins the no-instance refusal (issue #112): a static
-// site has no instance records to resolve a host from, so -host is required —
-// and required BEFORE ship and promote run, since afterwards the only honest
-// report left is "promoted, nothing to watch".
+// TestStaticDeployHosts pins the host resolution (issue #112, extended by
+// #82's kind-aware gate): -host names one explicitly; without it the app's
+// own static records are the watch targets (each names a host an earlier
+// attach wrote); with neither, the deploy is refused — BEFORE ship and
+// promote run, since afterwards the only honest report left is "promoted,
+// nothing to watch".
func TestStaticDeployHosts(t *testing.T) {
- hosts, err := staticDeployHosts("carlosframework.com")
+ hosts, err := staticDeployHosts("carlosframework.com", nil)
if err != nil || len(hosts) != 1 || hosts[0] != "carlosframework.com" {
t.Fatalf("with -host: %v %v", hosts, err)
}
- _, err = staticDeployHosts("")
+ recs := []apiInstanceRecord{{Host: "a.example.test", Kind: "static"}, {Host: "b.example.test", Kind: "static"}}
+ hosts, err = staticDeployHosts("", recs)
+ if err != nil || len(hosts) != 2 || hosts[0] != "a.example.test" || hosts[1] != "b.example.test" {
+ t.Fatalf("no -host, records present: %v %v, want both record hosts watched", hosts, err)
+ }
+ _, err = staticDeployHosts("", nil)
if err == nil {
- t.Fatal("an app with no instances and no -host must be refused")
+ t.Fatal("an app with no records and no -host must be refused")
}
for _, want := range []string{
"no instance records",
@@ -467,3 +480,924 @@ func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) {
t.Fatalf("empty reason printed a blank line: %v", err)
}
}
+
+// ---- domain attach (Task 6, #82/#151) --------------------------------
+//
+// The server half of the wire contract below does not exist yet (Task 8
+// builds it, gated on #151 landing); these tests pin what THIS client sends
+// and expects against a hand-rolled fake console, exactly like
+// TestAPISessionRestartWireShape (restart_test.go) does for a door that
+// already shipped.
+
+// TestAPISessionDomainListAndAttachWireShape pins the wire shape the
+// console door (apidomains.go, #151's contract) answers: GET .../domains
+// for the list (rows carry dns_status/guidance), POST .../domains with a
+// {"host","kind","channel"} body for attach, decoding a 200 into
+// (created, resolvedRoute).
+func TestAPISessionDomainListAndAttachWireShape(t *testing.T) {
+ var gotListPath string
+ var gotAttachPath, gotAttachMethod, gotAttachBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/domains"):
+ gotListPath = r.URL.RequestURI()
+ json.NewEncoder(w).Encode(map[string]any{
+ "domains": []map[string]string{{"host": "existing.example.test", "route": "static", "status": "live", "dns_status": "pointing"}},
+ })
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"):
+ gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method
+ b, _ := io.ReadAll(r.Body)
+ gotAttachBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"host": "fresh.example.test", "route": "fresh.example.test", "created": true})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer ts.Close()
+
+ sess := newTestSession(ts.URL)
+ domains, err := sess.domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
+ if err != nil {
+ t.Fatalf("domainList: %v", err)
+ }
+ if gotListPath != "/api/cli/apps/ops/sitely/domains" {
+ t.Fatalf("list path = %s", gotListPath)
+ }
+ if len(domains) != 1 || domains[0].Host != "existing.example.test" || domains[0].DNSStatus != "pointing" {
+ t.Fatalf("domains = %+v", domains)
+ }
+
+ created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely",
+ "fresh.example.test", "", "static", "canary/live", nil, nil)
+ if err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if gotAttachMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotAttachMethod)
+ }
+ if gotAttachPath != "/api/cli/apps/ops/sitely/domains" {
+ t.Fatalf("path = %s", gotAttachPath)
+ }
+ if !strings.Contains(gotAttachBody, `"host":"fresh.example.test"`) ||
+ !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) {
+ t.Fatalf("body = %s, want host+kind+channel", gotAttachBody)
+ }
+ if strings.Contains(gotAttachBody, `"route"`) {
+ t.Fatalf("body = %s, empty route must be omitted", gotAttachBody)
+ }
+ if !created || resolvedRoute != "fresh.example.test" {
+ t.Fatalf("attach result = created %v route %q", created, resolvedRoute)
+ }
+}
+
+// TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal
+// shapes the wire contract names: 409 "hostname unavailable" (a host
+// already held — the binding note deploy's own gate exists to avoid ever
+// triggering) and a 422 policy refusal. Both must reach the caller in the
+// console's own words, via apiErrorFromBody, never rewritten.
+func TestAPISessionDomainAttachRefusalSurfacesVerbatim(t *testing.T) {
+ for _, tc := range []struct {
+ status int
+ msg string
+ }{
+ {http.StatusConflict, "hostname unavailable"},
+ {http.StatusUnprocessableEntity, "not a usable hostname"},
+ } {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ json.NewEncoder(w).Encode(map[string]string{"error": tc.msg})
+ }))
+ _, _, err := newTestSession(ts.URL).domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely",
+ "x.test", "", "static", "canary/live", nil, nil)
+ ts.Close()
+ if err == nil || !strings.Contains(err.Error(), tc.msg) {
+ t.Fatalf("status %d: err = %v, want %q verbatim", tc.status, err, tc.msg)
+ }
+ }
+}
+
+// TestDomainNeedsAttach pins the gate itself, independent of any HTTP call:
+// a host answered by an existing instance record, or already named in
+// domainList, must never be attached again.
+func TestDomainNeedsAttach(t *testing.T) {
+ recs := []apiInstanceRecord{{Host: "has-instance.test"}}
+ domains := []apiDomainRecord{{Host: "already-claimed.test"}}
+ cases := []struct {
+ host string
+ want bool
+ }{
+ {"fresh.test", true},
+ {"has-instance.test", false},
+ {"already-claimed.test", false},
+ }
+ for _, c := range cases {
+ if got := domainNeedsAttach(c.host, recs, domains); got != c.want {
+ t.Errorf("domainNeedsAttach(%q) = %v, want %v", c.host, got, c.want)
+ }
+ }
+}
+
+// deployFakeConsole is a minimal console double for `carlos deploy`'s own
+// tests: only the doors deploy itself calls (instances, domains, releases,
+// promote) — not internal/console's real handlers, since the CLI-facing
+// domains door does not exist on this branch yet (Task 8 builds it,
+// integrating against #151's real one). calls records every hit, in order,
+// so a test can assert attach happened before ship rather than merely that
+// it happened at all.
+type deployFakeConsole struct {
+ ts *httptest.Server
+
+ mu sync.Mutex
+ calls []string // "METHOD /path", in hit order
+ domains []map[string]string
+ instances []apiInstanceRecord // nil = the app has no records
+ shipVersion string
+ domainsListStatus int // 0 = normal 200 JSON list; else this status with a plain (non-JSON) body — simulates an old console (404) or an unwell one (5xx)
+ attachStatus int // 0 = normal 201; else this status with an {"error": attachErrMsg} body
+ attachErrMsg string
+ // attachRouteOverride, when non-empty, is the "route" the POST /domains
+ // response names instead of echoing the attached host — simulating the
+ // door aliasing this host onto an existing route rather than minting it
+ // as a first route, the review#1 case attachUnclaimedHost must read off
+ // the door's own answer rather than assume.
+ attachRouteOverride string
+}
+
+func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
+ t.Helper()
+ fc := &deployFakeConsole{shipVersion: shipVersion}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ recs := fc.instances
+ fc.mu.Unlock()
+ if recs == nil {
+ recs = []apiInstanceRecord{}
+ }
+ json.NewEncoder(w).Encode(map[string]any{"instances": recs})
+ })
+ mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ domains, status := fc.domains, fc.domainsListStatus
+ fc.mu.Unlock()
+ if status != 0 {
+ // A plain body with no JSON "error" field, on purpose: this is
+ // what an unregistered route (old console) OR a bare 5xx (an
+ // unwell one) both look like on the wire — neither carries a
+ // server diagnosis, which is exactly the shape apiErrorFromBody
+ // treats as "not a real diagnosis, just a status".
+ w.WriteHeader(status)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"domains": domains})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ status, errMsg, routeOverride := fc.attachStatus, fc.attachErrMsg, fc.attachRouteOverride
+ fc.mu.Unlock()
+ if status != 0 {
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": errMsg})
+ return
+ }
+ var in struct {
+ Host string `json:"host"`
+ }
+ json.NewDecoder(r.Body).Decode(&in)
+ route := in.Host
+ if routeOverride != "" {
+ route = routeOverride
+ }
+ json.NewEncoder(w).Encode(map[string]any{"host": in.Host, "route": route, "created": true})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ fc.mu.Lock()
+ ver := fc.shipVersion
+ fc.mu.Unlock()
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{"version": ver})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
+ fc.record(r)
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false})
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// setDomains is the mutex-guarded way to change what the fake's GET
+// .../domains answers once the server is already live (t.Setenv-style
+// direct field writes are only safe before the httptest.Server starts
+// serving requests).
+func (fc *deployFakeConsole) setDomains(domains []map[string]string) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.domains = domains
+}
+
+func (fc *deployFakeConsole) setInstances(recs []apiInstanceRecord) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.instances = recs
+}
+
+func (fc *deployFakeConsole) setDomainsListStatus(status int) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.domainsListStatus = status
+}
+
+func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.attachStatus, fc.attachErrMsg = status, msg
+}
+
+func (fc *deployFakeConsole) setAttachRouteOverride(route string) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.attachRouteOverride = route
+}
+
+func (fc *deployFakeConsole) record(r *http.Request) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.calls = append(fc.calls, r.Method+" "+r.URL.Path)
+}
+
+// callIndex returns the index of the first recorded call containing sub, or
+// -1 — used both to assert a call happened and, via two calls, that one
+// happened before another.
+func (fc *deployFakeConsole) callIndex(sub string) int {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ for i, c := range fc.calls {
+ if strings.Contains(c, sub) {
+ return i
+ }
+ }
+ return -1
+}
+
+// TestDeployAutoAttachesUnclaimedStaticHost pins the auto-attach behaviour
+// end to end: a -host deploy against a fresh static target (no instance
+// record, no domainList entry) attaches the host BEFORE shipping and says
+// so; a repeated deploy against the SAME host, once domainList already
+// names it, must not re-POST attach at all — the binding note's 409 "host
+// unavailable" is what a re-attach gets, so this is what keeps a repeated
+// `carlos deploy` idempotent rather than erroring on its own prior success.
+func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ app = "sitely"
+ host = "sitely.example.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0001")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // The watch leg's own edge: always answers whatever version this run
+ // expects, so the test is about the attach step, not about racing a
+ // slow-to-converge edge.
+ var edgeVersion atomic.Value
+ edgeVersion.Store("sha0001")
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", edgeVersion.Load().(string))
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains")
+ if attachIdx < 0 {
+ t.Fatalf("attach was never POSTed: %v", fc.calls)
+ }
+ if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx {
+ t.Fatalf("ship (index %d) happened before attach (index %d): %v", shipIdx, attachIdx, fc.calls)
+ }
+ if want := "attached " + host + " (first route: static, channel " + channel + ")"; !strings.Contains(out, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, out)
+ }
+
+ // --- re-run: domainList now names the host, as the console would once
+ // the first attach landed. The second deploy must NOT re-POST attach. --
+ fc.mu.Lock()
+ fc.domains = []map[string]string{{"host": host, "route": "static", "dns_status": "pointing"}}
+ fc.calls = nil
+ fc.shipVersion = "sha0002"
+ fc.mu.Unlock()
+ edgeVersion.Store("sha0002")
+
+ buf.Reset()
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out = buf.String()
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
+ t.Fatalf("a host already in domainList must not trigger an attach POST: %v", fc.calls)
+ }
+ if strings.Contains(out, "attached "+host) {
+ t.Fatalf("stdout claims an attach that must not have happened:\n%s", out)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
+ t.Fatalf("ship never happened on the re-run: %v", fc.calls)
+ }
+
+ checkNoRegistry()
+}
+
+// TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute is final-review
+// finding #1: attachUnclaimedHost must print off the door's OWN answer, not
+// assume "first route" just because that's what it asked for. A console
+// that resolves this deploy's -host to a DIFFERENT route than the host
+// itself — the alias case, e.g. because the app already had a ready route
+// by the time the door decided (spec §1's yes/no split lives door-side,
+// not in what this call sent) — must get the "aliases <route>" line with
+// no channel named; a channel belongs to the primary route, and an alias
+// serves whatever channel that primary already follows.
+func TestDeployAutoAttachPrintsAliasWordingOnDifferingRoute(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ app = "sitely"
+ host = "alias.example.test"
+ primary = "sitely.canary.oncarlos.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0001")
+ fc.setAttachRouteOverride(primary)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "sha0001")
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ if want := "attached " + host + " (aliases " + primary + ")"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want the alias wording naming the door's resolved route %q", out, primary)
+ }
+ if strings.Contains(out, "first route") || strings.Contains(out, "channel "+channel) {
+ t.Fatalf("stdout wrongly named a channel for an alias attach (a channel belongs to the primary):\n%s", out)
+ }
+
+ checkNoRegistry()
+}
+
+// TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for
+// a connection-class watch timeout: the deploy itself already succeeded
+// (shipped, promoted) but the host never answered at all — the shape a
+// domain that has not been pointed here yet produces — so the exit text
+// must say DNS specifically, carry the console's own guidance, and still
+// exit nonzero: "deploy failed" would be a lie (the release IS live), and a
+// zero exit would be a worse one (the URL does not serve it).
+//
+// Run as a subprocess (buildCarlos + exec.Command, carlosTestEnv's own
+// pattern): log.Fatal exits the process, which would take the rest of this
+// package's tests down with it if this ran in-process. The host is a bare
+// loopback address with nothing listening on :443 — an instant, hermetic
+// "unreachable" that needs no real DNS or outbound network access, and
+// exercises the exact diag class (watchVersion's "unreachable: ...", from
+// http.Client.Get itself failing) a not-yet-pointing domain produces. The
+// fake console already lists the host as claimed (domainList has an entry
+// with guidance), so auto-attach is a no-op here — this is the SECOND deploy
+// to an already-attached host shape, not the first.
+func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) {
+ bin := buildCarlos(t)
+ const (
+ app = "sitely"
+ host = "127.0.0.1"
+ channel = "canary/site"
+ version = "sha0001"
+ guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test"
+ )
+ fc := newDeployFakeConsole(t, version)
+ fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}})
+
+ credPath := filepath.Join(t.TempDir(), "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", credPath)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL,
+ "-timeout", "100ms", siteDir)
+ // carlosTestEnv is the repo rule for a subprocess that shells out to the
+ // carlos binary — it masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE to its
+ // own scratch space; the explicit CARLOS_CREDENTIALS below wins over
+ // that default because os/exec keeps only the LAST value for a
+ // duplicate key, landing the subprocess on the exact credential file
+ // this test just wrote to (the same path this process's own
+ // CARLOS_CREDENTIALS was just pointed at, via t.Setenv above).
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
+ cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule (carlosTestEnv's own reasoning)
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("DNS-not-pointing deploy exited 0:\n%s", out)
+ }
+ want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide +
+ ". Watch with 'carlos domains list -app " + app + " -account ops'; re-run this deploy any time — the shipped release is already promoted."
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("exit text = %q, want it to contain %q", out, want)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
+ t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls)
+ }
+}
+
+// TestDeployDNSGuidanceMessageBothArms pins review fix #1: a host-match alone is
+// not enough to say "DNS is not pointing" — DNS == "pointing" with a
+// connection-class failure means something else (the certificate, almost
+// always) is what's still missing, and printing an empty "Create: ." would
+// be a lie by omission. Both arms are driven through a real domainList call
+// against a fake console (not hand-built apiDomainRecord values), so the test
+// also proves the DNS/Guidance fields survive the wire round trip into the
+// message. A third case pins the fall-through: no DNS verdict and no
+// guidance yet must defer to the generic timeout report rather than guess.
+func TestDeployDNSGuidanceMessageBothArms(t *testing.T) {
+ fakeDomainsServer := func(t *testing.T, domain map[string]string) []apiDomainRecord {
+ t.Helper()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{domain}})
+ }))
+ defer ts.Close()
+ domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return domains
+ }
+
+ // Arm 1: DNS is pointing, so a connection-class failure means the
+ // certificate isn't issued yet — never "DNS is not pointing" (that
+ // would be false), and never an empty "Create: ." (pointing carries no
+ // guidance at all).
+ domains := fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "pointing"})
+ msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"})
+ if !ok {
+ t.Fatal("pointing arm: want ok")
+ }
+ for _, want := range []string{
+ "DNS for x.test is pointing here, but the certificate is not issued yet",
+ "re-run this deploy shortly",
+ "carlos domains list -app sitely -account ops",
+ } {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("pointing-arm message = %q, missing %q", msg, want)
+ }
+ }
+ if strings.Contains(msg, "Create:") || strings.Contains(msg, "not pointing") {
+ t.Fatalf("pointing arm must not claim DNS is not pointing or print an empty guidance line: %q", msg)
+ }
+
+ // Arm 2: DNS is elsewhere, with real guidance — the original report.
+ domains = fakeDomainsServer(t, map[string]string{"host": "x.test", "dns_status": "elsewhere", "guidance": "create a CNAME"})
+ msg, ok = dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"})
+ if !ok {
+ t.Fatal("not-pointing arm: want ok")
+ }
+ for _, want := range []string{
+ "DNS for x.test is not pointing at this deployment yet. Create: create a CNAME",
+ "re-run this deploy any time",
+ "carlos domains list -app sitely -account ops",
+ } {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("not-pointing-arm message = %q, missing %q", msg, want)
+ }
+ }
+
+ // Fall-through: DNS never checked at all (no dns field, no guidance) —
+ // neither arm applies, so the caller must fall back to the generic
+ // watchVersion timeout report rather than print a guessed-at message.
+ domains = fakeDomainsServer(t, map[string]string{"host": "x.test"})
+ if _, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok {
+ t.Fatal("no DNS verdict and no guidance yet must fall through, not print a message")
+ }
+
+ // A host absent from domains entirely must also fall through.
+ if _, ok := dnsGuidanceMessage(domains, "not-listed.test", "sitely", apiAcct{Seg: "ops"}); ok {
+ t.Fatal("a host absent from domainList must fall through, not print a message")
+ }
+}
+
+// TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review
+// fix #2: silence is only right when nothing needed attaching anyway. A
+// console that predates the /domains door 404s the list; when the target
+// host is genuinely unclaimed (absent from the instance list — always true
+// on the no-instance static path), the member is told the door is missing
+// and the host needs a console-side attach, and the deploy still PROCEEDS
+// (ships and promotes) rather than fataling over a feature this console
+// simply cannot offer yet.
+func TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ const (
+ app = "sitely"
+ host = "sitely.example.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0001")
+ fc.setDomainsListStatus(http.StatusNotFound) // old console: the route itself is unregistered
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "sha0001")
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ if !strings.Contains(out, "no domains door") || !strings.Contains(out, host) {
+ t.Fatalf("stdout missing the no-domains-door note naming %s:\n%s", host, out)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
+ t.Fatalf("no domains door — attach must never have been attempted: %v", fc.calls)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
+ t.Fatalf("deploy must still proceed to ship: %v", fc.calls)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("deploy must still complete and confirm the URL:\n%s", out)
+ }
+}
+
+// TestDeployAutoAttachDegradesOnDomainsListServerError pins review fix #3:
+// a transport-level or 5xx domainList failure must warn and continue, not
+// abort a deploy that would otherwise succeed — the door exists but is
+// unwell, which is not the same problem as an unauthorized credential.
+func TestDeployAutoAttachDegradesOnDomainsListServerError(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ const (
+ app = "sitely"
+ host = "sitely.example.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0001")
+ fc.setDomainsListStatus(http.StatusInternalServerError)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "sha0001")
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ if !strings.Contains(out, "could not check custom domains") || !strings.Contains(out, host) {
+ t.Fatalf("stdout missing the degrade warning naming %s:\n%s", host, out)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
+ t.Fatalf("a domainList 500 must not be treated as an unclaimed host — attach must never be attempted: %v", fc.calls)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
+ t.Fatalf("deploy must still proceed to ship despite the domainList 500: %v", fc.calls)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("deploy must still complete and confirm the URL:\n%s", out)
+ }
+}
+
+// TestDeployAttachRefusalStopsBeforeShip pins the no-partial-ship
+// invariant: when the console refuses the attach POST (422 — a policy
+// refusal, e.g. an unshaped host), the deploy must exit nonzero WITHOUT
+// ever reaching ship — a half-done deploy (bytes shipped, but the route
+// never attached) would be worse than stopping before anything is written.
+// Run as a subprocess: log.Fatal exits the process.
+func TestDeployAttachRefusalStopsBeforeShip(t *testing.T) {
+ bin := buildCarlos(t)
+ const (
+ app = "sitely"
+ host = "sitely.example.test"
+ channel = "canary/site"
+ version = "sha0001"
+ )
+ fc := newDeployFakeConsole(t, version)
+ fc.setAttachRefusal(http.StatusUnprocessableEntity, "not a usable hostname")
+
+ credPath := filepath.Join(t.TempDir(), "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", credPath)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL,
+ "-timeout", "5s", siteDir)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("deploy with a refused attach exited 0:\n%s", out)
+ }
+ if !strings.Contains(string(out), "not a usable hostname") {
+ t.Fatalf("exit text missing the console's refusal verbatim:\n%s", out)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx >= 0 {
+ t.Fatalf("ship must never happen after a refused attach: %v", fc.calls)
+ }
+}
+
+// TestDeployTargetsStaticKindAware pins the kind-aware static gate (task-8
+// review, important #1): a Kind=="static" provision record — the one a
+// first-route domain attach writes — is a static-deploy TARGET, not an
+// instance that pulls the deploy off the static path. Before this gate,
+// the record the FIRST `deploy -kind static -host <h>` left behind made
+// every redeploy lose the recheck note and the DNS-guidance exit, and made
+// a second static -host fatal with "no instance with host".
+func TestDeployTargetsStaticKindAware(t *testing.T) {
+ staticRec := apiInstanceRecord{Host: "site.example.test", Channel: "canary/site", Kind: "static"}
+ channelsCalled := false
+ channels := func() ([]apiChannel, error) {
+ channelsCalled = true
+ return []apiChannel{{Channel: "canary/other"}}, nil
+ }
+
+ // Redeploy: -host names the record — static path, channel straight from
+ // the record, no channels round trip.
+ ch, hosts, static, err := deployTargets([]apiInstanceRecord{staticRec}, "static", "", "site.example.test", channels)
+ if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" {
+ t.Fatalf("redeploy = ch %q hosts %v static %v err %v, want the record's own channel on the static path", ch, hosts, static, err)
+ }
+ if channelsCalled {
+ t.Fatal("redeploy consulted the channels listing — the record already knows its channel")
+ }
+
+ // Second static host: unrecorded -host stays on the static path (it is
+ // about to be attached), channel resolved from the app's channels.
+ ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "second.example.test", channels)
+ if err != nil || !static || len(hosts) != 1 || hosts[0] != "second.example.test" || ch != "canary/other" {
+ t.Fatalf("second host = ch %q hosts %v static %v err %v, want the static path with the listed channel", ch, hosts, static, err)
+ }
+ if !channelsCalled {
+ t.Fatal("second host never consulted the channels listing")
+ }
+
+ // No -host at all — the obvious redeploy invocation once a domain is
+ // attached: the records are the watch targets AND their agreed channel
+ // is the promote target, with no -channel and NO channels round trip. A
+ // listing that happened to hold one other channel must not win here —
+ // promoting onto it while watching hosts that follow the record's
+ // channel would verify the wrong thing (re-review, important #1).
+ channelsCalled = false
+ ch, hosts, static, err = deployTargets([]apiInstanceRecord{staticRec}, "static", "", "", channels)
+ if err != nil || !static || len(hosts) != 1 || hosts[0] != "site.example.test" || ch != "canary/site" {
+ t.Fatalf("no -host = ch %q hosts %v static %v err %v, want the record's host watched on the record's channel", ch, hosts, static, err)
+ }
+ if channelsCalled {
+ t.Fatal("no -host redeploy consulted the channels listing — the records already agree on a channel")
+ }
+
+ // Records DISAGREEING on a channel resolve nothing: fall through to the
+ // channels listing (whose single entry answers, and whose plural case
+ // refuses naming -channel) rather than guessing between the records.
+ twoChannels := []apiInstanceRecord{
+ {Host: "site.example.test", Channel: "canary/site", Kind: "static"},
+ {Host: "blog.example.test", Channel: "canary/blog", Kind: "static"},
+ }
+ ch, hosts, static, err = deployTargets(twoChannels, "static", "", "", channels)
+ if err != nil || !static || len(hosts) != 2 || ch != "canary/other" {
+ t.Fatalf("disagreeing records = ch %q hosts %v static %v err %v, want the listing's channel via fallthrough", ch, hosts, static, err)
+ }
+ if !channelsCalled {
+ t.Fatal("disagreeing records never fell through to the channels listing")
+ }
+
+ // A real instance record still pulls a -kind static deploy onto the
+ // instance path, exactly as before the gate existed.
+ instRec := apiInstanceRecord{Host: "app.example.test", Channel: "stable"}
+ ch, hosts, static, err = deployTargets([]apiInstanceRecord{instRec}, "static", "", "app.example.test", channels)
+ if err != nil || static || ch != "stable" || len(hosts) != 1 {
+ t.Fatalf("instance record = ch %q hosts %v static %v err %v, want the instance path", ch, hosts, static, err)
+ }
+}
+
+// TestDeployStaticRedeployWithRecordGetsDNSGuidance is review important #1's
+// regression case (a): the SECOND `deploy -kind static -host <h>` — after
+// the first one's attach left a static provision record — must still take
+// the static path and still exit through the DNS-guidance report while the
+// domain is unpointed, not through a bare watch timeout. No -channel either:
+// the record resolves it. Subprocess, because the exit path is log.Fatal.
+func TestDeployStaticRedeployWithRecordGetsDNSGuidance(t *testing.T) {
+ bin := buildCarlos(t)
+ const (
+ app = "sitely"
+ host = "127.0.0.1" // nothing listens on :443 — instant, hermetic "unreachable"
+ channel = "canary/site"
+ version = "sha0002"
+ guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test"
+ )
+ fc := newDeployFakeConsole(t, version)
+ fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel, Kind: "static"}})
+ fc.setDomains([]map[string]string{{"host": host, "route": "", "dns_status": "elsewhere", "guidance": guide}})
+
+ credPath := filepath.Join(t.TempDir(), "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", credPath)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // No -channel: the static record must resolve it (kind-aware gate).
+ cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", version, "-host", host, "-console", fc.ts.URL, "-timeout", "100ms", siteDir)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+credPath, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
+ cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("redeploy to an unpointed domain exited 0:\n%s", out)
+ }
+ want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide
+ if !strings.Contains(string(out), want) {
+ t.Fatalf("exit text = %q, want the DNS-guidance report %q", out, want)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains"); idx >= 0 {
+ t.Fatalf("host already recorded and claimed — attach must not be re-POSTed: %v", fc.calls)
+ }
+ if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 {
+ t.Fatalf("redeploy never shipped: %v", fc.calls)
+ }
+}
+
+// TestDeploySecondStaticHostAttaches is review important #1's case (b),
+// fixed outright by the kind-aware gate: an app whose only record is the
+// first domain's static record accepts a deploy to a SECOND fresh host —
+// static path, attach POSTed before ship — instead of the pre-fix fatal
+// `no instance with host "<h2>"`.
+func TestDeploySecondStaticHostAttaches(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ app = "sitely"
+ first = "sitely.example.test"
+ second = "more.example.test"
+ channel = "canary/site"
+ )
+ fc := newDeployFakeConsole(t, "sha0002")
+ fc.setInstances([]apiInstanceRecord{{Host: first, Channel: channel, Kind: "static"}})
+ fc.setDomains([]map[string]string{{"host": first, "route": "", "dns_status": "pointing"}})
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "sha0002")
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
+ "-channel", channel, "-host", second, "-timeout", "5s", siteDir})
+ out := buf.String()
+
+ attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains")
+ if attachIdx < 0 {
+ t.Fatalf("second static host was never attached: %v", fc.calls)
+ }
+ if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx {
+ t.Fatalf("ship (index %d) ran before attach (index %d): %v", shipIdx, attachIdx, fc.calls)
+ }
+ if !strings.Contains(out, "attached "+second) {
+ t.Fatalf("stdout missing the attach line for %s:\n%s", second, out)
+ }
+ if !strings.Contains(out, "live https://"+second) {
+ t.Fatalf("deploy did not complete against the second host:\n%s", out)
+ }
+ checkNoRegistry()
+}
diff --git a/docs/superpowers/plans/2026-08-12-domain-attach.md b/docs/superpowers/plans/2026-08-12-domain-attach.md
new file mode 100644
index 000000000..b2a63df0e
--- /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
+// "hostname 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
+// "hostname 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 `"hostname 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 "hostname 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 `hostname 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.
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
new file mode 100644
index 000000000..c5d736499
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
@@ -0,0 +1,256 @@
+# Domain attach — self-serve custom domains from console, CLI and deploy
+
+**Date:** 2026-08-12 · **Status:** draft (design approved in conversation
+2026-08-12; alias mechanism re-converged the same evening with the #151
+session — see §1.1) · **Requested by:** Paul
+
+**Closes:** #82 (console-driven custom domain attach).
+
+**Builds on:** route records (2026-08-10 — the declared-record vocabulary
+and reconcile authority; PR #137), #151 (domain claim records +
+`route_aliases` + the `domains` console door + `carlos domains` verbs — the
+alias half of this design is #151's mechanism, consumed as-is), custom
+domains (2026-08-02 — the DNS stamps, the §8 future-work list this
+retires a line from), console app pages (2026-08-07 §6 — the Settings
+honesty text this replaces), and `carlos deploy` (2026-08-08 — the
+one-command form §5 completes).
+
+## 1. Why, and the shape in one paragraph
+
+Attaching a custom domain to an app is an operator act today: `carlos add`
+on the box over SSM, which is the exact class of gap CLAUDE.md's no-infra
+rule names. Two live incidents prove both halves: lazyatom's
+echo.example.com (alias attach to a running app — refused by every member
+door) and story.carlosframework.com (a fresh static app whose FIRST route
+is the custom domain — took an operator route-add plus manual DNS). The
+self-serve path splits cleanly on one question the console can answer at
+attach time: **does the app already have a ready route?**
+
+- **Yes → alias attach** (#151's mechanism): the door writes a global
+ claim record naming the primary route; a box-side reconcile pass
+ derives a `route_aliases` entry from it, and the edge resolves the
+ alias to the primary route's upstream at request time. No second route
+ row exists, which is what avoids the #129 socket-race class, #148's
+ bare-alias breakage, and the wake-through-aliasing hole (custom-domains
+ §8) that a copied-shape row inherits. Alias→static-primary works by
+ #151's pinned rule — content/backing derivation uses the primary's
+ host; the request host is TLS/cache-key/metering only — so an alias of
+ a static route serves the same channel-pointed content and carries
+ `X-Carlos-Version`.
+- **No → first-route attach** (this spec's one new mechanism): the same
+ global claim, plus a **declared route record** in #137's vocabulary —
+ `kind: static` + `channel`, validated by `ValidHostShape` — which the
+ provisioning reconciler materializes as the app's real primary route.
+ There is no source to alias, so nothing competes with `route_aliases`;
+ the minted route IS the primary.
+
+Everything else — Settings UI, `carlos deploy -host` auto-attach, and the
+policy rulings — rides those two mechanisms through one console door.
+
+### 1.1 The convergence note for Paul's morning read
+
+Two designs were approved in separate sessions on 2026-08-12: this
+session's original approach (attach = a route record with an additive
+`alias: true` field; reconcile mints a second route row copying the
+primary's upstream trio) and the #151 session's (global claim +
+`route_aliases`, no second row). They conflicted. The second-row shape
+loses on the merits — it reproduces the #129 socket race for hibernating
+instances, re-imports #148's alias breakage, and keeps the
+502-while-hibernating hole — and its one advantage (no new registry
+table) is not worth those. The per-app record's uniqueness check also
+loses to the global claim: scanning records across accounts is racy where
+a global create-only key is atomic. This spec is the reconciliation: #151
+builds the alias mechanism and the shared door; #82 adds the first-route
+mechanism, the UI, and deploy integration on top. The `alias: true`
+record field from the earlier in-session approval is **dead** — it
+appears nowhere below.
+
+## 2. Rulings (decided 2026-08-12, in-session)
+
+- **Route-only.** An attached domain serves the app; it does not widen
+ instance space. `provision.ValidHost` and the sqid-space pin on
+ `carlos instances create` / `handleAPIInstanceCreate` are untouched.
+ An app whose model is instance-per-tenant still mints instances under
+ `<sqid>.<apps-domain>` only. Widening the manifest's `Domain` to "any
+ attached domain" is a named follow-up issue, not an oversight.
+- **DNS-pointing is the ownership evidence.** A claim whose DNS does not
+ reach the edge yields an inert alias or an unroutable route; the
+ console's existing `pointing`/`elsewhere` stamp is the signal, and
+ first-writer-wins (the global claim) closes the intra-platform race.
+ Residual risk, stated: a domain whose DNS already points at the edge
+ but is unclaimed could be claimed by an account that does not control
+ it. Narrow window, accepted for this slice. A TXT-challenge
+ (`_carlos-challenge.<host>`) pending state is named future work, gated
+ on abuse actually appearing once Carloku is commercial — the claim
+ record grows a field then; nothing structural changes.
+- **No DNS record automation.** The door tells the member exactly which
+ records to create (edge IPs / CNAME target) and stamps progress; it
+ does not write to any registrar. The story incident's DNSimple half
+ stays manual by design — token scopes differ per zone and no
+ self-serve story exists yet.
+
+## 3. First-route attach — the one new mechanism
+
+Console-side only; the box half already exists in #137.
+
+When the app has no ready route and the attach names `kind: static`, the
+door writes, in order:
+
+1. The **global claim** (`domains/<host>.json`, create-only, via #151's
+ shared claim writer). The claim shape is #151's:
+ `{schema: 1, host, account, app, route, created_at, created_by}` —
+ `route` names the primary route host in the alias case, and an
+ **empty `route` means "this host is its own primary"**, which is
+ exactly this case. Loss of the create = neutral refusal (§4).
+2. A **declared route record** at `apps/<sqid>/<app>/instances/<host>.json`
+ in #137's vocabulary: `kind: static`, `channel` (explicit, or the
+ app's only shipped channel — several channels and no flag is refused
+ as ambiguous, naming them), `pool`/`region` empty (the shared pool,
+ unconstrained region — the deployment's default boxes), plus
+ `app`/`account`/`added_at`/actor audit fields the record already
+ carries.
+3. `bumpSerial` — the route exists fleet-side in seconds, #137's normal
+ materialization path, zero new reconcile code.
+
+A proxied kind (`instance`, `service`) with no existing route is refused:
+there is no process, socket or address to serve, so the attach would mint
+a route to nothing. The message says to `carlos deploy` (or enable
+instances) first — except through §5's deploy integration, where the ship
+happens in the same command and the static case is the only one that
+arises.
+
+Detach = delete the route record and release the claim (same door,
+reverse order); #137's `Provisioned`-keyed removal phase drops the route.
+If the record was adopted rather than minted (backfill, §7), removal
+authority is identical — that is #137's contract.
+
+## 4. The door, shared with #151
+
+One endpoint family (built by #151, extended here):
+
+```
+POST /api/cli/apps/{account}/{app}/domains attach ({host, route?|kind?, channel?} body)
+GET /api/cli/apps/{account}/{app}/domains list + stamps
+DELETE /api/cli/apps/{account}/{app}/domains/{host} detach
+```
+
+One deliberate asymmetry, stated so nobody reads it as a bug: the POST
+answers the RESOLVED route — the alias target, or the host itself on a
+first-route attach (the record just declared makes the host the app's
+primary) — while the GET reports the claim's STORED `route`, which for a
+first-route claim is empty (the claim marks the host as its own primary,
+not an alias of anything). Both are honest; they answer different
+questions ("where did my hostname land?" vs "what does the claim say?").
+
+Gating follows the CLI-API family's pattern, not the browser doors':
+bearer token (app-token tried first, exact sqid+app binding), byte-
+identical 404 for every non-answer, rate limit on the token hash, audit
+entries, serial bump after every write. The browser Settings door (§6)
+carries the session → CSRF → owner-404 ordering instead — both doors call
+the same attach/detach core.
+
+For an alias attach the door's only write is the claim (with `route` set)
+plus the serial bump — the `route_aliases` row is derived box-side, never
+door-written. For a first-route attach the door writes claim then
+declared record (§3). Validation lives in one exported console-side spot
+(#151 builds it; this spec's rulings land there) so both mechanisms share
+it:
+
+- `ValidHostShape` — lowercase FQDN shape, no path-traversal characters;
+ apexes allowed.
+- **Refuse platform space**: any host equal to or under the deployment's
+ apps-domain, the console's own host, or an edge host. Attached domains
+ are foreign domains by definition.
+- **First-writer-wins**: the global claim's create-only write is the
+ entire uniqueness check. A lost create — whatever account holds the
+ claim — returns the same neutral "hostname unavailable" with no owner
+ leaked. A host that is routed on some box but unclaimed (operator
+ routes predating this) is handled box-side, not console-side: reconcile
+ finds a differing existing route, leaves it alone, and logs — inert,
+ diagnosable, no takeover (#137 §4.2's rule doing its job at this door).
+
+The GET carries the DNS stamp per host (`pointing` / `elsewhere`, the
+existing Domains-table machinery pointed at attached hosts) plus what to
+create: an A-record instruction naming this deployment's edge IPs
+(`domainGuidance`, `internal/console/view.go`), the same wording whether the
+host is an apex or not. The CNAME-for-non-apex split named in an earlier
+draft of this line was never built — the console has one guidance path, not
+two, and every surface (Settings, `carlos domains`, `carlos deploy`'s DNS
+exit) reads it verbatim, so they can never disagree. A CNAME option for
+non-apex hosts is future polish, not a correctness gap: an A record at the
+edge IPs works for any host shape.
+
+## 5. `carlos deploy -host` completes the story
+
+`carlos deploy -kind static -host story.carlosframework.com` on a fresh
+app becomes the whole job:
+
+1. Resolve the host's state via the door's GET: claimed by this app →
+ proceed; claimed by another → refuse (neutral); unclaimed → attach
+ (alias if the app has a ready route, first-route declared record if
+ static, exactly §3's rules — `-kind static` supplies the kind, the
+ shipped channel supplies the channel).
+2. Ship → promote, unchanged.
+3. Verify: wait on `X-Carlos-Version` over 2xx as deploy already does.
+ If DNS is not pointing yet, say so honestly and print the records to
+ create plus the stamp state, then exit nonzero with "re-run when DNS
+ points, or watch with `carlos domains -app X`" — never spin against a
+ host whose DNS the member has not created, and never claim success.
+
+`carlos domains attach/detach/list` themselves are #151's verbs; deploy
+calls the same client functions. Attach output always ends with the DNS
+records to create and the current stamp — the command completes the job
+or says exactly what is left, per the no-infra rule.
+
+## 6. Settings UI
+
+The app Settings page swaps the "custom domains attach via carlos add on
+the box" honesty text for the real thing: an attach form (host; kind
+fixed to what the app supports — alias for routed apps, static
+first-route otherwise), the attached-domains list with DNS stamps and
+detach, and the DNS records to create shown inline per pending host.
+Confirm-before-detach; no fresh-auth gate (attach is reversible and
+serves the member's own app; detach of a live domain gets the confirm,
+not a password). Empty state names the platform domain the app already
+has, so attach reads as addition, not rescue.
+
+## 7. Backfill
+
+story.carlosframework.com (and any other operator-added custom-domain
+route): write the claim + the declared record describing the route that
+already exists; #137's adopt-on-match stamps `Provisioned` and takes
+ownership; the SSM-added row becomes a record-owned row with no box
+command. Applied one host at a time, verified through the serial, per
+#137 §4.3's discipline.
+
+## 8. Rollout and testing
+
+**Order:** #151 lands first on main (its own rollout). #137 merges
+(one-click, catch-up merge pushed 2026-08-12; merging is Paul's action).
+This branch stacks on `worktree-route-records` and rebases to main when
+#137 lands; console + CLI ship together, and since every box-side
+mechanism pre-exists (#137's reconcile, #151's alias pass), the door
+opening is the only event — no window where records outrun readers.
+
+**Testing:**
+- Door: gate-ordering tests (byte-identical 404), each refusal (shape,
+ platform space, claim held, ambiguous channel, proxied-kind-no-route),
+ claim-then-record write order, serial bump, audit entries.
+- First-route: record materializes via existing reconcile tests' harness;
+ detach removes; adopt-on-match backfill golden for the story shape.
+- Deploy: auto-attach happy path, DNS-not-pointing honest failure, claim
+ -held refusal; e2e through `carlosTestEnv` with `cmd.Dir` scratch.
+- Parse goldens: claim and record shapes byte-stable against pre-change
+ writers (the additive rule).
+
+## 9. Out of scope, named
+
+- **Instance-space widening** (attached domain as instance space) — the
+ route-only ruling's follow-up issue.
+- **TXT ownership challenge** — future work gated on abuse (§2).
+- **Bring-your-own apps domain** (custom-domains §8 bullet 1) — the
+ per-account ACME and DNS-delegation price tag stays there, unpaid.
+- **DNS record automation** — no registrar writes (§2).
+- **Path routing** — one host, one upstream, still.
+- **Proxied first-route attach** — an app with no process gets no
+ proxied route; deploy-first stays the answer.
diff --git a/domains_cli.go b/domains_cli.go
index b1841e93d..b046ab640 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -102,8 +102,9 @@ func runDomainsAttach(args []string) {
})
sess := requireAPISession("attach", *consoleFlag)
printUsing(stdout, sess)
- created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
- *app, host, strings.TrimSpace(*route), wildcardArg, catchallArg)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ ctx := context.Background()
+ created, resolvedRoute, err := sess.domainAttach(ctx, acct, *app, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg)
if err != nil {
log.Fatalf("domains attach: %v", err)
}
@@ -112,6 +113,43 @@ func runDomainsAttach(args []string) {
} else {
fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute)
}
+ printDomainDNSLine(ctx, sess, acct, *app, host)
+}
+
+// printDomainDNSLine completes the job `carlos domains attach` promises
+// (spec §5: "Attach output always ends with the DNS records to create and
+// the current stamp — the command completes the job or says exactly what is
+// left"): it re-fetches the domain list and prints the just-attached host's
+// own DNS stamp, so the member never needs a second command (let alone a
+// console visit) to learn whether the domain is live. Degrades quietly on a
+// list failure or a missing row (an old console, or a claim the sweep has
+// not reached yet with no readable answer) — the attach line above already
+// reported the thing that matters (whether the write happened), so a DNS
+// line this call cannot back up is skipped rather than guessed at.
+func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app, host string) {
+ domains, err := sess.domainList(ctx, acct, app)
+ if err != nil {
+ return
+ }
+ for _, d := range domains {
+ if d.Host != host {
+ continue
+ }
+ if d.DNSStatus == "pointing" {
+ fmt.Fprintln(stdout, "DNS: pointing")
+ return
+ }
+ dns := d.DNSStatus
+ if dns == "" {
+ dns = "pending"
+ }
+ if d.Guidance != "" {
+ fmt.Fprintf(stdout, "DNS: %s — %s\n", dns, d.Guidance)
+ } else {
+ fmt.Fprintf(stdout, "DNS: %s\n", dns)
+ }
+ return
+ }
}
func runDomainsDetach(args []string) {
@@ -160,7 +198,9 @@ func runDomainsList(args []string) {
// without shouting it. DELEGATION is a tri-state the door itself never
// fabricates: "-" covers both "no delegation zone configured" and
// "wildcard is off", the two cases the door omits the field for.
- fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION")
+ // GUIDANCE (last — it is the long free-text column) is the door's own
+ // point-your-DNS-here instruction, "-" when the domain is pointing.
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE")
for _, r := range recs {
dns := r.DNSStatus
if dns == "" {
@@ -182,8 +222,12 @@ func runDomainsList(args []string) {
if delegation == "" {
delegation = "-"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation)
+ guidance := r.Guidance
+ if guidance == "" {
+ guidance = "-"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation, guidance)
}
w.Flush()
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index bc3a318cc..693e88b3a 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -38,7 +38,7 @@ func TestAPISessionDomainAttach(t *testing.T) {
s := newTestSession(ts.URL)
created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
- "tickets.example.com", "slug.go.tito.io", nil, nil)
+ "tickets.example.com", "slug.go.tito.io", "", "", nil, nil)
if err != nil {
t.Fatalf("domainAttach: %v", err)
}
@@ -66,7 +66,7 @@ func TestAPISessionDomainAttachNoOp(t *testing.T) {
s := newTestSession(ts.URL)
created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
- "tickets.example.com", "", nil, nil)
+ "tickets.example.com", "", "", "", nil, nil)
if err != nil {
t.Fatalf("domainAttach: %v", err)
}
@@ -86,7 +86,7 @@ func TestAPISessionDomainAttachConflict(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", nil, nil)
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "", "", "", nil, nil)
if err == nil || err.Error() != "hostname unavailable" {
t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err)
}
@@ -105,7 +105,7 @@ func TestAPISessionDomainAttachWildcardRefusal(t *testing.T) {
s := newTestSession(ts.URL)
yes := true
- _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, nil)
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, nil)
if err == nil || err.Error() != msg {
t.Fatalf("domainAttach against a 422 = %v, want exactly %q", err, msg)
}
@@ -127,7 +127,7 @@ func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) {
// Neither named: the wire body must carry no "wildcard"/"catchall" keys
// at all, not false-valued ones — an absent key is what "leave
// unchanged" means to the door.
- if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", nil, nil); err != nil {
+ if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", nil, nil); err != nil {
t.Fatalf("domainAttach: %v", err)
}
if _, ok := gotBody["wildcard"]; ok {
@@ -141,7 +141,7 @@ func TestAPISessionDomainAttachWildcardCatchallWireShape(t *testing.T) {
// their own values — false is a real request ("turn it off"), not an
// absence.
yes, no := true, false
- if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", &yes, &no); err != nil {
+ if _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com", "", "", "", &yes, &no); err != nil {
t.Fatalf("domainAttach: %v", err)
}
var w, c bool
@@ -246,7 +246,7 @@ func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
s := newTestSession(ts.URL)
acct := apiAcct{Seg: "bab"}
- if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", nil, nil); err == nil {
+ if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", "", "", "", nil, nil); err == nil {
t.Fatal("domainAttach against a 404 must error")
}
if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil {
@@ -299,6 +299,15 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
"wildcard": true, "catchall": true, "delegation_status": "delegated"},
{"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending",
"wildcard": false, "catchall": false},
+ // guide.example.com is not pointing yet and carries guidance —
+ // the row TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing
+ // and TestRunDomainsListPrintsTable's GUIDANCE-column assertion
+ // exercise, alongside tickets.example.com's pointing case and
+ // new.example.com's no-guidance-yet ("pending", no dns_status
+ // sent at all) case above.
+ {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending",
+ "dns_status": "elsewhere",
+ "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."},
},
})
default:
@@ -326,6 +335,12 @@ func domainsCLIFixture(t *testing.T) *fakeDomainsConsole {
return fc
}
+// TestRunDomainsAttachPrintsAttached also pins the no-infra completion the
+// spec asks for (§5): a successful attach re-fetches the domain list and
+// prints the just-attached host's own DNS stamp — here "pointing", since
+// the fake's fixed GET row for tickets.example.com already carries it — so
+// the command answers "is this live?" without a second `carlos domains
+// list`.
func TestRunDomainsAttachPrintsAttached(t *testing.T) {
fc := domainsCLIFixture(t)
fc.attachCreated = true
@@ -336,8 +351,28 @@ func TestRunDomainsAttachPrintsAttached(t *testing.T) {
if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" {
t.Fatalf("console saw body %+v", fc.lastAttachBody)
}
- if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") {
- t.Fatalf("stdout = %q, want the attached confirmation", buf.String())
+ out := buf.String()
+ if !strings.Contains(out, "attached tickets.example.com -> slug.go.tito.io") {
+ t.Fatalf("stdout = %q, want the attached confirmation", out)
+ }
+ if !strings.Contains(out, "DNS: pointing") {
+ t.Fatalf("stdout = %q, want the DNS stamp line completing the job", out)
+ }
+}
+
+// TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing pins the other arm:
+// a host whose DNS is not pointing yet gets the console's own guidance text
+// verbatim, ending the command's output with exactly what the member still
+// has to do (spec §5's "completes the job or says exactly what is left").
+func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) {
+ domainsCLIFixture(t)
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "guide.example.com"})
+
+ want := "DNS: elsewhere — Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."
+ if out := buf.String(); !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
}
}
@@ -380,8 +415,11 @@ func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) {
}
}
-// TestRunDomainsListPrintsTable: the list table carries every column and
-// falls back to "-" for the row with no dns_status/cert_expires_at.
+// TestRunDomainsListPrintsTable: the list table carries every column
+// (including the GUIDANCE column added for the no-infra rule — the DNS
+// stamp alone tells a member DNS is wrong, but only guidance tells them
+// what to do about it) and falls back to "-" for a row with no
+// dns_status/cert_expires_at/guidance.
func TestRunDomainsListPrintsTable(t *testing.T) {
domainsCLIFixture(t)
buf := captureStdout(t)
@@ -390,9 +428,11 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
out := buf.String()
for _, want := range []string{
- "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION",
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE",
"tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
"new.example.com", "pending",
+ "guide.example.com", "elsewhere",
+ "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes.",
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout missing %q:\n%s", want, out)
@@ -409,21 +449,25 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
}
}
// tickets.example.com carries wildcard+catchall+delegated: "yes" twice
- // and "delegated" once, in the last three (tabwriter-padded) columns.
+ // and "delegated" once, followed only by the trailing GUIDANCE cell —
+ // "-" here, since the row is pointing and the door sends no guidance.
if wildcardLine == "" {
t.Fatal("no row for tickets.example.com")
}
- if fields := strings.Fields(wildcardLine); len(fields) < 3 ||
- fields[len(fields)-3] != "yes" || fields[len(fields)-2] != "yes" || fields[len(fields)-1] != "delegated" {
- t.Fatalf("wildcard row = %q, want yes/yes/delegated in the last three columns", wildcardLine)
+ if fields := strings.Fields(wildcardLine); len(fields) < 4 ||
+ fields[len(fields)-4] != "yes" || fields[len(fields)-3] != "yes" ||
+ fields[len(fields)-2] != "delegated" || fields[len(fields)-1] != "-" {
+ t.Fatalf("wildcard row = %q, want yes/yes/delegated/- in the last four columns", wildcardLine)
}
// The pending row's dns/cert columns read "-", and wildcard/catchall are
- // blank (not "yes") with delegation "-" — the door omits the field.
+ // blank (not "yes") with delegation and guidance both "-" — the door
+ // omits both fields.
if plainLine == "" {
t.Fatal("no row for new.example.com")
}
- if fields := strings.Fields(plainLine); len(fields) < 1 || fields[len(fields)-1] != "-" {
- t.Fatalf("pending row = %q, want delegation \"-\"", plainLine)
+ if fields := strings.Fields(plainLine); len(fields) < 2 ||
+ fields[len(fields)-2] != "-" || fields[len(fields)-1] != "-" {
+ t.Fatalf("pending row = %q, want delegation and guidance \"-\"", plainLine)
}
if strings.Contains(plainLine, "yes") {
t.Fatalf("pending row = %q, want no wildcard/catchall yes when both are false", plainLine)
diff --git a/domains_e2e_test.go b/domains_e2e_test.go
new file mode 100644
index 000000000..87488d55f
--- /dev/null
+++ b/domains_e2e_test.go
@@ -0,0 +1,253 @@
+// End-to-end acceptance for the domain-attach half of #82 (Task 8), driven
+// through the REAL binary against a REAL console.Server: `carlos deploy
+// -kind static -host <h>` attaches an unclaimed custom domain on its way
+// through (claim + declared record in the bucket), a second attach from a
+// DIFFERENT account is refused with the neutral "hostname unavailable" and
+// nothing else, and `carlos domains list -app` shows the member their own
+// claim. Subprocesses throughout, because the feature's surface is the
+// binary's own composition (deploy → attach POST → ship → promote → watch →
+// DNS guidance) and the refusal text on a real stderr is the thing the spec
+// pins.
+//
+// Env discipline: every subprocess builds its env through carlosTestEnv AND
+// sets cmd.Dir to a scratch dir (the repo rule — the binary walks cwd
+// ancestors for .carlos/config, and a logged-in laptop once minted 40+ real
+// accounts on the production console). The explicit CARLOS_CREDENTIALS
+// appended after carlosTestEnv's own wins (os/exec keeps the LAST duplicate),
+// landing each subprocess on exactly the credential file this test minted.
+package main
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// e2eWriteCredential mints a credential file at path for a device-flow token
+// against consoleURL — the state `carlos auth login` leaves behind, minted
+// through the console's real approve/poll handlers (e2eDeviceLogin).
+func e2eWriteCredential(t *testing.T, path, consoleURL, addr, token string, expiresAt time.Time) {
+ t.Helper()
+ t.Setenv("CARLOS_CREDENTIALS", path)
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: consoleURL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestE2EDomainsAttachThroughDeploy(t *testing.T) {
+ bin := buildCarlos(t)
+ const (
+ ownerAddr = "paul@keymail.dev"
+ rivalAddr = "rival@keymail.dev"
+ app = "sitely"
+ rivalApp = "rival"
+ channel = "canary/site"
+ // .test never resolves, so the watch leg's connection-class failure —
+ // the shape a domain nobody has pointed yet produces — is hermetic.
+ host = "e2e-domains.example.test"
+ )
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Account 1: the ops owner and the static app the deploy targets.
+ if err := cst.AddMember(console.OpsAccountID, ownerAddr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, ownerAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Account 2: a different tenant entirely — the prober the neutrality
+ // half of this test speaks as.
+ const rivalID, rivalSqid = 42, "cdef"
+ if err := cst.CreateAccount(rivalID, rivalSqid, "Rival", rivalAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(rivalID, rivalApp, rivalAddr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ ownerToken, ownerExp := e2eDeviceLogin(t, ts.URL, cst, ownerAddr, "e2e@owner")
+ rivalToken, rivalExp := e2eDeviceLogin(t, ts.URL, cst, rivalAddr, "e2e@rival")
+ ownerCreds := filepath.Join(t.TempDir(), "owner-credentials")
+ rivalCreds := filepath.Join(t.TempDir(), "rival-credentials")
+ e2eWriteCredential(t, ownerCreds, ts.URL, ownerAddr, ownerToken, ownerExp)
+ e2eWriteCredential(t, rivalCreds, ts.URL, rivalAddr, rivalToken, rivalExp)
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ run := func(creds string, args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds,
+ "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
+ cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // --- 1. deploy to a fresh static host: the attach happens on the way --
+ out, err := run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", "sha0001", "-channel", channel, "-host", host, "-console", ts.URL,
+ "-timeout", "3s", siteDir)
+ // The deploy itself succeeds (attach + ship + promote) and then exits
+ // NONZERO on the watch: nobody has pointed DNS for the host, and a zero
+ // exit would claim a URL that does not serve. The exit text is the DNS
+ // guidance, not "deploy failed".
+ if err == nil {
+ t.Fatalf("deploy to an unpointed domain exited 0:\n%s", out)
+ }
+ for _, want := range []string{
+ "attached " + host + " (first route: static, channel " + channel + ")",
+ "shipped " + app + " sha0001",
+ "promoted " + app + " sha0001 -> " + channel,
+ "DNS for " + host + " is not pointing at this deployment yet",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("deploy output missing %q:\n%s", want, out)
+ }
+ }
+ // The two bucket writes the attach is made of, read back directly.
+ claim, err := domains.GetClaim(context.Background(), relStore, host)
+ if err != nil {
+ t.Fatalf("claim after deploy: %v", err)
+ }
+ if claim.Account != release.OpsAccount || claim.App != app || claim.Route != "" {
+ t.Fatalf("claim = %+v, want a first-route claim (empty route) held by ops/%s", claim, app)
+ }
+ rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host)
+ if err != nil {
+ t.Fatalf("record after deploy: %v", err)
+ }
+ if rec.Kind != "static" || rec.Channel != channel || rec.Slug != "" {
+ t.Fatalf("record = %+v, want a static record on %s", *rec, channel)
+ }
+
+ // --- 1b. a SECOND static host on the same app: the first deploy's
+ // record must not pull this one off the static path (the kind-aware
+ // gate), and the fresh host attaches through the same door on the way.
+ const second = "e2e-domains-more.example.test"
+ out, err = run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
+ "-version", "sha0002", "-channel", channel, "-host", second, "-console", ts.URL,
+ "-timeout", "3s", siteDir)
+ if err == nil {
+ t.Fatalf("second-host deploy to an unpointed domain exited 0:\n%s", out)
+ }
+ for _, want := range []string{
+ "attached " + second,
+ "DNS for " + second + " is not pointing at this deployment yet",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("second-host deploy output missing %q:\n%s", want, out)
+ }
+ }
+ if _, err := domains.GetClaim(context.Background(), relStore, second); err != nil {
+ t.Fatalf("claim for the second host: %v", err)
+ }
+ if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, second); err != nil {
+ t.Fatalf("record for the second host: %v", err)
+ }
+
+ // --- 2. the same host from ANOTHER account: neutral refusal ----------
+ out, err = run(rivalCreds, "domains", "attach", "-app", rivalApp, "-account", rivalSqid,
+ "-route", "web.cdef.oncarlos.test", "-console", ts.URL, host)
+ if err == nil {
+ t.Fatalf("rival attach of a held host exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "hostname unavailable") {
+ t.Fatalf("rival attach refusal missing the neutral \"hostname unavailable\":\n%s", out)
+ }
+ // Neutral means NEUTRAL: no owner, no account, no app of the holder's in
+ // the refusal — the door must not be a hostname-ownership oracle.
+ for _, leak := range []string{release.OpsAccount, app, ownerAddr} {
+ if strings.Contains(out, leak) {
+ t.Fatalf("refusal leaks the holder (%q):\n%s", leak, out)
+ }
+ }
+ // And the claim is untouched.
+ if after, err := domains.GetClaim(context.Background(), relStore, host); err != nil ||
+ after.Account != release.OpsAccount || after.App != app {
+ t.Fatalf("claim after rival attach = %+v (err %v), want the original untouched", after, err)
+ }
+
+ // --- 3. the owner lists it -------------------------------------------
+ out, err = run(ownerCreds, "domains", "list", "-app", app, "-account", "ops", "-console", ts.URL)
+ if err != nil {
+ t.Fatalf("domains list: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, host) || !strings.Contains(out, "pending") {
+ t.Fatalf("domains list missing %s as pending:\n%s", host, out)
+ }
+
+ // --- 4. detach releases it, and the record goes with it --------------
+ out, err = run(ownerCreds, "domains", "detach", "-app", app, "-account", "ops", "-console", ts.URL, host)
+ if err != nil {
+ t.Fatalf("domains detach: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "detached "+host) {
+ t.Fatalf("detach output:\n%s", out)
+ }
+ if _, err := domains.GetClaim(context.Background(), relStore, host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("claim after detach: %v, want gone", err)
+ }
+ // The record goes with the claim (detach's record-then-claim order): a
+ // record outliving its claim would keep a route materializing for a
+ // host anyone may now claim.
+ if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("record after detach: %v, want gone", err)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 4e82f297e..6b6d3b492 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -140,14 +140,26 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a
// route, write. Idempotent by design (the titogo contract): re-attaching a
// hostname this app already holds is 200 with created false, converging the
// route if it moved, and a hostname anyone ELSE holds is the neutral 409.
+//
+// The optional kind/channel body fields are `carlos deploy`'s first-route
+// extras (#82): kind "static" routes the attach through the shared
+// attachDomain core (domainattach.go) instead of the route-defaulting below
+// (which needs an instance record a static app never has). The core itself
+// decides what the attach means — an app holding a ready route gets an
+// alias claim naming it; one holding none gets the claim AND a declared
+// static provision record the reconciler materializes as the app's first
+// route. route and kind together are refused: an alias target and a
+// first-route mint are two different instructions.
func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
principal, acct, app, ok := s.instanceGate(w, r)
if !ok {
return
}
var in struct {
- Host string `json:"host"`
- Route string `json:"route"`
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Kind string `json:"kind"`
+ Channel string `json:"channel"`
// Wildcard and Catchall are the woodstar-carloku capability flips
// (Task 5), decoded as pointers so an ABSENT field means "leave
// unchanged" — the same tri-state rule apps place's -target and
@@ -175,8 +187,37 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
return
}
+ if in.Kind != "" && in.Route != "" {
+ jsonError(w, http.StatusBadRequest,
+ "route and kind are two different instructions — name a route to alias, or a kind to mint, not both")
+ return
+ }
+ if in.Kind != "" && in.Kind != "static" {
+ jsonError(w, http.StatusBadRequest, "kind must be \"static\"")
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
+ if in.Kind == "static" {
+ // The shared core decides alias-vs-first-route itself (readyRouteHost
+ // is ITS question — no pre-read here): a ready route makes this an
+ // ordinary alias claim naming it, none makes the host the app's
+ // first route (claim + declared static record). The answer comes
+ // from the core's result: on the first-route branch the host IS the
+ // route the record just declared; on the alias branch it is the
+ // route the claim names.
+ res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ route := res.Claim.Route
+ if res.FirstRoute {
+ route = host
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": res.Created})
+ return
+ }
route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route)
if !ok {
return
@@ -363,6 +404,22 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
+ // The first-route half (#82): a claim with an empty Route minted the
+ // app's own primary route via a declared provision record, and that
+ // record must go BEFORE the claim — the mirror of attach's claim-then-
+ // record order, so a failure here cannot leave a record materializing a
+ // route for a host anyone may now claim. Only the caller's own
+ // first-route claim takes this branch; every other shape (absent,
+ // another owner's, an alias) skips it silently and DetachClaim's own
+ // neutrality answers as before.
+ if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil &&
+ cur.Account == acct.Sqid && cur.App == app && cur.Route == "" {
+ if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
+ log.Printf("console: api domains detach %s/%s %s: record delete: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not detach the hostname — try again")
+ return
+ }
+ }
removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app)
if err != nil {
log.Printf("console: api domains detach %s/%s %s: %v", acct.Sqid, app, host, err)
@@ -381,13 +438,19 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
// (host, route) joined with what the fleet actually reports for it. Status is
// "live" or "pending" and never absent; the two reading columns omit when
// unstamped, the same unknown-stays-absent convention status.AliasReading
-// itself uses.
+// itself uses. Guidance is the point-your-DNS-here instruction, present
+// exactly when DNSStatus is anything but "pointing" — the same words and
+// the same condition as the console Settings page (domainGuidance, view.go),
+// so `carlos deploy`'s DNS-guidance exit and the page a member opens next
+// can never tell them two different stories. Additive: a client that
+// predates it simply never decodes it.
type domainListItem struct {
Host string `json:"host"`
Route string `json:"route"`
Status string `json:"status"`
DNSStatus string `json:"dns_status,omitempty"`
CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+ Guidance string `json:"guidance,omitempty"`
// Wildcard and Catchall (Task 5) copy the claim's own capability bits
// verbatim — always present, true or false, the same "known state"
// convention Created/Removed use on the attach/detach answers (never
@@ -447,6 +510,9 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
item.DNSStatus = reading.DNSStatus
item.CertExpiresAt = reading.CertExpiresAt
}
+ if item.DNSStatus != "pointing" {
+ item.Guidance = domainGuidance(c.Host, s.cfg.EdgeIPs)
+ }
// Each stamp is a live, sequential, uncancellable-per-call DNS round
// trip on the request path (lookupCNAME) — capped at
// domainListCNAMELookupCap per list call so an app with dozens of
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index 29173035a..3127077bc 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -27,6 +27,7 @@ import (
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
@@ -490,6 +491,117 @@ func TestAPIDomainSerialBump(t *testing.T) {
}
}
+// attachBodyKind is attachBody with the deploy extras (#82): kind and/or
+// channel ride the same POST body; empty fields are simply absent, matching
+// what the client sends (omitempty).
+func attachBodyKind(host, route, kind, channel string) string {
+ m := map[string]string{"host": host}
+ if route != "" {
+ m["route"] = route
+ }
+ if kind != "" {
+ m["kind"] = kind
+ }
+ if channel != "" {
+ m["channel"] = channel
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+// TestAPIDomainAttachKindValidation pins the two 400s the kind extension
+// adds: route and kind together (an alias target and a first-route mint are
+// two different instructions), and any kind but "static" (nothing else can
+// be minted from a domain attach). Neither writes a claim.
+func TestAPIDomainAttachKindValidation(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-400")
+ for name, body := range map[string]string{
+ "route and kind together": attachBodyKind("both.example.com", "go.tito.io", "static", ""),
+ "kind other than static": attachBodyKind("both.example.com", "", "binary", ""),
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusBadRequest {
+ t.Errorf("%s = %d %s, want 400", name, code, out)
+ }
+ claimAbsent(t, fx, "both.example.com")
+ }
+}
+
+// TestAPIDomainAttachKindStaticFirstRoute: kind=static against an app with
+// no ready route runs the shared attach core's first-route branch — the
+// claim (empty stored route) AND a declared static provision record land,
+// and the answer's route is the host itself, the primary the record just
+// declared. This is `carlos deploy -kind static -host <h>`'s server half.
+func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-first")
+ // The fixture registry holds no titogo routes, so the core has nothing
+ // to alias; the first-route branch needs something shipped to resolve
+ // the record's channel from.
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ const host = "fresh.example.com"
+
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBodyKind(host, "", "static", ""))
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s, want 200", code, out)
+ }
+ var got domainAttachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("attach body %s: %v", out, err)
+ }
+ if got != (domainAttachResp{Host: host, Route: host, Created: true}) {
+ t.Fatalf("attach answer = %+v, want route == host (the first-route primary) and created true", got)
+ }
+ c := claimOf(t, fx, host)
+ if c.Route != "" || c.Account != "bab" || c.App != "titogo" {
+ t.Fatalf("claim = %+v, want a first-route claim (empty stored route) for bab/titogo", *c)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host)
+ if err != nil {
+ t.Fatalf("record: %v, want the declared static record", err)
+ }
+ if rec.Kind != "static" || rec.Channel != "canary/live" || rec.Slug != "" {
+ t.Fatalf("record = %+v, want a static record on canary/live", *rec)
+ }
+}
+
+// TestAPIDomainAttachKindStaticWithReadyRouteAliases: the same kind=static
+// body against an app that DOES hold a ready route is an ordinary alias —
+// the core answers its own question and nothing static is minted: the claim
+// stores the ready route, the answer reports it, and no provision record
+// appears at the host's key.
+func TestAPIDomainAttachKindStaticWithReadyRouteAliases(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-kind-alias")
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "titogo.bab.oncarlos.test", Kind: "static", App: "titogo",
+ Account: "bab", Channel: "canary/live", Status: "ready"},
+ )
+ const host = "alias.example.com"
+
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBodyKind(host, "", "static", ""))
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s, want 200", code, out)
+ }
+ var got domainAttachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil {
+ t.Fatalf("attach body %s: %v", out, err)
+ }
+ if got != (domainAttachResp{Host: host, Route: "titogo.bab.oncarlos.test", Created: true}) {
+ t.Fatalf("attach answer = %+v, want the ready route as the alias target", got)
+ }
+ if c := claimOf(t, fx, host); c.Route != "titogo.bab.oncarlos.test" {
+ t.Fatalf("claim = %+v, want the alias claim naming the ready route", *c)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("record: %v, want NONE — an alias attach must not mint a static record", err)
+ }
+}
+
// ---- wildcard/catchall capability door (Task 5) ----------------------------
// TestAttachWildcardCNAMEGate: the attach body's optional wildcard/catchall
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
new file mode 100644
index 000000000..ec23703c9
--- /dev/null
+++ b/internal/console/appdomains.go
@@ -0,0 +1,106 @@
+// The app Settings tab's domain attach/detach door (#82, Task 4): thin
+// browser-form handlers over the attachDomain/detachDomain core in
+// domainattach.go. Everything that decides whether an attach or detach is
+// ALLOWED — platform space, alias-vs-first-route, the claim/record write
+// order, the audit row, the serial bump — lives there and is not repeated
+// here. What's here is the browser's own shape: session, CSRF, the
+// owner-only gate, and turning the core's answer into a page.
+//
+// Gate order copies handleAppDelete's (appsettings.go) exactly, minus
+// requireFreshAuth: session (accountApp) -> CSRF -> owner (byte-identical
+// 404) -> refusals -> act. Spec §6: attach/detach are member-reversible, no
+// passkey step-up — detach's protection is the typed-host confirm below,
+// not a passkey.
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// handleAppDomainAttach claims a host for the app and, when the app has no
+// route to alias, declares the static route that becomes its first one.
+// The browser door never sends a kind — the core infers alias vs
+// first-route from whether the app already has a ready route — and the
+// channel field is optional, passed through as typed.
+func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
+ channel := strings.TrimSpace(r.FormValue("channel"))
+
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr)
+ if ref != nil {
+ s.renderDoorRefusal(w, r, ref)
+ return
+ }
+ msg := "Attached " + host + " — it now aliases the app's existing route."
+ if res.FirstRoute {
+ msg = "Attached " + host + " as the app's first route, following " + res.Channel + "."
+ }
+ s.setFlash(w, "ok", msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+}
+
+// handleAppDomainDetach releases a host's claim. The typed-host confirm is
+// the browser door's own gate — spec §6's substitute for a passkey step-up
+// on a reversible action — and is checked BEFORE the core is called: a
+// mismatch must leave the claim exactly as it was.
+func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ host := strings.ToLower(strings.TrimSpace(r.FormValue("host")))
+ if strings.ToLower(strings.TrimSpace(r.FormValue("confirm"))) != host {
+ s.renderMessage(w, r, http.StatusUnprocessableEntity, "Refused", "Type the domain exactly to detach it.")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ if ref := s.detachDomain(ctx, acct, app, host, addr); ref != nil {
+ s.renderDoorRefusal(w, r, ref)
+ return
+ }
+ s.setFlash(w, "ok", "Detached "+host+".")
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+}
+
+// renderDoorRefusal turns a doorRefusal from the Task 3 core into this
+// door's page. 404 MUST be http.NotFound: the stranger's byte-identical
+// 404, not a rendered message that would tell a prober "an app exists
+// here, but this host does not" versus "nothing exists here at all".
+// Every other status renders through renderMessage with the refusal's own
+// status and member-facing text.
+func (s *Server) renderDoorRefusal(w http.ResponseWriter, r *http.Request, ref *doorRefusal) {
+ if ref.Status == http.StatusNotFound {
+ http.NotFound(w, r)
+ return
+ }
+ s.renderMessage(w, r, ref.Status, "Refused", ref.Msg)
+}
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go
new file mode 100644
index 000000000..02ab75a0f
--- /dev/null
+++ b/internal/console/appdomains_test.go
@@ -0,0 +1,213 @@
+package console
+
+// The browser Settings door for domain attach/detach (appdomains.go, #82
+// Task 4): thin handlers over the attachDomain/detachDomain core proven in
+// domainattach_test.go. What these tests own is the door's OWN shape —
+// session, CSRF, the owner-only gate matching handleAppDelete's, and
+// detach's typed-host confirm — not the attach/detach policy itself.
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+)
+
+// TestAppDomainAttachHappyPathAliases exercises the default fixture's kass
+// app, which already carries one ready route (kass.carlosapp.dev, view_test's
+// buildFixtures) — the alias branch needs nothing more set up than that.
+func TestAppDomainAttachHappyPathAliases(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ const host = "docs.example.com"
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
+ url.Values{"csrf": {csrf}, "host": {host}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ claim, err := domains.GetClaim(context.Background(), fx.rel, host)
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+ if claim.Account != fx.sqid || claim.App != "kass" || claim.Route == "" {
+ t.Errorf("claim = %+v, want an alias claim naming kass's existing route", claim)
+ }
+}
+
+// TestAppDomainAttachNeedsCSRF matches TestRenameHandlerNeedsCSRF's shape: a
+// cross-site request is refused before the host is ever looked at, and
+// nothing is claimed.
+func TestAppDomainAttachNeedsCSRF(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ const host = "docs.example.com"
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
+ url.Values{"csrf": {"not-the-token"}, "host": {host}})
+ if status != http.StatusForbidden {
+ t.Fatalf("attach with a bad csrf = %d, want 403:\n%s", status, body)
+ }
+ if !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("403 body does not name the reason:\n%s", body)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestAppDomainAttachLowercasesHost: a member typing Docs.Example.com must
+// not be refused by ValidHostShape's lowercase-only label regex — the door
+// normalizes case before the host ever reaches the core, and the claim
+// lands at the normalized key, not the one typed.
+func TestAppDomainAttachLowercasesHost(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ const mixed = "Docs.Example.com"
+ const lower = "docs.example.com"
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
+ url.Values{"csrf": {csrf}, "host": {mixed}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil {
+ t.Errorf("claim at the lowercase key: %v, want it there", err)
+ }
+ if _, err := domains.GetClaim(context.Background(), fx.rel, mixed); err == nil {
+ t.Error("a claim also landed at the mixed-case key — the door did not normalize")
+ }
+}
+
+// TestAppDomainAttachIsOwnerOnly matches TestAppDeleteIsOwnerOnly's shape:
+// a member gets the same byte-identical 404 a total stranger would, and
+// nothing is claimed.
+func TestAppDomainAttachIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-appdomainattach")
+ csrf := sessionToken(t, fx, c)
+ const host = "docs.example.com"
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/kass/domains/attach"),
+ url.Values{"csrf": {csrf}, "host": {host}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member attaching a domain = %d, want 404", status)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdomainattach", fx.u("/apps/kass/domains/attach"),
+ url.Values{"host": {host}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// domainAttachedFixture attaches host to app via the Task 3 core directly
+// (the doors' shared, already-tested path) and returns the fixture and
+// signed-in owner client — the setup every detach test needs and none of
+// them is testing.
+func domainAttachedFixture(t *testing.T, app, host string) (*consoleFixture, *http.Client, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, app, host, "", "", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("attach setup: refused %+v", ref)
+ }
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ return fx, c, csrf
+}
+
+// TestAppDomainDetachNeedsMatchingConfirm is the binding detail from the
+// brief: a confirm that does not equal the host is refused 422 BEFORE the
+// core is ever called, so the claim must survive untouched.
+func TestAppDomainDetachNeedsMatchingConfirm(t *testing.T) {
+ const host = "docs.example.com"
+ fx, c, csrf := domainAttachedFixture(t, "kass", host)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {"not-" + host}})
+ if status != http.StatusUnprocessableEntity {
+ t.Fatalf("detach with a wrong confirm = %d, want 422:\n%s", status, body)
+ }
+ if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil {
+ t.Errorf("claim after a refused detach: %v, want it untouched", err)
+ }
+}
+
+// TestAppDomainDetachHappyPath: the typed confirm matches, the claim goes,
+// and the member lands back on Settings.
+func TestAppDomainDetachHappyPath(t *testing.T) {
+ const host = "docs.example.com"
+ fx, c, csrf := domainAttachedFixture(t, "kass", host)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestAppDomainDetachUnattachedHostIs404 exercises renderDoorRefusal's 404
+// branch: the owner's own gates all pass (CSRF, RoleOwner, confirm == host),
+// but the core has nothing to release, so its doorRefusal{404,...} must
+// render as the stranger's own byte-identical 404 — never a message that
+// would tell a prober "an app exists here, but this host does not".
+func TestAppDomainDetachUnattachedHostIs404(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ const host = "never-attached.example.com"
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusNotFound {
+ t.Fatalf("detach of an unattached host = %d, want 404:\n%s", status, body)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach-unattached", fx.u("/apps/kass/domains/detach"),
+ url.Values{"host": {host}, "confirm": {host}})
+ if body != strangerBody {
+ t.Fatalf("owner's unattached-host 404 is not byte-identical to the stranger's:\n(owner) %q\n(stranger) %q", body, strangerBody)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestAppDomainDetachIsOwnerOnly mirrors the attach case: a member gets the
+// same byte-identical 404 a stranger would.
+func TestAppDomainDetachIsOwnerOnly(t *testing.T) {
+ const host = "docs.example.com"
+ fx, _, _ := domainAttachedFixture(t, "kass", host)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ sc := newBrowser(t)
+ signUp(t, fx, sc, "code-sam-appdomaindetach")
+ scsrf := sessionToken(t, fx, sc)
+
+ status, _, memberBody := postForm(t, sc, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {scsrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member detaching a domain = %d, want 404", status)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdomaindetach", fx.u("/apps/kass/domains/detach"),
+ url.Values{"host": {host}, "confirm": {host}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil {
+ t.Errorf("claim after a member's refused detach: %v, want it untouched", err)
+ }
+}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index a77a78324..35d55cb6b 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -12,10 +12,15 @@
// rename is a coordinated migration, not a settings write. The form
// refuses with that reason once anything outside this database knows the
// name.
-// - Creating routes (custom domains). The console reads the registry and
-// writes it nothing; domains attach on the box today, and the console-
-// driven path wants the bucket-record + reconciler design instance
-// provisioning proved out — a platform feature, not a handler here.
+// - Attach/detach ITSELF. Domains attach and detach through their own door
+// now (appdomains.go, #82 Task 4) — the claim/record writes, their gate
+// order and the audit trail all live there. What's here is read-only:
+// settingsDomains below folds an app's still-routeless claims (every
+// alias claim, and a first-route claim before the reconciler has
+// materialized its route — neither ever gets a registry row of its
+// own) into the Domains & TLS table this page already draws, so an
+// attach shows up the moment it lands, not only once a route exists to
+// join it to.
// - The full account move. `carlos accounts migrate` moves bucket objects
// and re-stamps routes on the box; what the console owns is its own
// record, so what Settings offers is the repair half — making this
@@ -32,6 +37,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -58,6 +64,30 @@ type SettingsView struct {
// and a zero card when there is genuinely no store, which is what the
// "ask for one" line renders from.
Store *StoreCard
+
+ // Domains is this page's OWN Domains & TLS rows (settingsDomains): a
+ // copy of AppView.Domains with a placeholder row appended for any of
+ // this app's claims that have no registry row yet. Deliberately not
+ // written back onto AppView.Domains itself — that slice stays
+ // index-parallel with AppView.Routes for assembleOverview's account
+ // filter (view.go), and a claim-only row has no route to pair with.
+ Domains []DomainView
+ // OnlyPlatformDomain is true when Domains holds rows but none of them
+ // is a custom one — the state the attach form's empty-state line
+ // ("this app already answers at its platform domain…") is written for.
+ OnlyPlatformDomain bool
+
+ // NeedsChannel is whether the attach form must show its channel field:
+ // true exactly when readyRouteHost (domainattach.go) would find no
+ // ready, non-Platform route to alias, which is the SAME condition the
+ // core uses to pick the first-route branch — attachChannel then only
+ // has a self channel or the app's sole shipped channel to fall back on,
+ // and refuses with "say which one" the moment there is more than one.
+ // This is deliberately NOT len(Detail.Routes) == 0: an app whose only
+ // route is its own platform mint has a non-empty Routes slice, but
+ // readyRouteHost skips Platform rows, so it still takes the
+ // first-route branch and still needs somewhere to type a channel.
+ NeedsChannel bool
}
// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
@@ -93,6 +123,38 @@ type StoreCard struct {
// emptiness claim must not enable an identity change) and never the page.
func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView {
sv := &SettingsView{}
+ // Domains, its derived OnlyPlatformDomain, and NeedsChannel are all
+ // read-only against the registry/claims and owe nothing to the
+ // deployment-bucket check below — they must be set even on a console
+ // with no bucket configured (s.cfg.Releases == nil), which used to
+ // return before reaching them and show "No domains yet" under a live
+ // route.
+ sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains)
+ sv.OnlyPlatformDomain = len(sv.Domains) > 0
+ for _, d := range sv.Domains {
+ // Prefix, not equality: domainView appends " · canary/<name>" AFTER
+ // the "platform"/"custom" word (view.go), so a platform canary
+ // follower's Type is "platform · canary/rc" — equality against the
+ // bare word would wrongly count it as a custom domain here.
+ if !strings.HasPrefix(d.Type, "platform") {
+ sv.OnlyPlatformDomain = false
+ break
+ }
+ }
+ // The same question attachDomain's first line of policy answers
+ // (domainattach.go's readyRouteHost): is there a ready, non-Platform
+ // route this app could alias? "" means no — the attach form's channel
+ // field must show, because the core is about to take the first-route
+ // branch and attachChannel only has a self channel or the app's sole
+ // shipped channel to infer from. The attaching host is unknown here
+ // (nothing has been typed yet), so "" is passed — it can never equal a
+ // real route host, so every ready non-Platform route is a candidate.
+ if route, _, err := s.readyRouteHost(acct.Sqid, p.AppName, ""); err != nil {
+ log.Printf("console: settings needs-channel %s/%s: %v", acct.Sqid, p.AppName, err)
+ sv.NeedsChannel = true // fail toward showing the field the core might need
+ } else {
+ sv.NeedsChannel = route == ""
+ }
if s.cfg.Releases == nil {
sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
return sv
@@ -167,6 +229,38 @@ func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *Stor
return card
}
+// settingsDomains is the Settings page's own Domains & TLS rows: routed
+// (the AppView's own DomainView slice, untouched) plus a placeholder row
+// for any of this app's domain claims that routed has no entry for yet.
+// Best-effort like the rest of this file — a claim-list read failure is
+// logged and the page falls back to routed alone, never a hard error.
+func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView {
+ out := append([]DomainView(nil), routed...)
+ if s.cfg.Releases == nil {
+ return out
+ }
+ have := make(map[string]int, len(out))
+ for i, d := range out {
+ have[strings.ToLower(d.Domain)] = i
+ }
+ claims, err := domains.ListClaims(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err)
+ return out
+ }
+ for _, c := range claims {
+ if c.Account != acct.Sqid || c.App != app {
+ continue
+ }
+ if i, ok := have[strings.ToLower(c.Host)]; ok {
+ out[i].Claimed = true
+ continue
+ }
+ out = append(out, claimDomainView(c.Host, s.cfg.EdgeIPs))
+ }
+ return out
+}
+
// appDeployEmpty reports whether the bucket holds ANYTHING under the app's
// prefix — account-qualified or legacy-unqualified. One key is enough to
// refuse a rename; the caller never needs the listing itself.
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index 38b10b441..5440b1a24 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -969,3 +969,109 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) {
t.Fatalf("an app with no store is offered a destroy form:\n%s", body)
}
}
+
+// ---- the domain attach/detach section of Settings (#82 Task 5) -------------
+//
+// The honesty text ("attached where the app runs — on its box, by an
+// operator") is retired now that this account's own door does the attaching
+// (appdomains.go, Task 4): the page must offer that door's form instead of a
+// box command. And a claim this app holds must show up here the moment it
+// exists, not only once a registry route has caught up with it — which,
+// for an ALIAS claim, is never: domainattach.go's own comment is that
+// minting a route row for an alias is exactly the #129/#148 bare-alias
+// class this design avoids.
+
+// TestSettingsPageOffersAttachFormNotBoxInstructions is the honesty-text
+// retirement itself.
+func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ if strings.Contains(body, "carlos add -host") {
+ t.Fatalf("the settings page still tells the owner to attach on the box:\n%s", body)
+ }
+ if !strings.Contains(body, `action="/a/bab/apps/sprout/domains/attach"`) {
+ t.Fatalf("the settings page does not post to its own attach door:\n%s", body)
+ }
+}
+
+// TestSettingsPageShowsAnUnroutedClaimAsAttaching exercises the gap the
+// honesty text used to paper over: kass already has a ready route, so
+// attaching docs.example.com to it takes the ALIAS branch, which never gets
+// a registry row — the claim must still render, as its own placeholder row,
+// not vanish until a route exists to join it to.
+func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) {
+ const host = "docs.example.com"
+ fx, c, _ := domainAttachedFixture(t, "kass", host)
+
+ status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, host) {
+ t.Fatalf("an attached claim with no route yet does not appear on its own Settings page:\n%s", body)
+ }
+ if !strings.Contains(body, "custom · attaching") {
+ t.Fatalf("the claim-only row does not say it's still attaching:\n%s", body)
+ }
+}
+
+// TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform is the review
+// fix: readyRouteHost (domainattach.go) skips Platform rows when deciding
+// whether an attach can alias, so an app whose only route is its own
+// platform mint takes the first-route branch server-side and needs the
+// channel field exactly as a never-shipped app does — `len(Detail.Routes)
+// == 0` would wrongly hide it, because the platform route makes that slice
+// non-empty.
+func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ fx.addRoute(t, registry.Route{Host: "sprout.bab.oncarlos.com", Kind: "static", Status: "ready",
+ App: "sprout", Channel: "stable", Platform: true})
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, `id="attach-channel"`) {
+ t.Fatalf("an app whose only route is its platform mint is not offered the channel field:\n%s", body)
+ }
+}
+
+// TestAssembleSettingsListsRoutedDomainsWithoutABucket is the other review
+// fix: assembleSettings used to return before sv.Domains was ever set on a
+// bucketless console (s.cfg.Releases == nil), so a routed app's own domain
+// would vanish from Settings.Domains the moment there was no bucket to
+// check the never-shipped rename gate against.
+//
+// This calls assembleSettings directly rather than through the HTTP page:
+// a bucketless console panics inside Assemble itself (assembleChannels,
+// view.go, calls release.Store.List on every app-page tab, Settings
+// included, with no nil guard) — a real, pre-existing gap, but one that
+// reaches every app page and is not Task 5's Settings-only bug. Fixing
+// assembleSettings's own early return is; isolating it here proves that fix
+// without the unit test tripping over the wider, separate one.
+func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.Releases = nil
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ detail := &AppView{
+ App: "kass",
+ Domains: []DomainView{{Domain: "kass.carlosapp.dev", Type: "custom"}},
+ }
+ p := &page{AppName: "kass", Detail: detail}
+
+ sv := fx.srv.assembleSettings(context.Background(), p, acct)
+ if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" {
+ t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains)
+ }
+}
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
new file mode 100644
index 000000000..40dce96a9
--- /dev/null
+++ b/internal/console/domainattach.go
@@ -0,0 +1,424 @@
+// Attach and detach a custom domain — the core both doors share (#82).
+// Spec: docs/superpowers/specs/2026-08-12-domain-attach-design.md §3-§4.
+//
+// The attach splits on one question the console can answer: does the app
+// already have a ready route? Yes → an ALIAS claim naming that route, and
+// nothing else is written (the `route_aliases` row is derived box-side;
+// minting a second route row is the #129 socket-race / #148 bare-alias
+// class this design exists to avoid). No → a FIRST-ROUTE attach: the same
+// claim with an empty route, plus a declared static route record in #137's
+// vocabulary, which the provisioning reconciler materializes as the app's
+// real primary route.
+//
+// The invariant: the write order is CLAIM THEN RECORD. The claim's
+// create-only write IS the fleet-wide uniqueness check (#151's
+// domains.AttachClaim — the bucket key is the cross-account gate), so it
+// must be decided before anything else about the host exists. That ordering
+// also picks the failure worth having — a half-failed attach leaves at worst
+// a claim with no record (inert: no route is materialized from a
+// first-route claim, and detach or a retry clears it), never a live route
+// whose host is unclaimed. A record write that fails ambiguously therefore
+// unwinds both best-effort and refuses, while a record ALREADY at the key
+// (which, being account+app scoped, can only be this app's own) makes the
+// attach idempotent rather than a permanent 500. The reverse write order
+// would let two accounts each declare a record for one host and the loser
+// find out from the fleet.
+//
+// audit + bumpSerial live in here rather than in the doors, so both doors
+// get them, in the right place, for free: strictly after the write they
+// announce — and gated on an ACTUAL write (#151's changed-gating rule): a
+// re-attach that stored nothing new must not send every box in the fleet
+// converging onto a serial that names no change, nor bury the tenant's
+// audit log under no-ops.
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// doorRefusal is a refusal the calling door translates into its own shape —
+// a rendered message for the browser, a JSON error (or, for 404, the
+// family's byte-identical http.NotFound) for the CLI API. Msg is
+// member-facing and NEVER names another account: the 409 below is one
+// string whoever holds the host, including the caller.
+type doorRefusal struct {
+ Status int
+ Msg string
+}
+
+// attachResult is what the doors report back. FirstRoute distinguishes the
+// two mechanisms — true means a declared record was written (or found
+// already standing) and a real route is materializing, which is the
+// difference between "your domain serves the app the moment DNS points" and
+// "a new route is being created". Created is domains.AttachClaim's own
+// answer: this call brought the claim into existence (false on a re-attach,
+// which the claim writer treats as a converge or a no-op, never a refusal).
+type attachResult struct {
+ Claim domains.Claim
+ Created bool
+ FirstRoute bool // a declared record backs this attach
+ Channel string // channel the first-route record follows ("" for alias)
+}
+
+// attachDomain claims host for acct's app and, when the app has no route to
+// alias, declares the static route that becomes its first one. kindFlag and
+// channelFlag may both be "" — the kind is then inferred (alias if there is
+// a route, static otherwise) and the channel is the one the host's own
+// existing route follows, or failing that the app's sole shipped channel.
+func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) {
+ if ref := s.bucketRefusal(); ref != nil {
+ return attachResult{}, ref
+ }
+ // Policy first: shape and platform space are decided before any write,
+ // so a refused host never reaches a bucket key. Both checks are #151's
+ // exported validation surface (internal/domains + deniedDomains), the
+ // same pair the CLI API door runs — one validation spot, two doors.
+ host, ok := domains.NormalizeAliasHost(host)
+ if !ok {
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
+ "not a valid hostname — attach a full hostname like tickets.example.com"}
+ }
+ if domains.HostDenied(host, s.deniedDomains()) {
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
+ "that hostname is platform-managed"}
+ }
+ route, selfChannel, err := s.readyRouteHost(acct.Sqid, app, host)
+ if err != nil {
+ log.Printf("console: attach %s: registry: %v", host, err)
+ return attachResult{}, &doorRefusal{http.StatusInternalServerError,
+ "could not read this deployment's routes — try again in a moment"}
+ }
+ claim := domains.Claim{
+ Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: route,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal,
+ }
+
+ // --- alias attach: the app already has a route to serve from ---------
+ if route != "" {
+ created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim)
+ if err != nil {
+ return attachResult{}, claimRefusal(host, err)
+ }
+ if changed {
+ s.audit(acct, app, principal, "domain-attach", host+" → "+route)
+ s.bumpSerial(ctx)
+ }
+ return attachResult{Claim: claim, Created: created}, nil
+ }
+
+ // --- first-route attach: this host becomes the app's primary --------
+ if kindFlag != "" && kindFlag != "static" {
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
+ "an app with no running route can only attach a static domain — deploy first, then attach"}
+ }
+ channel, ref := s.attachChannel(ctx, acct.Sqid, app, channelFlag, selfChannel)
+ if ref != nil {
+ return attachResult{}, ref
+ }
+ created, changed, err := domains.AttachClaim(ctx, s.cfg.Releases, claim)
+ if err != nil {
+ return attachResult{}, claimRefusal(host, err)
+ }
+ rec := provision.Record{
+ Schema: 1, Host: host, App: app, Account: acct.Sqid,
+ Channel: channel, Kind: "static",
+ CreatedAt: time.Now().UTC(), CreatedBy: principal,
+ }
+ if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
+ // A record already at this key is NOT a failure to undo. The key is
+ // account+app scoped, so a create that lost the race lost it to this
+ // app's own record — a retry after a half-failed attach, or a record
+ // written for a host whose claim had been released. GetRecord
+ // re-reads it and refuses a body that disagrees with its own path,
+ // so a successful read is proof the record describes this app and
+ // account; the attach is then idempotent, the claim stays (this
+ // app's, whether this call minted it or an earlier one did), and the
+ // truth reported back is the record's channel, not the one this call
+ // would have written. audit + serial follow the write-gating rule:
+ // only when the CLAIM half actually wrote — a fully-converged
+ // re-attach (claim untouched, record standing) stores nothing and
+ // announces nothing.
+ if errors.Is(err, release.ErrCASConflict) {
+ existing, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if gerr == nil && attachWrote(*existing) {
+ if changed {
+ s.audit(acct, app, principal, "domain-attach", host+" (first route, "+existing.Channel+", record already present)")
+ s.bumpSerial(ctx)
+ }
+ return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel}, nil
+ }
+ // Unreadable (malformed, or a body that lies about its
+ // identity) or written by a door other than this one. Not ours
+ // to delete and not safe to write over, so the claim goes back
+ // (only if this call minted it — a pre-existing claim is not
+ // this failure's to unwind) and the member is told what stands
+ // in the way — the fact of a record, and nothing about who
+ // wrote it.
+ log.Printf("console: attach %s: existing record is not this door's to adopt (read: %v)", host, gerr)
+ s.rollbackAttach(ctx, acct, app, host, false, created)
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
+ "a route record already exists for this host — detach it or pick another hostname"}
+ }
+ // Anything else is AMBIGUOUS: a timeout or a dropped connection can
+ // come back as an error for a write that landed. Unwind both, in
+ // detach's order (record, then claim), so the failure cannot leave a
+ // record behind a released claim — a route materializing for a host
+ // anyone may now claim.
+ log.Printf("console: attach %s: record write: %v", host, err)
+ s.rollbackAttach(ctx, acct, app, host, true, created)
+ return attachResult{}, &doorRefusal{http.StatusInternalServerError,
+ "could not declare the route for this domain — nothing was attached, try again"}
+ }
+ // The record wrote (a real change by definition), so this announces
+ // regardless of whether the claim half was the no-op of a retry.
+ s.audit(acct, app, principal, "domain-attach", host+" (first route, "+channel+")")
+ s.bumpSerial(ctx)
+ return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel}, nil
+}
+
+// detachDomain releases host's claim and, when the claim minted the app's
+// own route (empty Route — the first-route case), removes the declared
+// record first so the reconciler's removal phase drops the route.
+//
+// Every non-answer is one refusal: absent, another account's, or this
+// account's other app's all return the same 404, which the doors render as
+// http.NotFound — a detach must not be the probe that confirms a host is
+// claimed, or by whom.
+func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal {
+ if ref := s.bucketRefusal(); ref != nil {
+ return ref
+ }
+ notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"}
+ // A host that is not even a hostname cannot be a claim key: refuse it
+ // here rather than handing it to a store as a key segment.
+ host, ok := domains.NormalizeAliasHost(host)
+ if !ok {
+ return notAttached
+ }
+ claim, err := domains.GetClaim(ctx, s.cfg.Releases, host)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return notAttached
+ }
+ log.Printf("console: detach %s: claim read: %v", host, err)
+ return &doorRefusal{http.StatusInternalServerError,
+ "could not read this domain's claim — try again in a moment"}
+ }
+ if claim.Account != acct.Sqid || claim.App != app {
+ return notAttached
+ }
+ if claim.Route == "" {
+ // First-route: the record goes before the claim, the mirror of
+ // attach. A record left behind by a released claim would keep a
+ // route alive for a host anyone may now claim.
+ if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
+ log.Printf("console: detach %s: record delete: %v", host, err)
+ return &doorRefusal{http.StatusInternalServerError,
+ "could not remove this domain's route — it is still attached, try again"}
+ }
+ }
+ // DetachClaim re-checks ownership before deleting (its own neutrality);
+ // the read above already proved it, so removed=false here can only mean
+ // a race this call lost — in which case nothing was written and nothing
+ // announces.
+ removed, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: detach %s: claim delete: %v", host, err)
+ return &doorRefusal{http.StatusInternalServerError,
+ "could not release this domain's claim — try again in a moment"}
+ }
+ if removed {
+ s.audit(acct, app, principal, "domain-detach", host)
+ s.bumpSerial(ctx)
+ }
+ return nil
+}
+
+// readyRouteHost answers "does this app already have a route to alias?" with
+// the host of the first one, or "" for none. The selection is
+// domains.firstReadyUpstream's, minus the upstream copy: only the HOST is
+// needed here, because nothing about the primary's upstream is copied —
+// the alias resolves to it at request time, box-side.
+//
+// An empty Registry path is "no routes", not an error: a console configured
+// without a registry (and every test that wants the first-route branch) has
+// nothing to alias, and a missing DB must not be reported as a route.
+//
+// attaching is the host being attached. It is never an alias candidate — a
+// claim whose route names its own host is a self-alias, which is not a thing
+// the box side can resolve — and its ready row is instead reported back as
+// selfChannel, the channel that row already follows. That case is real: an
+// operator-added custom-domain route (story.carlosframework.com, spec §7)
+// that no claim covers yet. Skipping it as an alias lands the attach on the
+// first-route branch, where the declared record describes the row that
+// already exists and #137's adopt-on-match transfers ownership with no box
+// command — the backfill, arriving through the member's own door — and
+// carrying its channel forward is what keeps that adoption from being a
+// silent repoint (provision.convergeOwned writes the record's channel onto
+// the row it adopts).
+func (s *Server) readyRouteHost(sqid, app, attaching string) (alias, selfChannel string, err error) {
+ if s.cfg.Registry == "" {
+ return "", "", nil
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return "", "", err
+ }
+ routes, err := reg.List() // host order
+ if err != nil {
+ return "", "", err
+ }
+ for _, rt := range routes {
+ if rt.App != app || release.RouteAccount(rt) != sqid {
+ continue
+ }
+ // A platform mint is not a source to alias — aliasing a mint is
+ // self-reference, the same rule firstReadyUpstream carries — and a
+ // route that is not ready has nothing to serve yet.
+ if rt.Platform || rt.Status != "ready" {
+ continue
+ }
+ if rt.Host == attaching {
+ selfChannel = rt.Channel
+ continue
+ }
+ if alias == "" {
+ alias = rt.Host
+ }
+ }
+ return alias, selfChannel, nil
+}
+
+// attachChannel resolves the channel a first-route record follows: the flag
+// when given, then the channel the host's own existing route already
+// follows, then the app's sole shipped channel. Nothing shipped and several
+// channels are both refusals rather than guesses — a record pointed at the
+// wrong channel serves the wrong build from a domain the member just
+// published.
+//
+// selfChannel (the backfill case above) takes precedence over the listing
+// for two reasons: the record is about to be adopted onto that live row, so
+// naming any other channel would REPOINT a running route the member did not
+// ask to move, and an app with two channels would otherwise be refused as
+// ambiguous for a domain whose channel was never in question.
+func (s *Server) attachChannel(ctx context.Context, sqid, app, channelFlag, selfChannel string) (string, *doorRefusal) {
+ // Shape only, never existence: a channel a member is about to ship is a
+ // legitimate target, but a malformed one becomes a bucket key segment
+ // and a route pinned to nothing (release.ValidChannel is the same rail
+ // the instance-create door puts on its own channel field).
+ if channelFlag != "" {
+ if !release.ValidChannel(channelFlag) {
+ return "", &doorRefusal{http.StatusUnprocessableEntity, "invalid channel name"}
+ }
+ return channelFlag, nil
+ }
+ if selfChannel != "" && release.ValidChannel(selfChannel) {
+ return selfChannel, nil
+ }
+ prefix := release.ChannelsPrefix(sqid, app)
+ keys, err := s.cfg.Releases.List(ctx, prefix)
+ if err != nil {
+ log.Printf("console: attach: list channels %s/%s: %v", sqid, app, err)
+ return "", &doorRefusal{http.StatusInternalServerError,
+ "could not list this app's channels — try again in a moment"}
+ }
+ var names []string
+ for _, k := range keys {
+ name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json")
+ if !ok || name == "" {
+ continue
+ }
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ switch len(names) {
+ case 0:
+ return "", &doorRefusal{http.StatusUnprocessableEntity,
+ "nothing shipped yet — carlos ship first"}
+ case 1:
+ return names[0], nil
+ }
+ return "", &doorRefusal{http.StatusUnprocessableEntity,
+ "this app follows several channels (" + strings.Join(names, ", ") +
+ ") — say which one this domain serves"}
+}
+
+// attachWrote reports whether rec is a record THIS door could have written:
+// the fixed shape of a first-route attach — kind static, no slug, and the
+// shared pool and unconstrained region (§3). It is the guard on the
+// idempotent conflict branch, and it is narrow on purpose. The record key
+// is host-scoped within an app, and an app with a custom manifest Domain
+// can have `carlos instances create` write an INSTANCE record at the very
+// same key; adopting one of those as "an attach that already happened"
+// would hand detach the authority to retire a tenant instance this door
+// never created. Anything that fails this test is left alone and refused.
+func attachWrote(rec provision.Record) bool {
+ return rec.Kind == "static" && rec.Slug == "" && rec.Region == "" && rec.Pool == ""
+}
+
+// bucketRefusal is the "this console cannot answer at all" check both verbs
+// open with: a deployment with no bucket has nowhere to put a claim or a
+// record. Same 503 and the same sentence the instance API's gate uses
+// (apiinstances.go), so one deployment shape produces one answer wherever a
+// member meets it.
+func (s *Server) bucketRefusal() *doorRefusal {
+ if s.cfg.Releases == nil {
+ return &doorRefusal{http.StatusServiceUnavailable,
+ "no deployment bucket on this host — the console is running without one"}
+ }
+ return nil
+}
+
+// rollbackAttach unwinds a half-written first-route attach, best effort:
+// the bucket that just refused a write may refuse these too, and a member
+// waiting on a refusal must not also wait on a retry loop. dropRecord asks
+// for the record to go first — set when the record write's outcome is
+// UNKNOWN (a timeout can report failure for an object that landed), and
+// clear when the record is known to be someone else's business. dropClaim
+// is whether the claim is this failure's to release at all: only when THIS
+// call created it — deleting a claim an earlier attach minted would strand
+// a host the member still legitimately holds. DetachClaim is idempotent and
+// owner-checked, so a no-op costs one read.
+//
+// A rollback that itself fails leaves a claim with no record: inert (nothing
+// materializes from a claim alone), visible to its own owner in the app's
+// domain list, and clearable with detach. That is the failure this ordering
+// was chosen to have.
+func (s *Server) rollbackAttach(ctx context.Context, acct *Account, app, host string, dropRecord, dropClaim bool) {
+ if dropRecord {
+ if err := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); err != nil {
+ log.Printf("console: attach %s: record rollback: %v", host, err)
+ }
+ }
+ if !dropClaim {
+ return
+ }
+ if _, err := domains.DetachClaim(ctx, s.cfg.Releases, host, acct.Sqid, app); err != nil {
+ log.Printf("console: attach %s: claim rollback: %v (claim is stranded — detach clears it)", host, err)
+ }
+}
+
+// claimRefusal maps a lost claim write onto the door's answer. The 409 is
+// ONE string whoever holds the host: domains.ErrHostUnavailable already
+// refuses to name the holder, and the text here is its message verbatim —
+// the same bytes the CLI API door answers, so the two doors cannot be
+// diffed against each other to learn anything either one refuses to say.
+func claimRefusal(host string, err error) *doorRefusal {
+ if errors.Is(err, domains.ErrHostUnavailable) {
+ return &doorRefusal{http.StatusConflict, "hostname unavailable"}
+ }
+ log.Printf("console: attach %s: claim write: %v", host, err)
+ return &doorRefusal{http.StatusInternalServerError,
+ "could not claim this domain — try again in a moment"}
+}
diff --git a/internal/console/domainattach_test.go b/internal/console/domainattach_test.go
new file mode 100644
index 000000000..93b90940c
--- /dev/null
+++ b/internal/console/domainattach_test.go
@@ -0,0 +1,711 @@
+package console
+
+// The attach/detach core (domainattach.go): the one place both doors — the
+// browser Settings form and the CLI API — decide what attaching a custom
+// domain to an app MEANS. The properties that matter, in the order they'd
+// hurt if lost: platform space is refused before anything is written, an
+// app with a ready route gets an alias claim and NO route record (a second
+// route row is the #129/#148 class this design exists to avoid), a lost
+// claim race is neutral even when the caller's own account holds it (a 409
+// that named the holder would be a cross-account leak), the first-route
+// path writes the CLAIM BEFORE the record (uniqueness is decided before
+// anything else exists), a record write that fails takes the claim back
+// with it, and detach unwinds in the reverse order behind a 404 that
+// cannot tell "not attached" from "someone else's".
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// attachFixture is server_test.go's console pointed at an EMPTY world for
+// attach purposes: no registry path at all (so the ready-route lookup is
+// skipped and every test starts on the first-route branch) and the
+// deployment's apps domain set, which is what makes platform space
+// refusable. Tests that want an alias point cfg.Registry at attachRegistry.
+func attachFixture(t *testing.T) (*consoleFixture, *Account) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.Registry = ""
+ fx.srv.cfg.AppsDomain = "oncarlos.com"
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if acct == nil {
+ t.Fatalf("fixture account %s not found", fx.sqid)
+ }
+ return fx, acct
+}
+
+// attachRegistry builds a registry holding routes and returns its path.
+func attachRegistry(t *testing.T, routes ...registry.Route) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := registry.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ for _, rt := range routes {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return path
+}
+
+// opStore records every store write in order — the only way to observe
+// "claim THEN record" (and detach's reverse) rather than merely "both
+// landed". failPut names one key whose Put fails, which is how the
+// half-failed attach is reproduced without a broken bucket.
+type opStore struct {
+ release.Store
+ ops []string
+ // failPut names a key whose Put fails before it lands. landThenFail
+ // makes the same key LAND and then report failure — the ambiguous
+ // outcome a timeout produces, and the one a one-sided rollback leaks a
+ // record through.
+ failPut string
+ landThenFail bool
+}
+
+func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if o.failPut != "" && key == o.failPut {
+ if o.landThenFail {
+ o.ops = append(o.ops, "put-then-fail "+key)
+ if _, err := o.Store.Put(ctx, key, body, ifMatch); err != nil {
+ return "", err
+ }
+ return "", errors.New("opStore: timed out after the object landed")
+ }
+ o.ops = append(o.ops, "put-fail "+key)
+ return "", errors.New("opStore: bucket unavailable")
+ }
+ o.ops = append(o.ops, "put "+key)
+ return o.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (o *opStore) Delete(ctx context.Context, key string) error {
+ o.ops = append(o.ops, "delete "+key)
+ return o.Store.Delete(ctx, key)
+}
+
+// opIndex is where an op appears in the log, or -1.
+func (o *opStore) opIndex(op string) int {
+ for i, got := range o.ops {
+ if got == op {
+ return i
+ }
+ }
+ return -1
+}
+
+// seedClaim plants a pre-existing claim — the "someone already holds this
+// host" fixture. AttachClaim is the one claim writer left (#151), and
+// creating against an empty key is its create path.
+func seedClaim(t *testing.T, fx *consoleFixture, c domains.Claim) {
+ t.Helper()
+ created, _, err := domains.AttachClaim(context.Background(), fx.rel, c)
+ if err != nil || !created {
+ t.Fatalf("seed claim %s: created=%v err=%v", c.Host, created, err)
+ }
+}
+
+// auditedOnce asserts exactly one audit row for the action, naming host —
+// the record a member (and an incident) reads the door's history from.
+func auditedOnce(t *testing.T, fx *consoleFixture, acct *Account, app, action, host string) {
+ t.Helper()
+ events, err := fx.st.Events(acct.ID, app, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, e := range events {
+ if e.Action == action && strings.Contains(e.Detail, host) {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Errorf("audit rows for %s %s = %d, want 1 (rows: %+v)", action, host, n, events)
+ }
+}
+
+func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) {
+ fx, acct := attachFixture(t)
+ // Three candidate rows in host order: a platform mint and a route that
+ // is not ready come FIRST alphabetically, so picking the third proves
+ // the filter rather than "picked the only one".
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "aaa." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready", Platform: true},
+ registry.Route{Host: "bbb." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "pending"},
+ registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ registry.Route{Host: "other." + fx.sqid + ".oncarlos.com", Kind: "static", App: "other",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ const host = "story.carlosframework.com"
+ ops := &opStore{Store: fx.rel}
+ fx.srv.cfg.Releases = ops
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach: refused %+v", ref)
+ }
+ if want := "story." + fx.sqid + ".oncarlos.com"; res.Claim.Route != want {
+ t.Errorf("claim route = %q, want the app's first non-platform ready route %q", res.Claim.Route, want)
+ }
+ if res.FirstRoute {
+ t.Error("FirstRoute = true for an app that already has a route to alias")
+ }
+ if res.Channel != "" {
+ t.Errorf("Channel = %q, want empty for an alias attach", res.Channel)
+ }
+ got, err := domains.GetClaim(context.Background(), fx.rel, host)
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+ if got.Schema != 1 || got.Host != host || got.Account != fx.sqid || got.App != "story" ||
+ got.Route != res.Claim.Route || got.CreatedBy != "paul@keymail.dev" {
+ t.Errorf("stored claim = %+v, want the returned claim %+v", got, res.Claim)
+ }
+ keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Errorf("records after an alias attach = %v, want none (the alias is derived box-side)", keys)
+ }
+ // The alias branch owes the same two side effects the first-route
+ // branch does: both doors get them from here, or neither does.
+ auditedOnce(t, fx, acct, "story", "domain-attach", host)
+ claimPut := ops.opIndex("put " + domains.ClaimKey(host))
+ serial := ops.opIndex("put " + release.SerialKey)
+ if claimPut < 0 || serial < claimPut {
+ t.Errorf("ops %v: want the serial bumped after the claim it announces (claim=%d serial=%d)", ops.ops, claimPut, serial)
+ }
+}
+
+// TestAttachNeverAliasesAHostToItself is the operator-added custom domain
+// (story.carlosframework.com, spec §7) arriving at the member's door: a
+// ready route for the very host being attached must not become the claim's
+// alias target — a claim naming its own host is a self-alias nothing can
+// resolve. The attach lands on the first-route branch instead, where the
+// declared record describes the row that already exists and #137's
+// adopt-on-match transfers ownership.
+func TestAttachNeverAliasesAHostToItself(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: host, Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach: refused %+v", ref)
+ }
+ if res.Claim.Route != "" || !res.FirstRoute {
+ t.Errorf("result = %+v, want a first-route attach with an empty claim route", res)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil {
+ t.Errorf("record: %v, want a declared record describing the existing route", err)
+ }
+}
+
+// TestAttachBackfillTakesChannelFromTheExistingRoute is the spec-owner's
+// ruling: on the self-host backfill the record's channel comes from the row
+// that already serves the host, not from the app's channel listing. Two
+// things turn on it — #137's adoption converges the record's channel onto
+// that live row, so any other value SILENTLY REPOINTS a running operator
+// route, and an app with several channels would otherwise be refused as
+// ambiguous for a domain whose channel was never in question.
+func TestAttachBackfillTakesChannelFromTheExistingRoute(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ seedChannel(t, fx, "story", "stable", "v1") // ambiguous, if the listing decided
+ const host = "story.carlosframework.com"
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: host, Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach: refused %+v, want the backfill to go through on the row's own channel", ref)
+ }
+ if res.Claim.Route != "" || !res.FirstRoute || res.Channel != "canary/live" {
+ t.Errorf("result = %+v, want a first-route attach on canary/live with an empty claim route", res)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host)
+ if err != nil {
+ t.Fatalf("record: %v", err)
+ }
+ if rec.Channel != "canary/live" {
+ t.Errorf("record channel = %q, want canary/live — the channel the live route already follows", rec.Channel)
+ }
+ // An explicit flag still wins: repointing is allowed when it is asked for.
+ const other = "docs.carlosframework.com"
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: other, Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ res, ref = fx.srv.attachDomain(context.Background(), acct, "story", other, "", "stable", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach with -channel stable: refused %+v", ref)
+ }
+ if res.Channel != "stable" {
+ t.Errorf("Channel = %q, want the explicitly named stable", res.Channel)
+ }
+}
+
+func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ ops := &opStore{Store: fx.rel}
+ fx.srv.cfg.Releases = ops
+
+ const host = "story.carlosframework.com"
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach: refused %+v", ref)
+ }
+ if !res.FirstRoute {
+ t.Error("FirstRoute = false for an app with no ready route")
+ }
+ if res.Channel != "canary/live" {
+ t.Errorf("Channel = %q, want the app's sole shipped channel canary/live", res.Channel)
+ }
+ if res.Claim.Route != "" {
+ t.Errorf("claim route = %q, want empty — this host IS its own primary", res.Claim.Route)
+ }
+ claim, err := domains.GetClaim(context.Background(), fx.rel, host)
+ if err != nil {
+ t.Fatalf("claim: %v", err)
+ }
+ if claim.Route != "" || claim.Account != fx.sqid || claim.App != "story" {
+ t.Errorf("stored claim = %+v", claim)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host)
+ if err != nil {
+ t.Fatalf("record: %v", err)
+ }
+ if rec.Schema != 1 || rec.Kind != "static" || rec.Channel != "canary/live" ||
+ rec.CreatedBy != "paul@keymail.dev" || rec.Pool != "" || rec.Region != "" {
+ t.Errorf("record = %+v, want a schema-1 static record on canary/live in the shared pool", *rec)
+ }
+ claimPut := ops.opIndex("put " + domains.ClaimKey(host))
+ recPut := ops.opIndex("put " + provision.RecordKey(fx.sqid, "story", host))
+ if claimPut < 0 || recPut < 0 || claimPut > recPut {
+ t.Errorf("write order %v: want the claim put before the record put (claim=%d record=%d)", ops.ops, claimPut, recPut)
+ }
+ // The two things the doors get for free by this living in the core: the
+ // audit row, and the serial bump that makes the fleet converge in
+ // seconds instead of at the hourly floor.
+ auditedOnce(t, fx, acct, "story", "domain-attach", host)
+ if serial := ops.opIndex("put " + release.SerialKey); serial < recPut {
+ t.Errorf("ops %v: want the serial bumped AFTER the writes it announces (serial=%d record=%d)", ops.ops, serial, recPut)
+ }
+}
+
+// TestAttachIdempotentWhenRecordAlreadyExists is the retry after a partial
+// attach (and the claim-released-but-record-kept shape): the record key is
+// account+app scoped, so a create that loses the race there lost it to this
+// app's own record. Refusing would make the host permanently unattachable —
+// the claim would roll back on every retry and detach would 404.
+func TestAttachIdempotentWhenRecordAlreadyExists(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ // A record left behind by an earlier attach, on a DIFFERENT channel so
+ // the answer proves which one is reported.
+ if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{
+ Schema: 1, Host: host, App: "story", Account: fx.sqid, Kind: "static", Channel: "stable",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach: refused %+v, want the idempotent success", ref)
+ }
+ if !res.FirstRoute || res.Channel != "stable" {
+ t.Errorf("result = %+v, want FirstRoute with the EXISTING record's channel", res)
+ }
+ if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil {
+ t.Errorf("claim: %v — the claim this call won must survive the idempotent path", err)
+ }
+ // And the second call is idempotent too (#151's same-owner contract):
+ // claim and record both stand, nothing is written, Created is false.
+ res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Errorf("second attach = %+v, want the idempotent success", ref)
+ } else if res.Created {
+ t.Error("second attach: Created = true, want false — the claim already existed")
+ }
+}
+
+// TestAttachUnreadableExistingRecordRefuses422 is the other half: a record
+// at the key that cannot be read as this app's (malformed, or a body that
+// disagrees with its own path) is not ours to overwrite or delete.
+func TestAttachUnreadableExistingRecordRefuses422(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ if _, err := fx.rel.Put(context.Background(),
+ provision.RecordKey(fx.sqid, "story", host), []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("refusal = %+v, want 422 for an unreadable record at the key", ref)
+ }
+ if strings.Contains(ref.Msg, fx.sqid) {
+ t.Errorf("msg %q leaks identity — the member learns a record exists, nothing more", ref.Msg)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestAttachInstanceRecordAtTheKeyIsNotAdopted is the narrow half of the
+// idempotent branch: an app with a custom manifest Domain can have `carlos
+// instances create` write an INSTANCE record at this very key. Treating one
+// of those as "an attach that already happened" would hand detach the
+// authority to retire a tenant instance this door never created.
+func TestAttachInstanceRecordAtTheKeyIsNotAdopted(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ // Kind "" is an instance record — what reconcile minted before Kind
+ // existed, and what the instance door writes today.
+ instance := provision.Record{
+ Schema: 1, Host: host, App: "story", Account: fx.sqid,
+ Slug: "story", Channel: "stable",
+ }
+ if err := provision.CreateRecord(context.Background(), fx.rel, instance); err != nil {
+ t.Fatal(err)
+ }
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("refusal = %+v, want 422 — an instance record is not an attach to adopt", ref)
+ }
+ claimAbsent(t, fx, host)
+ got, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host)
+ if err != nil {
+ t.Fatalf("record: %v, want the instance record untouched", err)
+ }
+ if got.Kind != "" || got.Slug != "story" || got.Channel != "stable" {
+ t.Errorf("record = %+v, want the instance record exactly as it was", *got)
+ }
+}
+
+// TestAttachAmbiguousRecordFailureUnwindsBoth: a write that LANDS and then
+// reports failure (a timeout) must not leave the record behind the claim it
+// rolls back — that record would materialize a route for a host anyone may
+// now claim.
+func TestAttachAmbiguousRecordFailureUnwindsBoth(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host), landThenFail: true}
+ fx.srv.cfg.Releases = ops
+
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 500 {
+ t.Fatalf("refusal = %+v, want 500 for an ambiguous record write", ref)
+ }
+ claimAbsent(t, fx, host)
+ if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("record after the rollback: %v, want it gone — a record outliving its claim is the leak", err)
+ }
+ recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host))
+ claimDel := ops.opIndex("delete " + domains.ClaimKey(host))
+ if recDel < 0 || claimDel < 0 || recDel > claimDel {
+ t.Errorf("ops %v: want the record dropped before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel)
+ }
+}
+
+// TestAttachClaimWriteFailureIs500 pins the non-CAS fallback: only a lost
+// race is "hostname unavailable".
+func TestAttachClaimWriteFailureIs500(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ fx.srv.cfg.Releases = &opStore{Store: fx.rel, failPut: domains.ClaimKey(host)}
+
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 500 {
+ t.Fatalf("refusal = %+v, want 500 for a claim write that failed for reasons other than a lost race", ref)
+ }
+ if ref.Msg == "hostname unavailable" {
+ t.Error("a broken bucket must not read as a taken host")
+ }
+ claimAbsent(t, fx, host)
+}
+
+func TestAttachWithNothingShippedRefused(t *testing.T) {
+ fx, acct := attachFixture(t)
+ const host = "story.carlosframework.com"
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 || ref.Msg != "nothing shipped yet — carlos ship first" {
+ t.Fatalf("refusal = %+v, want 422 \"nothing shipped yet — carlos ship first\"", ref)
+ }
+ claimAbsent(t, fx, host)
+}
+
+func TestAttachInvalidChannelFlagRefused(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ for _, bad := range []string{"../escape", "a/b/c", "canary live"} {
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", bad, "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("channel %q: refusal = %+v, want 422", bad, ref)
+ }
+ claimAbsent(t, fx, host)
+ }
+}
+
+// TestAttachWithoutBucketIs503: a console with no deployment bucket has
+// nowhere to put a claim, and says so rather than panicking on a nil store.
+func TestAttachWithoutBucketIs503(t *testing.T) {
+ fx, acct := attachFixture(t)
+ fx.srv.cfg.Releases = nil
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "story.carlosframework.com", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 503 {
+ t.Errorf("attach refusal = %+v, want 503", ref)
+ }
+ if ref := fx.srv.detachDomain(context.Background(), acct, "story", "story.carlosframework.com", "paul@keymail.dev"); ref == nil || ref.Status != 503 {
+ t.Errorf("detach refusal = %+v, want 503", ref)
+ }
+}
+
+// TestAttachFirstRouteRollsBackClaimOnRecordFailure is the half-failed
+// attach: the claim landed, the record did not, and the host must be left
+// attachable again rather than claimed by an app with no route.
+func TestAttachFirstRouteRollsBackClaimOnRecordFailure(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ ops := &opStore{Store: fx.rel, failPut: provision.RecordKey(fx.sqid, "story", host)}
+ fx.srv.cfg.Releases = ops
+
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 500 {
+ t.Fatalf("refusal = %+v, want a 500 for a record write that failed", ref)
+ }
+ claimAbsent(t, fx, host)
+ if ops.opIndex("delete "+domains.ClaimKey(host)) < 0 {
+ t.Errorf("ops %v: want the claim deleted after the record write failed", ops.ops)
+ }
+}
+
+func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ seedChannel(t, fx, "story", "stable", "v1")
+
+ const host = "story.carlosframework.com"
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("refusal = %+v, want 422 for two channels and no flag", ref)
+ }
+ for _, name := range []string{"canary/live", "stable"} {
+ if !strings.Contains(ref.Msg, name) {
+ t.Errorf("msg %q does not name channel %q — the member cannot pick one from it", ref.Msg, name)
+ }
+ }
+ claimAbsent(t, fx, host)
+
+ // The same attach with the flag supplied goes through.
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "stable", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach with -channel stable: refused %+v", ref)
+ }
+ if res.Channel != "stable" {
+ t.Errorf("Channel = %q, want the named stable", res.Channel)
+ }
+}
+
+func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+
+ const host = "story.carlosframework.com"
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "instance", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("refusal = %+v, want 422 for a proxied kind with no process to serve", ref)
+ }
+ if !strings.Contains(ref.Msg, "static") {
+ t.Errorf("msg %q does not say what IS attachable", ref.Msg)
+ }
+ claimAbsent(t, fx, host)
+}
+
+func TestAttachHeldHostIsNeutral409(t *testing.T) {
+ const host = "story.carlosframework.com"
+ t.Run("another account", func(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"})
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" {
+ t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\"", ref)
+ }
+ })
+ // This account's OTHER app holding it is the same refusal, byte for
+ // byte: the message may never be the thing that tells a prober WHICH
+ // owner stands in the way — GET is where you see your own claims.
+ t.Run("own account, another app", func(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "docs"})
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 409 || ref.Msg != "hostname unavailable" {
+ t.Fatalf("refusal = %+v, want exactly 409 \"hostname unavailable\" for the caller's own other app too", ref)
+ }
+ })
+ // The SAME app re-attaching is not a refusal at all any more: #151's
+ // claim writer treats it as a converge (titogo's router re-attaches on
+ // every reload pass), and the core inherits that idempotency — Created
+ // false, nothing re-audited when nothing was written.
+ t.Run("same app is idempotent", func(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("first attach: refused %+v", ref)
+ }
+ if !res.Created {
+ t.Error("first attach: Created = false, want true")
+ }
+ res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("re-attach: refused %+v, want the idempotent success", ref)
+ }
+ if res.Created {
+ t.Error("re-attach: Created = true, want false — the claim already existed")
+ }
+ auditedOnce(t, fx, acct, "story", "domain-attach", host)
+ })
+}
+
+func TestAttachPlatformSpaceRefused(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+
+ // "../control/serial" is the traversal shape (domainpolicy_test.go's
+ // case, ported): a host that would walk out of the domains/ prefix must
+ // die at the shape check, never reach a bucket key — claimAbsent can't
+ // even ASK about it (the store refuses the key), which is the point.
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "../control/serial", "", "", "paul@keymail.dev"); ref == nil || ref.Status != 422 {
+ t.Fatalf("attach traversal host: refusal = %+v, want 422", ref)
+ }
+ for _, host := range []string{"oncarlos.com", "story." + fx.sqid + ".oncarlos.com", "not a host"} {
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 422 {
+ t.Fatalf("attach %q: refusal = %+v, want 422", host, ref)
+ }
+ claimAbsent(t, fx, host)
+ }
+}
+
+func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ const host = "story.carlosframework.com"
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("attach: refused %+v", ref)
+ }
+ ops := &opStore{Store: fx.rel}
+ fx.srv.cfg.Releases = ops
+
+ if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil {
+ t.Fatalf("detach: refused %+v", ref)
+ }
+ claimAbsent(t, fx, host)
+ if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("record after detach: %v, want gone", err)
+ }
+ recDel := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host))
+ claimDel := ops.opIndex("delete " + domains.ClaimKey(host))
+ if recDel < 0 || claimDel < 0 || recDel > claimDel {
+ t.Errorf("ops %v: want the record deleted before the claim (record=%d claim=%d)", ops.ops, recDel, claimDel)
+ }
+ auditedOnce(t, fx, acct, "story", "domain-detach", host)
+ if serial := ops.opIndex("put " + release.SerialKey); serial < claimDel {
+ t.Errorf("ops %v: want the serial bumped after the deletes it announces (serial=%d claim=%d)", ops.ops, serial, claimDel)
+ }
+}
+
+// TestDetachAliasLeavesRecordsAlone: an alias claim (Route set) never had a
+// record of its own, and detaching it must not delete at that key — the app
+// may hold a record there for a route this claim only pointed at.
+func TestDetachAliasLeavesRecordsAlone(t *testing.T) {
+ fx, acct := attachFixture(t)
+ const host = "echo.example.com"
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref != nil || res.Claim.Route == "" {
+ t.Fatalf("attach: %+v / %+v, want an alias claim", res, ref)
+ }
+ ops := &opStore{Store: fx.rel}
+ fx.srv.cfg.Releases = ops
+ if ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev"); ref != nil {
+ t.Fatalf("detach: refused %+v", ref)
+ }
+ claimAbsent(t, fx, host)
+ if i := ops.opIndex("delete " + provision.RecordKey(fx.sqid, "story", host)); i >= 0 {
+ t.Errorf("ops %v: an alias detach deleted a record key it never wrote", ops.ops)
+ }
+}
+
+func TestDetachForeignClaimIs404(t *testing.T) {
+ fx, acct := attachFixture(t)
+ const host = "story.carlosframework.com"
+ seedClaim(t, fx, domains.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"})
+ ref := fx.srv.detachDomain(context.Background(), acct, "story", host, "paul@keymail.dev")
+ if ref == nil || ref.Status != 404 {
+ t.Fatalf("refusal = %+v, want 404 for another account's claim", ref)
+ }
+ // The claim survives: a foreign detach must not be a delete door.
+ if _, err := domains.GetClaim(context.Background(), fx.rel, host); err != nil {
+ t.Fatalf("claim after a foreign detach: %v, want it untouched", err)
+ }
+ // A claim held by this account for a DIFFERENT app is the same refusal.
+ const other = "docs.carlosframework.com"
+ seedClaim(t, fx, domains.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"})
+ if ref := fx.srv.detachDomain(context.Background(), acct, "story", other, "paul@keymail.dev"); ref == nil || ref.Status != 404 {
+ t.Fatalf("refusal = %+v, want 404 for this account's OTHER app's claim", ref)
+ }
+ // And a host nobody has claimed.
+ if ref := fx.srv.detachDomain(context.Background(), acct, "story", "unclaimed.example.com", "paul@keymail.dev"); ref == nil || ref.Status != 404 {
+ t.Fatalf("refusal = %+v, want 404 for an unclaimed host", ref)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 89f2898a3..362bf51f9 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -693,6 +693,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
+ // Custom domain attach/detach (appdomains.go, #82): owner-only, no
+ // passkey step-up (spec §6) — detach's guard is the typed-host confirm.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach)
// Customer fleets (Task 9): the owner's own remote boxes and what's
// placed on them — member-readable, owner-gated for rotate/detach
@@ -2251,8 +2255,15 @@ func (s *Server) audit(acct *Account, app, addr, action, detail string) {
// it, because rewriting a different id there would re-home every platform
// domain the account owns. It also covers the accounts that predate this
// feature (the built-in ops account, and any minted while the bucket was
-// unreachable). Best-effort: failures are logged, never fail the page.
+// unreachable). Best-effort: failures are logged, never fail the page — which
+// a bucketless console (s.cfg.Releases == nil) is the extreme case of: there
+// is nothing to read OR backfill, so every appPage view on such a console
+// panicked here until #82 Task 5's Settings-without-a-bucket coverage caught
+// it; nil is now just another "nothing to do" the caller never sees.
func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) {
+ if s.cfg.Releases == nil {
+ return
+ }
key := AccountManifestKey(acct.Sqid)
_, _, err := s.cfg.Releases.Get(ctx, key)
if err == nil {
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index a6db42984..adff2cacf 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -28,33 +28,53 @@ bucket keys and stays.</p>
{{end}}
<h2>Domains &amp; TLS</h2>
-{{if .Detail.Domains}}
+{{if .Settings.Domains}}
<div class="tablewrap">
<table>
<tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr>
- {{range .Detail.Domains}}
+ {{range $i, $d := .Settings.Domains}}
<tr>
- <td><code>{{.Domain}}</code></td>
- <td>{{.Type}}</td>
- <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
- <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
+ <td><code>{{$d.Domain}}</code></td>
+ <td>{{$d.Type}}</td>
+ <td>{{if $d.DNS}}{{$d.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
+ <td>{{if $d.TLS}}{{$d.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
</tr>
- {{if .Guidance}}
- <tr><td colspan="4" class="small">{{.Guidance}}</td></tr>
+ {{if $d.Guidance}}
+ <tr><td colspan="4" class="small">{{$d.Guidance}}</td></tr>
+ {{end}}
+ {{if and $.IsOwner $d.Claimed}}
+ <tr><td colspan="4" class="small">
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/domains/detach" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$d.Domain}}">
+ <label class="visually-hidden" for="detach-confirm-{{$i}}">Type {{$d.Domain}} to confirm detaching it</label>
+ <input type="text" id="detach-confirm-{{$i}}" name="confirm"
+ placeholder="type {{$d.Domain}} to confirm" autocomplete="off" required>
+ <button type="submit" class="btn-sm btn-danger">Detach</button>
+ </form>
+ </td></tr>
{{end}}
{{end}}
</table>
</div>
{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
-<h3>Add a custom domain</h3>
-<p class="small">A custom domain is attached where the app runs — on its box,
-by an operator:</p>
-<pre><code>carlos add -host www.example.com -app {{.Detail.App}} -account {{.Account.Sqid}} \
- -channel stable ...</code></pre>
-<p class="small">Then point the domain's A record at this deployment's edge;
-the certificate is minted on the first request and renews on its own.
-Attaching a domain from this page, without the box, is on the platform's
-roadmap.</p>
+{{if .IsOwner}}
+<h3>Attach a custom domain</h3>
+{{if .Settings.OnlyPlatformDomain}}<p class="small">This app already answers
+at its platform domain; attaching adds addresses, it never removes one.</p>{{end}}
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/domains/attach" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="attach-host">Domain</label>
+ <input type="text" id="attach-host" name="host" placeholder="www.example.com" required>
+ {{if .Settings.NeedsChannel}}
+ <label class="visually-hidden" for="attach-channel">Channel</label>
+ <input type="text" id="attach-channel" name="channel" placeholder="channel (e.g. stable)">
+ {{end}}
+ <button type="submit" class="btn-sm">Attach domain</button>
+</form>
+<p class="small">Point the domain's A record at this deployment's edge;
+the certificate is minted on the first request and renews on its own.</p>
+{{end}}
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
diff --git a/internal/console/view.go b/internal/console/view.go
index a5dfbe5f0..1bb2eedb2 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -71,6 +71,32 @@ func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
type DomainView struct {
Domain, Type, DNS, TLS string
Guidance string
+ // Claimed is Settings-only (settingsDomains, appsettings.go): true when
+ // this row corresponds to a domain claim this app holds — routed or not
+ // — which is what the Settings page uses to decide whether a detach
+ // form belongs beside the row. A plain domainView row (no claim looked
+ // up) always carries the zero value.
+ Claimed bool
+}
+
+// domainGuidance is the A-record instruction a custom host that is not
+// pointing at this deployment yet gets shown — shared by domainView (a
+// routed custom host) and claimDomainView (a claim with no route yet), so
+// the two rows never drift into different wording for the same instruction.
+func domainGuidance(host string, edgeIPs []string) string {
+ target := "this deployment's edge IP"
+ if len(edgeIPs) > 0 {
+ target = strings.Join(edgeIPs, ", ")
+ }
+ return "Point an A record for " + host + " at " + target + ". Checks run every few minutes."
+}
+
+// claimDomainView folds a domain claim that has no colocated registry row
+// yet into its own Domains placeholder row — an alias claim (which never
+// gets a route row; see domainattach.go) or a first-route claim before the
+// provisioning reconciler has materialized its route.
+func claimDomainView(host string, edgeIPs []string) DomainView {
+ return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, edgeIPs)}
}
// domainView folds one registry route into its Domains row. edgeIPs is the
@@ -99,11 +125,7 @@ func domainView(rt registry.Route, edgeIPs []string) DomainView {
// Guidance only for custom + non-pointing: platform domains are ours to
// fix, and a pointing custom domain needs no pointing instructions.
if !rt.Platform && rt.DNSStatus != "pointing" {
- target := "this deployment's edge IP"
- if len(edgeIPs) > 0 {
- target = strings.Join(edgeIPs, ", ")
- }
- dv.Guidance = "Point an A record for " + rt.Host + " at " + target + ". Checks run every few minutes."
+ dv.Guidance = domainGuidance(rt.Host, edgeIPs)
}
return dv
}
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 5a5ad8404..f9193a80d 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -1718,6 +1718,114 @@ func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) {
}
}
+// TestReconcileAdoptsStaticStoryRoute tests the backfill golden path:
+// a declared static record adopts the story-shaped route that was
+// hand-added before records existed. This is issue #82 backfill path.
+func TestReconcileAdoptsStaticStoryRoute(t *testing.T) {
+ s := dirStore(t)
+ // Create a different account for story (id=3, to avoid collision with the tito account)
+ storyAcctSqid := writeAccountManifest(t, s, 3, "Story")
+ // Create an app manifest for story
+ if err := PutManifest(ctx, s, storyAcctSqid, "story", Manifest{
+ Schema: 1, App: "story", Domain: "carlosframework.com", Seed: []string{},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // Pre-Upsert the operator-added shape: a static route as it existed
+ // before records were written to the bucket
+ handAdded := registry.Route{
+ Host: "story.carlosframework.com", Kind: "static",
+ App: "story", Account: storyAcctSqid, Channel: "canary/live",
+ Status: "ready",
+ }
+ reg := regWith(t, handAdded)
+
+ // Write the matching declared record
+ rec := Record{
+ Schema: 1, Host: "story.carlosframework.com", App: "story",
+ Account: storyAcctSqid, Channel: "canary/live", Kind: "static",
+ CreatedAt: time.Now(),
+ }
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+
+ // Run Reconcile with an unconstrained region/pool (matches the static route)
+ cfg := ReconcileConfig{DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ // Assert the route is now Provisioned
+ got := *mustGet(t, reg, "story.carlosframework.com")
+ if !got.Provisioned {
+ t.Fatal("the matching static route was not adopted")
+ }
+
+ // Static routes should have no Socket or DBPath
+ if got.Socket != "" || got.DBPath != "" {
+ t.Errorf("a static route derived filesystem paths: socket %q db %q", got.Socket, got.DBPath)
+ }
+
+ // Static routes should not be hibernating
+ if got.Hibernate {
+ t.Error("a static route was armed for hibernation — it has no process to wake")
+ }
+}
+
+// Companion negative: a static record does NOT adopt an instance route.
+// The kind must match for adoption to happen.
+func TestReconcileRefusesAdoptInstanceRouteForStaticRecord(t *testing.T) {
+ s := dirStore(t)
+ // Create a different account for story (id=3)
+ storyAcctSqid := writeAccountManifest(t, s, 3, "Story")
+ // Create an app manifest for story
+ if err := PutManifest(ctx, s, storyAcctSqid, "story", Manifest{
+ Schema: 1, App: "story", Domain: "carlosframework.com", Seed: []string{},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // Pre-Upsert a route with Kind: "instance" (not "static")
+ handAdded := registry.Route{
+ Host: "story.carlosframework.com", Kind: "instance",
+ Socket: "/run/carlos/story.carlosframework.com.sock",
+ DBPath: "/data/story.carlosframework.com.db",
+ App: "story", Account: storyAcctSqid, Channel: "canary/live",
+ Status: "ready",
+ }
+ reg := regWith(t, handAdded)
+ before := *mustGet(t, reg, "story.carlosframework.com")
+
+ // Write the matching declared record WITH Kind: "static"
+ rec := Record{
+ Schema: 1, Host: "story.carlosframework.com", App: "story",
+ Account: storyAcctSqid, Channel: "canary/live", Kind: "static",
+ CreatedAt: time.Now(),
+ }
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+
+ // Run Reconcile
+ cfg := ReconcileConfig{DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ // Assert the route was NOT adopted (Provisioned still false)
+ after := *mustGet(t, reg, "story.carlosframework.com")
+ if after.Provisioned {
+ t.Error("a kind-mismatched route was adopted — removal authority granted over a route the record does not describe")
+ }
+
+ // Verify the route was not modified at all
+ if after != before {
+ t.Errorf("a mismatched route was modified: before %+v after %+v", before, after)
+ }
+}
+
// TestRouteForCarriesAddrsAndHealth is Task 3's mint-side proof: a record
// with an explicit multi-upstream Addrs set mints a route whose Addrs is the
// comma-joined string and whose Health carries a fully-materialized default