[feat](web): serve the SPA and web actions on the single listener

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.
This commit is contained in:
2026-10-19 09:28:59 +01:00
parent c58e193038
commit a791232f18
46 changed files with 5392 additions and 61 deletions
+120
View File
@@ -0,0 +1,120 @@
// 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
}