// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package web import ( "errors" "net/url" "os" "strings" ) const ( EnvEnabled = "DCNTRLIZED_WEB_ENABLED" EnvOrigins = "DCNTRLIZED_WEB_ORIGINS" EnvDir = "DCNTRLIZED_WEB_DIR" EnvCookieSecure = "COOKIE_SECURE" EnvAccessMaxAge = "DCNTRLIZED_WEB_ACCESS_MAX_AGE" EnvRefreshMaxAge = "DCNTRLIZED_WEB_REFRESH_MAX_AGE" DefaultAccessMaxAge = 900 DefaultRefreshMaxAge = 604800 ) // Enabled reports whether the browser surface must be mounted on the service's // single :8080 listener. Unset/unknown values default to on in dev/debug and // off in prod (opt-in). // // Contract rev 9: there is no separate browser port any more // (DCNTRLIZED_WEB_PORT is gone) — the SPA and /web/* ride the same listener as // the API, so this is purely a mount switch. func Enabled() bool { switch os.Getenv(EnvEnabled) { case "true": return true case "false": return false default: return devMode() } } // devMode reports the dev/debug selector directly from the environment. It no // longer depends on the shared tlsutil package: rev 9 removed the transport // security layer (and with it the reason to consult it here). func devMode() bool { return os.Getenv("DCNTRLIZED_DEBUG") == "true" || os.Getenv("DCNTRLIZED_DEV") == "true" } // ErrDisabled is returned by Mount when the browser surface is disabled. The // disabled state is enforced in code, not by caller convention, so a disabled // edge can never mount a route even if a caller forgets the `if web.Enabled()` // guard (S31 rewrite: there is no second socket to bind any more). var ErrDisabled = errors.New("web: browser edge disabled") // Validate fails closed when the browser edge is enabled outside dev/debug: an // Origin allowlist is mandatory (DNS-rebinding hardening) and session cookies // must be Secure (F3 — never rely on the compose default alone). The old "https // hub base" requirement is gone with the de-mTLS migration. func Validate() error { if !Enabled() || devMode() { return nil } if len(origins()) == 0 { return errors.New("web: DCNTRLIZED_WEB_ORIGINS is required when the browser edge is enabled outside dev/debug") } if !cookieSecure() { return errors.New("web: COOKIE_SECURE=true is required when the browser edge is enabled outside dev/debug") } return nil } func origins() []string { raw := os.Getenv(EnvOrigins) if strings.TrimSpace(raw) == "" { return nil } var out []string for _, part := range strings.Split(raw, ",") { if o := normalizeOrigin(part); o != "" { out = append(out, o) } } return out } // normalizeOrigin returns the canonical scheme://host form (lowercase, default // port stripped) or "" when the entry is unusable. func normalizeOrigin(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if !strings.Contains(raw, "://") { raw = "https://" + raw } u, err := url.Parse(raw) if err != nil || u.Host == "" || u.User != nil { return "" } scheme := strings.ToLower(u.Scheme) host := strings.ToLower(u.Host) if (scheme == "https" && strings.HasSuffix(host, ":443")) || (scheme == "http" && strings.HasSuffix(host, ":80")) { host = host[:strings.LastIndex(host, ":")] } return scheme + "://" + host }