carlos / platform Public

console: attach/detach core — alias claim or claim+declared record

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ee243ebec846cc4900acabacf07c60c6446eb6ad parent 7013bba
2 files changed, +662 −0
  • internal/console/domainattach.go +269 −0
  • internal/console/domainattach_test.go +393 −0
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
new file mode 100644
index 000000000..26a40d2de
--- /dev/null
+++ b/internal/console/domainattach.go
@@ -0,0 +1,269 @@
+// 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, 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 claim, and
+// detach or a retry clears it), never a live route whose host is
+// unclaimed. The record write failing therefore rolls the claim back
+// best-effort and refuses; the reverse 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.
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domainclaim"
+ "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 and a real
+// route is now materializing, which is the difference between "your domain
+// serves the app the moment DNS points" and "a new route is being created".
+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 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 app's sole shipped one.
+func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) {
+ // Policy first: shape and platform space are decided before any write,
+ // so a refused host never reaches a bucket key.
+ if msg := attachHostRefusal(host, s.cfg.AppsDomain); msg != "" {
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, msg}
+ }
+ route, 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 := domainclaim.Claim{
+ Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: route,
+ CreatedAt: time.Now().UTC(), CreatedBy: principal,
+ }
+
+ // --- alias attach: the app already has a route to serve from ---------
+ if route != "" {
+ if err := domainclaim.Create(ctx, s.cfg.Releases, claim); err != nil {
+ return attachResult{}, claimRefusal(host, err)
+ }
+ s.audit(acct, app, principal, "domain-attach", host+" → "+route)
+ s.bumpSerial(ctx)
+ return attachResult{Claim: claim}, 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)
+ if ref != nil {
+ return attachResult{}, ref
+ }
+ if err := domainclaim.Create(ctx, s.cfg.Releases, claim); err != nil {
+ return attachResult{}, claimRefusal(host, err)
+ }
+ rec := provision.Record{
+ Schema: 1, Host: host, App: app, Account: acct.Sqid,
+ Channel: channel, Kind: "static",
+ CreatedAt: claim.CreatedAt, CreatedBy: principal,
+ }
+ if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
+ // The claim landed and the record did not. Take the claim back so
+ // the host stays attachable — best effort, because the bucket that
+ // just refused a write may refuse this one too; a stranded claim is
+ // inert (nothing materializes from a claim alone) and its own owner
+ // can clear it with detach.
+ log.Printf("console: attach %s: record write: %v", host, err)
+ if derr := domainclaim.Delete(ctx, s.cfg.Releases, host); derr != nil {
+ log.Printf("console: attach %s: claim rollback: %v (claim is stranded — detach clears it)", host, derr)
+ }
+ return attachResult{}, &doorRefusal{http.StatusInternalServerError,
+ "could not declare the route for this domain — nothing was attached, try again"}
+ }
+ s.audit(acct, app, principal, "domain-attach", host+" (first route, "+channel+")")
+ s.bumpSerial(ctx)
+ return attachResult{Claim: claim, 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 {
+ 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.
+ if !provision.ValidHostShape(host) {
+ return notAttached
+ }
+ claim, _, err := domainclaim.Get(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"}
+ }
+ }
+ if err := domainclaim.Delete(ctx, s.cfg.Releases, host); 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"}
+ }
+ 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, and it is skipped as a candidate:
+// a claim whose route names its own host is a self-alias, which is not a
+// thing the box side can resolve. That case is real — an operator-added
+// custom-domain route (story.carlosframework.com, spec §7) that no claim
+// covers yet — and skipping it 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. That is
+// the backfill, arriving through the member's own door.
+func (s *Server) readyRouteHost(sqid, app, attaching string) (string, 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" || rt.Host == attaching {
+ continue
+ }
+ return rt.Host, nil
+ }
+ return "", nil
+}
+
+// attachChannel resolves the channel a first-route record follows: the flag
+// when given, otherwise 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.
+func (s *Server) attachChannel(ctx context.Context, sqid, app, channelFlag string) (string, *doorRefusal) {
+ if channelFlag != "" {
+ return channelFlag, 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"}
+}
+
+// claimRefusal maps a lost claim create onto the door's answer. The 409 is
+// ONE string whoever holds the host — including the caller's own account:
+// the message a prober gets may never depend on who the holder is. Seeing
+// your own claims is what the GET is for.
+func claimRefusal(host string, err error) *doorRefusal {
+ if errors.Is(err, release.ErrCASConflict) {
+ return &doorRefusal{http.StatusConflict, "host 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..57b12a1d1
--- /dev/null
+++ b/internal/console/domainattach_test.go
@@ -0,0 +1,393 @@
+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/domainclaim"
+ "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 string
+}
+
+func (o *opStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if o.failPut != "" && key == o.failPut {
+ 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
+}
+
+// claimAbsent asserts no claim exists for host — the "nothing was written"
+// half of every refusal test.
+func claimAbsent(t *testing.T, fx *consoleFixture, host string) {
+ t.Helper()
+ _, _, err := domainclaim.Get(context.Background(), fx.rel, host)
+ if !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("claim for %s: got %v, want a claim that was never written", host, err)
+ }
+}
+
+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"
+ 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 := domainclaim.Get(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)
+ }
+}
+
+// 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)
+ }
+}
+
+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 := domainclaim.Get(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 " + domainclaim.Key(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)
+ }
+}
+
+// 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 "+domainclaim.Key(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"
+ held := func(t *testing.T, fx *consoleFixture, c domainclaim.Claim) {
+ t.Helper()
+ if err := domainclaim.Create(context.Background(), fx.rel, c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Run("another account", func(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ held(t, fx, domainclaim.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 != "host unavailable" {
+ t.Fatalf("refusal = %+v, want exactly 409 \"host unavailable\"", ref)
+ }
+ })
+ // The caller's OWN account holding it is the same refusal, byte for
+ // byte: the message may never be the thing that tells a prober whether
+ // they hold a host — GET is where you see your own claims.
+ t.Run("own account", func(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+ held(t, fx, domainclaim.Claim{Schema: 1, Host: host, Account: acct.Sqid, App: "story"})
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != 409 || ref.Msg != "host unavailable" {
+ t.Fatalf("refusal = %+v, want exactly 409 \"host unavailable\" for the caller's own claim too", ref)
+ }
+ })
+}
+
+func TestAttachPlatformSpaceRefused(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, "story", "v1")
+ seedChannel(t, fx, "story", "canary/live", "v1")
+
+ 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 " + domainclaim.Key(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)
+ }
+}
+
+func TestDetachForeignClaimIs404(t *testing.T) {
+ fx, acct := attachFixture(t)
+ const host = "story.carlosframework.com"
+ if err := domainclaim.Create(context.Background(), fx.rel,
+ domainclaim.Claim{Schema: 1, Host: host, Account: "zzz", App: "rival"}); err != nil {
+ t.Fatal(err)
+ }
+ 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 := domainclaim.Get(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"
+ if err := domainclaim.Create(context.Background(), fx.rel,
+ domainclaim.Claim{Schema: 1, Host: other, Account: acct.Sqid, App: "docs"}); err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+}