Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/, an embedded placeholder bundle, the /web/* auth and action handlers, and the explicit browser-action allowlist. The SPA and the API share the one plaintext :8080 listener; there is no second port and no peer edge, so main.go also drops the mTLS listener and the identity mounts in the debug compose fall away. /_manifest announces the web block only while the SPA is mounted (honesty rule), always with the single service port. The DSNs move to the shared database's scram-sha-256 credentials, matching rev 9's removal of the PKI layer. Committed web/bun.lock because web/package.json and vite.config.ts are new in this commit. src/web/dist/index.html is a hand-written placeholder, not a generated bundle: it keeps bare `go build`/`go vet` working from a clean checkout, while /web/dist (the real build output) stays gitignored.
121 lines
3.8 KiB
Go
121 lines
3.8 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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
|
|
}
|