// 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" "log" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/domains" ) // 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() // "*." is an instance catch-all (domaincatchall.go). The owner // gate above is the person check: a session is always a person. if parent, ok := domains.ParseInstanceCatchallHost(host); ok { if channel != "" { s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "domains", "an instance catch-all takes no channel — each instance keeps its own", map[string]string{"domain": host}) return } ref := s.catchallAppReady(ctx, acct, app) if ref == nil { _, ref = s.attachInstanceCatchall(ctx, acct, app, parent, addr, "") } if ref != nil { s.renderDoorRefusal(w, r, ref) return } s.setFlash(w, "ok", "Attached "+domains.InstanceCatchallHost(parent)+" — each instance now also answers at