carlos / platform Public

console: browser attach/detach door on app Settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3a7f6c69683dc837b5ddad6170f328c0da5d44d1 parent 5d4bb5d
3 files changed, +275 −0
  • internal/console/appdomains.go +106 −0
  • internal/console/appdomains_test.go +165 −0
  • internal/console/server.go +4 −0
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
new file mode 100644
index 000000000..304828d61
--- /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.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.TrimSpace(r.FormValue("host"))
+ if 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 family's byte-identical
+// unknown-app answer, 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..702937843
--- /dev/null
+++ b/internal/console/appdomains_test.go
@@ -0,0 +1,165 @@
+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/domainclaim"
+)
+
+// 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 := domainclaim.Get(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)
+}
+
+// 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 := domainclaim.Get(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)
+}
+
+// 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 := domainclaim.Get(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/server.go b/internal/console/server.go
index fada08534..1e19ec082 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -387,6 +387,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