console: Settings attaches domains itself — honesty text retired
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+174
−26
internal/console/appsettings.go+59 −4internal/console/appsettings_test.go+51 −0internal/console/templates/app_settings.html+37 −17internal/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 & 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 |
| } |