carlos / platform Public

console: Settings attaches domains itself — honesty text retired

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 399c8aba8b3195ad9dd6e9c57707f63cd3809c65 parent eb769e8
4 files changed, +174 −26
  • internal/console/appsettings.go +59 −4
  • internal/console/appsettings_test.go +51 −0
  • internal/console/templates/app_settings.html +37 −17
  • internal/console/view.go +27 −5
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index a77a78324..a337df1c7 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/domainclaim"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -58,6 +64,18 @@ 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
}
// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
@@ -108,6 +126,14 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
sv.CanRename = true
}
sv.Store = s.storeCard(ctx, acct, p.AppName)
+ sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains)
+ sv.OnlyPlatformDomain = len(sv.Domains) > 0
+ for _, d := range sv.Domains {
+ if !strings.HasPrefix(d.Type, "platform") {
+ sv.OnlyPlatformDomain = false
+ break
+ }
+ }
if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
@@ -167,6 +193,35 @@ 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 := domainclaim.ListForApp(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err)
+ return out
+ }
+ for _, c := range claims {
+ 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..081160a9c 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -969,3 +969,54 @@ 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)
+ }
+}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index a6db42984..71adfd3e9 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 not .Detail.Routes}}
+ <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..f87c01ffe 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 domainclaim 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
}