From a791232f1845bad4895b6381709cc996522c5732 Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Mon, 19 Oct 2026 09:28:59 +0100
Subject: [PATCH] [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.
---
.gitignore | 16 +
Dockerfile | 19 +-
docker-compose.debug.yaml | 29 +-
docker-compose.yaml | 12 +-
src/handlers/manifest/manifest.go | 57 ++-
src/handlers/manifest/manifest_test.go | 70 +++
src/handlers/web/action.go | 82 ++++
src/handlers/web/auth.go | 167 ++++++++
src/handlers/web/config.go | 120 ++++++
src/handlers/web/cookies.go | 62 +++
src/handlers/web/hub.go | 66 +++
src/handlers/web/json.go | 48 +++
src/handlers/web/listener.go | 84 ++++
src/handlers/web/middleware.go | 121 ++++++
src/handlers/web/spa.go | 144 +++++++
src/handlers/web/web_test.go | 254 +++++++++++
src/main.go | 117 +++--
src/web/dist/index.html | 19 +
src/web_assets.go | 21 +
web/.gitignore | 11 +
web/bun.lock | 377 ++++++++++++++++
web/index.html | 14 +
web/package.json | 22 +
web/src/components/Banner.tsx | 15 +
web/src/components/ConfirmDialog.tsx | 110 +++++
web/src/components/Shell.tsx | 180 ++++++++
web/src/components/Spinner.tsx | 12 +
web/src/components/TagInput.tsx | 85 ++++
web/src/index.css | 524 +++++++++++++++++++++++
web/src/lib/api.ts | 239 +++++++++++
web/src/lib/auth.ts | 110 +++++
web/src/lib/drafts.ts | 27 ++
web/src/lib/hooks.ts | 22 +
web/src/lib/relativeTime.ts | 48 +++
web/src/lib/storage.ts | 55 +++
web/src/lib/theme.ts | 28 ++
web/src/main.tsx | 140 ++++++
web/src/router.tsx | 112 +++++
web/src/screens/Editor.tsx | 566 +++++++++++++++++++++++++
web/src/screens/Login.tsx | 266 ++++++++++++
web/src/screens/NotesList.tsx | 282 ++++++++++++
web/src/screens/Settings.tsx | 188 ++++++++
web/src/state/store.ts | 435 +++++++++++++++++++
web/src/types.ts | 43 ++
web/tsconfig.json | 19 +
web/vite.config.ts | 15 +
46 files changed, 5392 insertions(+), 61 deletions(-)
create mode 100644 src/handlers/manifest/manifest_test.go
create mode 100644 src/handlers/web/action.go
create mode 100644 src/handlers/web/auth.go
create mode 100644 src/handlers/web/config.go
create mode 100644 src/handlers/web/cookies.go
create mode 100644 src/handlers/web/hub.go
create mode 100644 src/handlers/web/json.go
create mode 100644 src/handlers/web/listener.go
create mode 100644 src/handlers/web/middleware.go
create mode 100644 src/handlers/web/spa.go
create mode 100644 src/handlers/web/web_test.go
create mode 100644 src/web/dist/index.html
create mode 100644 src/web_assets.go
create mode 100644 web/.gitignore
create mode 100644 web/bun.lock
create mode 100644 web/index.html
create mode 100644 web/package.json
create mode 100644 web/src/components/Banner.tsx
create mode 100644 web/src/components/ConfirmDialog.tsx
create mode 100644 web/src/components/Shell.tsx
create mode 100644 web/src/components/Spinner.tsx
create mode 100644 web/src/components/TagInput.tsx
create mode 100644 web/src/index.css
create mode 100644 web/src/lib/api.ts
create mode 100644 web/src/lib/auth.ts
create mode 100644 web/src/lib/drafts.ts
create mode 100644 web/src/lib/hooks.ts
create mode 100644 web/src/lib/relativeTime.ts
create mode 100644 web/src/lib/storage.ts
create mode 100644 web/src/lib/theme.ts
create mode 100644 web/src/main.tsx
create mode 100644 web/src/router.tsx
create mode 100644 web/src/screens/Editor.tsx
create mode 100644 web/src/screens/Login.tsx
create mode 100644 web/src/screens/NotesList.tsx
create mode 100644 web/src/screens/Settings.tsx
create mode 100644 web/src/state/store.ts
create mode 100644 web/src/types.ts
create mode 100644 web/tsconfig.json
create mode 100644 web/vite.config.ts
diff --git a/.gitignore b/.gitignore
index df5d535..857402c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,19 @@
src/tmp/
src/src/
src/logs/
+
+# Frontend build output and dependencies (the committed placeholder at
+# src/web/dist/index.html is deliberately NOT ignored — it keeps bare
+# `go build`/`go vet` working from a clean checkout). Patterns are anchored to
+# the repo root so `src/web/dist/` stays tracked.
+/web/dist/
+/web/node_modules/
+/web/.bun/
+/web/.buntmp/
+
+# Go build cache (sandbox: default GOCACHE is read-only)
+/.gocache/
+/.gotmp/
+/.docker-config/
+/.vision/
+/.vision/
diff --git a/Dockerfile b/Dockerfile
index acf3d6a..62b362d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,13 @@
+# --- Web build stage (browser-edge SPA, contract §10.2) ---
+# Produces /web/dist, copied into the Go build context as web/dist so the
+# //go:embed all:web/dist directive in src/web_assets.go picks it up.
+FROM oven/bun:1.3.9-alpine AS web-builder
+WORKDIR /web
+COPY web/package.json web/bun.lock ./
+RUN bun ci
+COPY web/ ./
+RUN bun run build
+
# --- Build Stage ---
FROM golang:1.26 AS builder
WORKDIR /app
@@ -13,8 +23,11 @@ COPY src/go.mod src/go.sum ./
RUN go mod download
COPY src/ ./
+COPY --from=web-builder /web/dist ./web/dist
-RUN CGO_ENABLED=0 GOOS=linux go build -o server main.go
+# Build the whole main package (not just main.go): the browser-edge SPA embed
+# lives in web_assets.go, so a single-file build would not see embeddedWeb.
+RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# --- Run Stage ---
FROM alpine:latest
@@ -30,4 +43,8 @@ LABEL org.opencontainers.image.revision=$IMAGE_REVISION
COPY --from=builder /app/server .
+# Contract rev 9 §3.3: exactly ONE plaintext listener (:8080) serves the API
+# and the embedded browser web app (SPA + /web/*). No second port, no PKI.
+EXPOSE 8080
+
CMD ./server
diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml
index 32eef13..4a7256c 100644
--- a/docker-compose.debug.yaml
+++ b/docker-compose.debug.yaml
@@ -44,12 +44,10 @@ services:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- /app/src/tmp # Exclude Air's tmp dir from syncing
- # mTLS identity for the auth edge: the certs Maestro issued for the dev
- # fleet (devboot, DCNTRLIZED_DEV=true) at the shared-module default paths,
- # so the authproxy client authenticates to the mTLS auth hub.
- - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/ca.crt:/dcntrlized/pki/ca.crt:ro
- - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.crt:/dcntrlized/pki/tls.crt:ro
- - ${DCNTRLIZED_DATA_DIR:-$HOME/.dcntrlized}/pki/services/keepnote/tls.key:/dcntrlized/pki/tls.key:ro
+ # Browser-edge SPA overlay (contract §10.3): host `bun run build` output is
+ # served without a Go rebuild; falls back to the embedded placeholder when
+ # absent. DCNTRLIZED_WEB_DIR is honoured only in dev/debug mode.
+ - ./web/dist:/web-dist:ro
working_dir: /app/
@@ -57,17 +55,26 @@ services:
environment:
# DCNTRLIZED_DEV is the single dev selector (registry/auth URLs).
- DCNTRLIZED_DEV=true
- # Security theme M2 §2: keep THIS listener plaintext so the RN client
- # (direct mode) and hot reload work without certs. The auth proxy edge
- # is still mTLS (mounted identity above).
+ # Contract rev 9: internal traffic is plaintext, so the debug selector no
+ # longer gates any listener — this listener is ALWAYS plaintext now.
- DCNTRLIZED_DEBUG=true
+ # Browser edge (contract rev 9 §17.2): the SPA and /web/* ride the single
+ # :8080 port, served from the host-built overlay; dev is HTTP so
+ # COOKIE_SECURE=false.
+ - DCNTRLIZED_WEB_ENABLED=true
+ - DCNTRLIZED_WEB_DIR=/web-dist
+ - COOKIE_SECURE=false
+ # Hub base URL for the plaintext internal hop (rev 9: no mTLS identity).
+ # Overridable so a throwaway hub can be exercised without touching the
+ # shared dev stack.
+ - AUTH_SERVICE_URL=${AUTH_SERVICE_URL:-http://dcntrlized-auth:8080}
# Application configuration
# - PORT=8080
# - ENVIRONMENT=development
# - LOG_LEVEL=debug
# Database connection (shared database on the dcntrlized network)
- - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized
+ - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
# Go specific settings
- GO111MODULE=on
@@ -102,7 +109,7 @@ services:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- - PGPASSWORD=
+ - PGPASSWORD=dcntrlized
- PGDATABASE=dcntrlized
command: >
sh -c 'until pg_isready -h dcntrlized-db -U postgres -d dcntrlized; do sleep 2; done;
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 0e105ae..f0790f7 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -9,9 +9,17 @@ services:
ports:
- "8081:8080"
- "9090:90"
+ # Contract rev 9 §3.4: ONE plaintext listener, published as 8081:8080. The
+ # browser web app (SPA + /web/*) rides the same port — there is no separate
+ # browser port. Opt-in in prod via DCNTRLIZED_WEB_ENABLED=true;
+ # web.Validate() then fails closed unless DCNTRLIZED_WEB_ORIGINS is
+ # non-empty and COOKIE_SECURE=true.
environment:
- - DATABASE_URL=postgres://postgres:@dcntrlized-db:5432/dcntrlized
+ - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable
- AUTH_SERVICE_URL=http://dcntrlized-auth:8080
+ - DCNTRLIZED_WEB_ENABLED=${DCNTRLIZED_WEB_ENABLED:-false}
+ - DCNTRLIZED_WEB_ORIGINS=${DCNTRLIZED_WEB_ORIGINS:-}
+ - COOKIE_SECURE=${COOKIE_SECURE:-true}
depends_on:
- migrate
build:
@@ -31,7 +39,7 @@ services:
- PGHOST=dcntrlized-db
- PGPORT=5432
- PGUSER=postgres
- - PGPASSWORD=
+ - PGPASSWORD=dcntrlized
- PGDATABASE=dcntrlized
command: >
sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done;
diff --git a/src/handlers/manifest/manifest.go b/src/handlers/manifest/manifest.go
index 1588375..5b261c6 100644
--- a/src/handlers/manifest/manifest.go
+++ b/src/handlers/manifest/manifest.go
@@ -2,17 +2,62 @@ package manifest
import (
_ "embed"
+ "encoding/json"
"net/http"
"git.dcntrlized.org/Dcntrlized/KeepNote/logger"
)
//go:embed manifest.json
-var manifest string
+var rawManifest string
-func ManifestHandler(w http.ResponseWriter, r *http.Request) {
- logger.Debug("Sending manifest file")
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(manifest))
+// base is the embedded manifest parsed once. The handler merges the dynamic
+// `web` capability block onto it per request.
+var base = mustParse(rawManifest)
+
+func mustParse(raw string) map[string]json.RawMessage {
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ // The manifest is an embedded build asset: a malformed one is a
+ // build-time defect, not a runtime condition to paper over.
+ panic("manifest: embedded manifest.json is not a JSON object: " + err.Error())
+ }
+ return m
+}
+
+// WebBlock is the optional top-level Web App capability (contract rev 8.2/9).
+// It is announced ONLY when the service actually serves the browser web app
+// (honesty rule, fail-closed) and carries the shared single service port.
+type WebBlock struct {
+ Enabled bool `json:"enabled"`
+ Port int `json:"port"`
+ URL string `json:"url"`
+}
+
+// Handler serves /_manifest.
+//
+// The `web` block is computed per request from the live listener state:
+// enabled reports whether the browser surface is mounted, and port is the
+// service's single listener port (the SPA shares it with the API in rev 9, so
+// there is no separate DCNTRLIZED_WEB_PORT). When enabled() is false the block
+// is omitted entirely — a disabled listener is never announced.
+func Handler(enabled func() bool, port int) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ logger.Debug("Sending manifest file")
+ out := make(map[string]json.RawMessage, len(base)+1)
+ for k, v := range base {
+ out[k] = v
+ }
+ if enabled() {
+ block, err := json.Marshal(WebBlock{Enabled: true, Port: port})
+ if err != nil {
+ http.Error(w, "manifest encoding failed", http.StatusInternalServerError)
+ return
+ }
+ out["web"] = block
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(out)
+ }
}
diff --git a/src/handlers/manifest/manifest_test.go b/src/handlers/manifest/manifest_test.go
new file mode 100644
index 0000000..c187a05
--- /dev/null
+++ b/src/handlers/manifest/manifest_test.go
@@ -0,0 +1,70 @@
+package manifest
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// Contract rev 8.2/9: the `web` block is announced ONLY when the browser
+// surface is actually mounted, and it carries the shared single service port.
+func TestManifestWebBlockHonestyRule(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ enabled bool
+ want bool
+ }{
+ {"enabled", true, true},
+ {"disabled", false, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Handler(func() bool { return tc.enabled }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if got := rec.Header().Get("Content-Type"); got != "application/json" {
+ t.Fatalf("Content-Type = %q, want application/json", got)
+ }
+ var body map[string]json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("manifest is not a JSON object: %v (%s)", err, rec.Body.String())
+ }
+ raw, ok := body["web"]
+ if ok != tc.want {
+ t.Fatalf("web block present = %v, want %v", ok, tc.want)
+ }
+ if !tc.want {
+ return
+ }
+ var block WebBlock
+ if err := json.Unmarshal(raw, &block); err != nil {
+ t.Fatalf("web block: %v", err)
+ }
+ if !block.Enabled {
+ t.Fatalf("web.enabled = false, want true")
+ }
+ if block.Port != 8080 {
+ t.Fatalf("web.port = %d, want 8080 (the single service port)", block.Port)
+ }
+ })
+ }
+}
+
+// The dynamic block is merged onto the embedded manifest without dropping any
+// static capability.
+func TestManifestKeepsStaticFields(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Handler(func() bool { return true }, 8080)(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil))
+
+ var body map[string]json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"name", "has_auth", "capabilities"} {
+ if _, ok := body[key]; !ok {
+ t.Fatalf("static field %q missing from the served manifest", key)
+ }
+ }
+}
diff --git a/src/handlers/web/action.go b/src/handlers/web/action.go
new file mode 100644
index 0000000..1ca6127
--- /dev/null
+++ b/src/handlers/web/action.go
@@ -0,0 +1,82 @@
+// 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 (
+ "net/http"
+ "strings"
+)
+
+// ActionFunc mirrors handlers/actions.ActionActionName.
+type ActionFunc func(w http.ResponseWriter, r *http.Request, params map[string]string)
+
+// Dispatch mirrors the /action/ method routing. Wire it to the same
+// functions main.go mounts on the API listener.
+type Dispatch struct {
+ Get ActionFunc
+ Post ActionFunc
+ Put ActionFunc
+ Delete ActionFunc
+ // Allowed is the explicit per-service browser-action allowlist (contract
+ // §4.4). It is a name set, NOT a denylist: anything absent -> 404, so a
+ // newly added action stays closed on the browser edge until it is listed.
+ // The auth family is absent by construction.
+ Allowed map[string]bool
+}
+
+// maxActionBody caps /web/action/* request bodies. 4 MiB is above the largest
+// documented action payload (Calendar importIcs: 1 MiB decoded ~ 1.4 MiB base64).
+const maxActionBody = 4 << 20
+
+func ActionHandler(d Dispatch) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/web/action/")
+ if name == "" || strings.Contains(name, "/") {
+ writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
+ return
+ }
+ if !d.Allowed[name] { // explicit allowlist (RC2); no name, no route
+ writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
+ return
+ }
+ if _, ok := principal(w, r); !ok {
+ return
+ }
+ r2 := r.Clone(r.Context())
+ r2.Header = r.Header.Clone()
+ r2.Header.Del("Cookie")
+ if c, err := r.Cookie(accessCookie); err == nil {
+ r2.Header.Set("X-Access-Token", c.Value) // overwrite any browser value
+ }
+ if r.Body != nil { // RC7: bounded memory; decode failures stay 400s
+ r2.Body = http.MaxBytesReader(w, r.Body, maxActionBody)
+ }
+ params := map[string]string{"actionName": name}
+ switch r.Method {
+ case http.MethodGet:
+ d.Get(w, r2, params)
+ case http.MethodPost:
+ d.Post(w, r2, params)
+ case http.MethodPut:
+ d.Put(w, r2, params)
+ case http.MethodDelete:
+ d.Delete(w, r2, params)
+ default:
+ w.Header().Set("Allow", "GET, POST, PUT, DELETE")
+ writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
+ }
+ }
+}
diff --git a/src/handlers/web/auth.go b/src/handlers/web/auth.go
new file mode 100644
index 0000000..1d08ce3
--- /dev/null
+++ b/src/handlers/web/auth.go
@@ -0,0 +1,167 @@
+// 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 (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+
+ "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
+)
+
+const maxAuthBody = 8 << 10 // 8 KiB
+
+type hubUser struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+}
+
+type hubAuthResponse struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ User hubUser `json:"user"`
+}
+
+func userResponse(u hubUser) map[string]any { return map[string]any{"user": u} }
+
+func LoginHandler() http.HandlerFunc { return authProxyHandler("login") }
+func RegisterHandler() http.HandlerFunc { return authProxyHandler("register") }
+
+func authProxyHandler(path string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodPost) {
+ return
+ }
+ raw, err := io.ReadAll(io.LimitReader(r.Body, maxAuthBody))
+ if err != nil || !json.Valid(raw) {
+ writeCode(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
+ return
+ }
+ status, body, err := callHub(r.Context(), http.MethodPost, path, json.RawMessage(raw))
+ if err != nil {
+ writeCode(w, http.StatusBadGateway, "ERR_INTERNAL")
+ return
+ }
+ if status != http.StatusOK {
+ relay(w, status, body)
+ return
+ }
+ var ar hubAuthResponse
+ if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" {
+ writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL")
+ return
+ }
+ setSessionCookies(w, ar.AccessToken, ar.RefreshToken)
+ writeJSON(w, http.StatusOK, userResponse(ar.User))
+ }
+}
+
+func RefreshHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodPost) {
+ return
+ }
+ c, err := r.Cookie(refreshCookie)
+ if err != nil || c.Value == "" {
+ clearSessionCookies(w)
+ writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
+ return
+ }
+ status, body, err := callHub(r.Context(), http.MethodPost, "refresh",
+ map[string]string{"refreshToken": c.Value})
+ if err != nil {
+ writeCode(w, http.StatusBadGateway, "ERR_INTERNAL")
+ return
+ }
+ if status != http.StatusOK {
+ clearSessionCookies(w)
+ relay(w, status, body)
+ return
+ }
+ var ar hubAuthResponse
+ if err := json.Unmarshal(body, &ar); err != nil || ar.AccessToken == "" || ar.RefreshToken == "" {
+ clearSessionCookies(w)
+ writeCode(w, http.StatusInternalServerError, "ERR_INTERNAL")
+ return
+ }
+ setSessionCookies(w, ar.AccessToken, ar.RefreshToken)
+ writeJSON(w, http.StatusOK, userResponse(ar.User))
+ }
+}
+
+func LogoutHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodPost) {
+ return
+ }
+ if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
+ _, _, _ = callHub(r.Context(), http.MethodPost, "logout",
+ map[string]string{"refreshToken": c.Value})
+ }
+ clearSessionCookies(w)
+ writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"})
+ }
+}
+
+func MeHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodGet) {
+ return
+ }
+ p, ok := principal(w, r)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, userResponse(hubUser{
+ ID: p.ID.String(), Username: p.Username, Email: p.Email,
+ }))
+ }
+}
+
+// principal validates the access cookie with the shared authclient path
+// (RS256 + iss + exp + type=access). It writes the uniform 401 itself.
+func principal(w http.ResponseWriter, r *http.Request) (*authclient.Principal, bool) {
+ c, err := r.Cookie(accessCookie)
+ if err != nil || c.Value == "" {
+ writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
+ return nil, false
+ }
+ // Cheap syntactic gate first: a malformed value must never reach
+ // authclient's lazy hub key-fetch path (which can cost a 10s outbound
+ // attempt when the hub is down). This is NOT verification.
+ if strings.Count(c.Value, ".") != 2 {
+ writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
+ return nil, false
+ }
+ clone := r.Clone(r.Context())
+ clone.Header = r.Header.Clone()
+ clone.Header.Set("X-Access-Token", c.Value)
+ p, err := authclient.Authorize(clone)
+ if err != nil {
+ writeCode(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
+ return nil, false
+ }
+ return p, true
+}
+
+func relay(w http.ResponseWriter, status int, body []byte) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write(body)
+}
diff --git a/src/handlers/web/config.go b/src/handlers/web/config.go
new file mode 100644
index 0000000..6efef8b
--- /dev/null
+++ b/src/handlers/web/config.go
@@ -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 .
+
+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
+}
diff --git a/src/handlers/web/cookies.go b/src/handlers/web/cookies.go
new file mode 100644
index 0000000..0beae89
--- /dev/null
+++ b/src/handlers/web/cookies.go
@@ -0,0 +1,62 @@
+// 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 (
+ "net/http"
+ "os"
+ "strconv"
+)
+
+const (
+ accessCookie = "dcntrlized_access"
+ refreshCookie = "dcntrlized_refresh"
+ cookiePath = "/web"
+)
+
+func cookieSecure() bool { return os.Getenv(EnvCookieSecure) == "true" }
+
+func maxAge(env string, def int) int {
+ if v := os.Getenv(env); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n >= 0 {
+ return n
+ }
+ }
+ return def
+}
+
+func sessionCookie(name, value string, age int) *http.Cookie {
+ return &http.Cookie{
+ Name: name, Value: value, Path: cookiePath,
+ HttpOnly: true, SameSite: http.SameSiteLaxMode,
+ Secure: cookieSecure(), MaxAge: age,
+ }
+}
+
+func setSessionCookies(w http.ResponseWriter, access, refresh string) {
+ http.SetCookie(w, sessionCookie(accessCookie, access, maxAge(EnvAccessMaxAge, DefaultAccessMaxAge)))
+ http.SetCookie(w, sessionCookie(refreshCookie, refresh, maxAge(EnvRefreshMaxAge, DefaultRefreshMaxAge)))
+}
+
+func clearSessionCookies(w http.ResponseWriter) {
+ for _, name := range []string{accessCookie, refreshCookie} {
+ http.SetCookie(w, &http.Cookie{
+ Name: name, Value: "", Path: cookiePath,
+ HttpOnly: true, SameSite: http.SameSiteLaxMode,
+ Secure: cookieSecure(), MaxAge: -1,
+ })
+ }
+}
diff --git a/src/handlers/web/hub.go b/src/handlers/web/hub.go
new file mode 100644
index 0000000..a0a7a94
--- /dev/null
+++ b/src/handlers/web/hub.go
@@ -0,0 +1,66 @@
+// 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 (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
+)
+
+const maxHubBody = 1 << 20 // 1 MiB
+
+// hubClient is the outbound hub transport. Contract rev 9 removed the mTLS
+// identity layer fleet-wide, so this is a plain HTTP client — no client
+// certificate and no custom TLS configuration. The timeout bounds the whole
+// hub exchange so a stalled hub cannot pin a browser request.
+var hubClient = &http.Client{Timeout: 10 * time.Second}
+
+// callHub performs one fixed hub /auth/* call over the plaintext internal
+// network. It deliberately does NOT use authproxy.Proxy: that helper copies
+// every inbound header (including Cookie and X-Access-Token) to the hub, which
+// is forbidden on the browser edge.
+func callHub(ctx context.Context, method, path string, payload any) (int, []byte, error) {
+ var body io.Reader
+ if payload != nil {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return 0, nil, err
+ }
+ body = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, authproxy.Target()+"/auth/"+path, body)
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ resp, err := hubClient.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer resp.Body.Close()
+ out, err := io.ReadAll(io.LimitReader(resp.Body, maxHubBody))
+ if err != nil {
+ return 0, nil, err
+ }
+ return resp.StatusCode, out, nil
+}
diff --git a/src/handlers/web/json.go b/src/handlers/web/json.go
new file mode 100644
index 0000000..af101b0
--- /dev/null
+++ b/src/handlers/web/json.go
@@ -0,0 +1,48 @@
+// 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 (
+ "encoding/json"
+ "net/http"
+)
+
+type errorBody struct {
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeCode(w http.ResponseWriter, status int, code string) {
+ var b errorBody
+ b.Error.Code = code
+ writeJSON(w, status, b)
+}
+
+func requireMethod(w http.ResponseWriter, r *http.Request, method string) bool {
+ if r.Method != method {
+ w.Header().Set("Allow", method)
+ writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
+ return false
+ }
+ return true
+}
diff --git a/src/handlers/web/listener.go b/src/handlers/web/listener.go
new file mode 100644
index 0000000..a7e071a
--- /dev/null
+++ b/src/handlers/web/listener.go
@@ -0,0 +1,84 @@
+// 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 (
+ "io/fs"
+ "log"
+ "net/http"
+)
+
+// Config wires the browser surface.
+type Config struct {
+ Embedded fs.FS
+ Dispatch Dispatch
+ Logger Logger
+}
+
+// NewMux builds the exact browser-edge surface. Nothing else is mounted.
+func NewMux(cfg Config) (*http.ServeMux, error) {
+ spa, err := NewSPA(cfg.Embedded)
+ if err != nil {
+ return nil, err
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/web/auth/login", LoginHandler())
+ mux.HandleFunc("/web/auth/register", RegisterHandler())
+ mux.HandleFunc("/web/auth/refresh", RefreshHandler())
+ mux.HandleFunc("/web/auth/logout", LogoutHandler())
+ mux.HandleFunc("/web/auth/me", MeHandler())
+ mux.HandleFunc("/web/action/", ActionHandler(cfg.Dispatch))
+ mux.Handle("/", spa)
+ return mux, nil
+}
+
+// Mount registers the browser surface on the service's single listener mux.
+//
+// Contract rev 9: there is no dedicated browser port and no second socket. The
+// SPA and /web/* ride the same :8080 listener as the API, so this only wires
+// routes — the caller owns the http.Server and its RC17 timeouts.
+//
+// The registered chain is AccessLog(APIHeaders(CSRF(mux))). It deliberately
+// does NOT include LoggingMiddleware (which logs body=%q, including login
+// bodies) or CorsMiddleware (which echoes any Origin and allows
+// X-Access-Token) — S48.
+//
+// Only "/" is registered: the browser mux's own exact /web/auth/* routes win
+// over its SPA fallback, and the API mux's "/auth/", "/action/" and
+// "/_manifest" patterns are more specific than "/" so they keep routing to the
+// API chain. That also preserves the reserved-prefix behaviour for the bare
+// "/web" path (JSON 404 from the SPA, not a redirect).
+//
+// Mount returns ErrDisabled when the edge is off, so a caller can never mount a
+// dead app by accident (S31).
+func Mount(outer *http.ServeMux, cfg Config) error {
+ if !Enabled() {
+ return ErrDisabled
+ }
+ if err := Validate(); err != nil {
+ return err
+ }
+ mux, err := NewMux(cfg)
+ if err != nil {
+ return err
+ }
+ logger := cfg.Logger
+ if logger == nil {
+ logger = log.Default()
+ }
+ outer.Handle("/", AccessLog(logger, APIHeaders(CSRF(mux))))
+ return nil
+}
diff --git a/src/handlers/web/middleware.go b/src/handlers/web/middleware.go
new file mode 100644
index 0000000..c96dfd1
--- /dev/null
+++ b/src/handlers/web/middleware.go
@@ -0,0 +1,121 @@
+// 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 (
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func mutating(method string) bool {
+ switch method {
+ case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
+ return true
+ }
+ return false
+}
+
+func originAllowed(r *http.Request) bool {
+ raw := r.Header.Get("Origin")
+ if raw == "" {
+ raw = r.Header.Get("Referer")
+ }
+ if raw == "" {
+ return false
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" || u.User != nil { // F5: no userinfo form
+ return false
+ }
+ allow := origins()
+ if len(allow) == 0 {
+ return strings.EqualFold(u.Host, r.Host)
+ }
+ got := normalizeOrigin(u.Scheme + "://" + u.Host)
+ for _, a := range allow {
+ if got == a {
+ return true
+ }
+ }
+ return false
+}
+
+// CSRF mirrors Maestro's ERR_CSRF contract (403 + {"error":{"code"}}) but is
+// stricter: the browser edge is browser-only, so a missing Origin/Referer is
+// refused rather than allowed.
+func CSRF(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if mutating(r.Method) && !originAllowed(r) {
+ writeCode(w, http.StatusForbidden, "ERR_CSRF")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// APIHeaders hardens every browser-API response (RC1). It is mounted around
+// the whole browser mux, so it also covers 401/403/404/405/413 error paths.
+// SPA responses are deliberately excluded (they keep their own headers and
+// must stay cacheable).
+func APIHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/web/") {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// Logger is satisfied by *log.Logger and by tiny service adapters.
+type Logger interface{ Printf(format string, v ...any) }
+
+// safeLogPath neutralises log-injection characters (F2). EscapedPath keeps the
+// original percent-encoding, so an encoded %0a/%0d can never become a real
+// newline; Quote makes any residual control character explicit and visible.
+func safeLogPath(r *http.Request) string {
+ p := r.URL.EscapedPath()
+ if p == "" {
+ p = "/"
+ }
+ if len(p) > 256 {
+ p = p[:256] + "..."
+ }
+ return strconv.Quote(p)
+}
+
+// AccessLog never logs headers, bodies or query strings.
+func AccessLog(logger Logger, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+ logger.Printf("web %s %s status=%d duration=%s", r.Method, safeLogPath(r), rec.status, time.Since(start))
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
diff --git a/src/handlers/web/spa.go b/src/handlers/web/spa.go
new file mode 100644
index 0000000..cbfa69e
--- /dev/null
+++ b/src/handlers/web/spa.go
@@ -0,0 +1,144 @@
+// 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 (
+ "io/fs"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+)
+
+var reserved = []string{"/web/", "/auth/", "/action/", "/config", "/_manifest", "/api/"}
+
+// isReserved reports whether p is, or is below, a reserved prefix. Matching is
+// exact or path-boundary only (RC18): /configx, /configuration and /_manifestx
+// are NOT reserved (they may be real SPA routes), while /config, /config/...,
+// /_manifest, /_manifest/..., /web, /web/..., /auth/..., /action/... and
+// /api/... are.
+func isReserved(p string) bool {
+ for _, pre := range reserved {
+ base := strings.TrimSuffix(pre, "/")
+ if p == base || strings.HasPrefix(p, base+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// reservedPath decides whether the request must be answered with the reserved
+// JSON 404 instead of the SPA shell. Go decodes the request path once, so a
+// double-encoded traversal such as /%252e%252e/config arrives as
+// /%2e%2e/config and only becomes /config after a second decode; checking up to
+// two extra decodes keeps that (and any undecodable encoding) fail-closed.
+func reservedPath(r *http.Request) bool {
+ if isReserved(path.Clean("/" + r.URL.Path)) {
+ return true
+ }
+ esc := r.URL.EscapedPath()
+ if esc == "" {
+ return false
+ }
+ for i := 0; i < 2; i++ {
+ dec, err := url.PathUnescape(esc)
+ if err != nil {
+ return true // undecodable encoding: never serve the shell
+ }
+ if isReserved(path.Clean("/" + dec)) {
+ return true
+ }
+ esc = dec
+ }
+ return false
+}
+
+// SPA serves the embedded bundle at web/dist, or the live directory named by
+// DCNTRLIZED_WEB_DIR when it exists. The disk overlay is a DEV-ONLY hot-reload
+// affordance (F4): outside devMode() it is ignored entirely, so the variable can
+// never make the browser listener serve arbitrary container files.
+type SPA struct{ fs http.FileSystem }
+
+func NewSPA(embedded fs.FS) (*SPA, error) {
+ if devMode() {
+ if dir := strings.TrimSpace(os.Getenv(EnvDir)); dir != "" {
+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
+ return &SPA{fs: http.Dir(dir)}, nil
+ }
+ }
+ }
+ sub, err := fs.Sub(embedded, "web/dist")
+ if err != nil {
+ return nil, err
+ }
+ return &SPA{fs: http.FS(sub)}, nil
+}
+
+func securityHeaders(w http.ResponseWriter) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "same-origin")
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
+ "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "+
+ "base-uri 'none'; form-action 'self'")
+}
+
+func (s *SPA) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
+ return
+ }
+ if reservedPath(r) {
+ writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
+ return
+ }
+ upath := path.Clean("/" + r.URL.Path)
+ securityHeaders(w)
+ if f, err := s.fs.Open(upath); err == nil {
+ st, serr := f.Stat()
+ if serr == nil && !st.IsDir() {
+ if strings.HasPrefix(upath, "/assets/") {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ http.ServeContent(w, r, path.Base(upath), st.ModTime(), f)
+ _ = f.Close()
+ return
+ }
+ _ = f.Close()
+ }
+ s.serveIndex(w, r)
+}
+
+func (s *SPA) serveIndex(w http.ResponseWriter, r *http.Request) {
+ f, err := s.fs.Open("/index.html")
+ if err != nil {
+ writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
+ return
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil || st.IsDir() {
+ writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND")
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ http.ServeContent(w, r, "index.html", st.ModTime(), f)
+}
diff --git a/src/handlers/web/web_test.go b/src/handlers/web/web_test.go
new file mode 100644
index 0000000..d853bb3
--- /dev/null
+++ b/src/handlers/web/web_test.go
@@ -0,0 +1,254 @@
+// 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/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+// prodEnv clears the dev selectors so the fail-closed prod branches run.
+func prodEnv(t *testing.T) {
+ t.Helper()
+ t.Setenv(EnvEnabled, "true")
+ t.Setenv("DCNTRLIZED_DEBUG", "")
+ t.Setenv("DCNTRLIZED_DEV", "")
+}
+
+// S31 (RC10/F1): Mount refuses to register the browser surface when the edge is
+// disabled, even if the caller forgot the `if web.Enabled()` guard. Rev 9 has no
+// second socket to bind — the guard is now a mount guard.
+func TestMountRefusesWhenDisabled(t *testing.T) {
+ t.Setenv(EnvEnabled, "false")
+ outer := http.NewServeMux()
+ err := Mount(outer, Config{Embedded: fstest.MapFS{}})
+ if !errors.Is(err, ErrDisabled) {
+ t.Fatalf("Mount error = %v, want ErrDisabled", err)
+ }
+ rec := httptest.NewRecorder()
+ outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/notes", nil))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("disabled edge served %d on /notes, want 404 (nothing mounted)", rec.Code)
+ }
+}
+
+// S23/RC6 + S33 (RC12/F3): outside dev/debug the edge requires an Origin
+// allowlist and COOKIE_SECURE=true. The old https-hub requirement is gone with
+// the fleet-wide de-mTLS migration (rev 9).
+func TestValidateProdFailClosed(t *testing.T) {
+ prodEnv(t)
+ t.Setenv(EnvOrigins, "")
+ t.Setenv(EnvCookieSecure, "")
+ if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvOrigins) {
+ t.Fatalf("Validate without origins = %v, want origins error", err)
+ }
+
+ t.Setenv(EnvOrigins, "https://notes.example.org")
+ t.Setenv(EnvCookieSecure, "false")
+ if err := Validate(); err == nil || !strings.Contains(err.Error(), EnvCookieSecure) {
+ t.Fatalf("Validate without COOKIE_SECURE = %v, want cookie error", err)
+ }
+
+ t.Setenv(EnvCookieSecure, "true")
+ if err := Validate(); err != nil {
+ t.Fatalf("Validate with full prod config = %v, want nil", err)
+ }
+}
+
+// S34 (RC13/F4): DCNTRLIZED_WEB_DIR is a dev-only overlay; in prod the
+// embedded FS is always used.
+func TestSPADirIsDevOnly(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("OVERLAY"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ embedded := fstest.MapFS{"web/dist/index.html": {Data: []byte("EMBEDDED")}}
+
+ prodEnv(t)
+ t.Setenv(EnvDir, dir)
+ spa, err := NewSPA(embedded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if body := getBody(t, spa, "/"); !strings.Contains(body, "EMBEDDED") || strings.Contains(body, "OVERLAY") {
+ t.Fatalf("prod SPA body = %q, want embedded content only", body)
+ }
+
+ t.Setenv("DCNTRLIZED_DEBUG", "true")
+ spa, err = NewSPA(embedded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if body := getBody(t, spa, "/"); !strings.Contains(body, "OVERLAY") {
+ t.Fatalf("debug SPA body = %q, want disk overlay", body)
+ }
+}
+
+// S28 (RC2): the action allowlist is fail-closed and is checked before the
+// session, so unknown and auth-family names are 404 even without a cookie.
+func TestActionAllowlistIsFailClosed(t *testing.T) {
+ reached := false
+ handler := ActionHandler(Dispatch{
+ Get: func(http.ResponseWriter, *http.Request, map[string]string) { reached = true },
+ Allowed: map[string]bool{"getNotes": true},
+ })
+ for _, name := range []string{"login", "register", "refresh", "logout", "delete", "me", "nope"} {
+ reached = false
+ rec := httptest.NewRecorder()
+ handler(rec, httptest.NewRequest(http.MethodGet, "/web/action/"+name, nil))
+ if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") {
+ t.Fatalf("action %q = %d %s, want 404 ERR_NOT_FOUND", name, rec.Code, rec.Body.String())
+ }
+ if reached {
+ t.Fatalf("action %q reached the dispatch despite the allowlist", name)
+ }
+ }
+}
+
+// S29 (RC1): every /web/* response carries no-store + nosniff, including
+// error paths produced before the handler runs.
+func TestAPIHeadersOnErrorPaths(t *testing.T) {
+ mux, err := NewMux(Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := APIHeaders(CSRF(mux))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}"))
+ req.Header.Set("Origin", "https://evil.example")
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("foreign-origin login = %d, want 403", rec.Code)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("Cache-Control = %q, want no-store", got)
+ }
+ if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
+ }
+}
+
+// S48 (rev 9): the browser chain never inherits CorsMiddleware. A mutating
+// browser request must not receive any Access-Control-* header, whatever the
+// Origin, and a foreign Origin must still be refused by CSRF.
+func TestBrowserChainHasNoCORS(t *testing.T) {
+ t.Setenv(EnvEnabled, "true")
+ t.Setenv("DCNTRLIZED_DEBUG", "true")
+ outer := http.NewServeMux()
+ if err := Mount(outer, Config{Embedded: fstest.MapFS{"web/dist/index.html": {Data: []byte("x")}}}); err != nil {
+ t.Fatal(err)
+ }
+ for _, origin := range []string{"https://evil.example", "http://localhost:8081"} {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/web/auth/login", strings.NewReader("{}"))
+ req.Header.Set("Origin", origin)
+ outer.ServeHTTP(rec, req)
+ for name := range rec.Header() {
+ if strings.HasPrefix(http.CanonicalHeaderKey(name), "Access-Control-") {
+ t.Fatalf("browser chain emitted %s for Origin %q (CorsMiddleware leaked)", name, origin)
+ }
+ }
+ }
+ // The SPA shell itself carries no CORS header either.
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/notes", nil)
+ req.Header.Set("Origin", "https://evil.example")
+ outer.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("SPA deep link = %d, want 200", rec.Code)
+ }
+ if h := rec.Header().Get("Access-Control-Allow-Origin"); h != "" {
+ t.Fatalf("SPA response echoed Origin %q", h)
+ }
+}
+
+// S32 (RC11/F2): log paths are quoted and control characters never survive.
+func TestSafeLogPathNoInjection(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/notes%0aforge", nil)
+ got := safeLogPath(req)
+ if strings.ContainsAny(got, "\n\r") {
+ t.Fatalf("safeLogPath = %q contains a raw control character", got)
+ }
+ if !strings.Contains(got, `%0a`) && !strings.Contains(got, `\n`) {
+ t.Fatalf("safeLogPath = %q, want the encoded newline preserved/quoted", got)
+ }
+ if long := safeLogPath(httptest.NewRequest(http.MethodGet, "/"+strings.Repeat("a", 400), nil)); len(long) > 300 {
+ t.Fatalf("safeLogPath did not cap length: %d", len(long))
+ }
+}
+
+// RC18: reserved matching is exact or path-boundary, and a double-encoded
+// traversal still lands on the reserved JSON 404.
+func TestReservedBoundaryAndDoubleEncoding(t *testing.T) {
+ reserved := []string{"/web", "/web/x", "/config", "/config/x", "/_manifest", "/api/v1"}
+ for _, p := range reserved {
+ if !isReserved(p) {
+ t.Fatalf("isReserved(%q) = false, want true", p)
+ }
+ }
+ allowed := []string{"/configx", "/configuration", "/_manifestx", "/webx", "/apiary", "/notes"}
+ for _, p := range allowed {
+ if isReserved(p) {
+ t.Fatalf("isReserved(%q) = true, want false (boundary matching)", p)
+ }
+ }
+ for _, raw := range []string{"/%252e%252e/config", "/%2e%2e/config"} {
+ req := httptest.NewRequest(http.MethodGet, raw, nil)
+ if !reservedPath(req) {
+ t.Fatalf("reservedPath(%q) = false, want true", raw)
+ }
+ }
+}
+
+// SPA fallback: an existing asset is served, an unknown route falls back to
+// index.html, and a reserved prefix never returns HTML.
+func TestSPAFallbackAndReserved(t *testing.T) {
+ embedded := fstest.MapFS{
+ "web/dist/index.html": {Data: []byte("SHELL")},
+ "web/dist/assets/app.js": {Data: []byte("JS")},
+ }
+ spa, err := NewSPA(embedded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if body := getBody(t, spa, "/notes/abc"); body != "SHELL" {
+ t.Fatalf("fallback body = %q, want SHELL", body)
+ }
+ if body := getBody(t, spa, "/assets/app.js"); body != "JS" {
+ t.Fatalf("asset body = %q, want JS", body)
+ }
+ rec := httptest.NewRecorder()
+ spa.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
+ if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "ERR_NOT_FOUND") {
+ t.Fatalf("/config on SPA = %d %s, want 404 JSON", rec.Code, rec.Body.String())
+ }
+}
+
+func getBody(t *testing.T, h http.Handler, target string) string {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", target, rec.Code)
+ }
+ return rec.Body.String()
+}
diff --git a/src/main.go b/src/main.go
index 8a9cfb1..0dda1d6 100644
--- a/src/main.go
+++ b/src/main.go
@@ -2,25 +2,36 @@ package main
import (
"context"
- "crypto/tls"
- "net"
+ "log"
"net/http"
"os"
"strings"
+ "time"
"git.dcntrlized.org/Dcntrlized/KeepNote/db"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/actions"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/auth"
"git.dcntrlized.org/Dcntrlized/KeepNote/handlers/manifest"
+ "git.dcntrlized.org/Dcntrlized/KeepNote/handlers/web"
"git.dcntrlized.org/Dcntrlized/KeepNote/logger"
"git.dcntrlized.org/Dcntrlized/KeepNote/middleware"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient"
"git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy"
- "git.dcntrlized.org/dcntrlized/dcntrlized-auth/tlsutil"
)
-func main() {
+// servicePort is the service's ONLY listening port (contract rev 9 §3.3). The
+// API and the browser web app (SPA + /web/*) share it; there is no second
+// listener, no DCNTRLIZED_WEB_PORT and no peer edge.
+const servicePort = "8080"
+// browserActions is the explicit browser-edge allowlist (contract §4.4).
+// KeepNote; Calendar uses its own §4.4 list. Unknown names -> 404.
+var browserActions = map[string]bool{
+ "getNotes": true, "getNote": true,
+ "writeNote": true, "updateNote": true, "deleteNote": true,
+}
+
+func main() {
db := db.NewDB()
defer db.Close()
@@ -29,24 +40,30 @@ func main() {
}
logger.Info("Server started")
- mux := http.NewServeMux()
- mux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
+ // The API surface. It is reached through the API chain
+ // (LoggingMiddleware(CorsMiddleware(apiMux))), never through the browser
+ // chain — the browser chain must not log bodies or echo Origins (S48).
+ apiMux := http.NewServeMux()
+
+ apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- manifest.ManifestHandler(w, r)
+ // Honesty rule (rev 8.2/9): the `web` block is announced only while the
+ // browser surface is actually mounted, on the shared single port.
+ manifest.Handler(web.Enabled, 8080)(w, r)
})
- mux.HandleFunc("/auth/login", auth.LoginHandler())
- mux.HandleFunc("/auth/register", auth.RegisterHandler())
- mux.HandleFunc("/auth/logout", auth.LogoutHandler())
- mux.HandleFunc("/auth/refresh", auth.RefreshHandler())
- mux.HandleFunc("/auth/me", auth.MeHandler())
- mux.HandleFunc("/auth/delete", auth.DeleteHandler())
+ apiMux.HandleFunc("/auth/login", auth.LoginHandler())
+ apiMux.HandleFunc("/auth/register", auth.RegisterHandler())
+ apiMux.HandleFunc("/auth/logout", auth.LogoutHandler())
+ apiMux.HandleFunc("/auth/refresh", auth.RefreshHandler())
+ apiMux.HandleFunc("/auth/me", auth.MeHandler())
+ apiMux.HandleFunc("/auth/delete", auth.DeleteHandler())
- mux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
+ apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/action/") {
http.NotFound(w, r)
return
@@ -72,38 +89,56 @@ func main() {
}
})
- handler := middleware.LoggingMiddleware(middleware.CorsMiddleware(mux))
+ // The single listener mux. Pattern precedence (Go 1.22 ServeMux) keeps the
+ // API prefixes off the browser chain:
+ //
+ // /_manifest, /auth/, /action/ -> apiChain
+ // / -> browser chain (SPA + /web/*)
+ outer := http.NewServeMux()
- // Security theme M2 §2: the service's listener requires a CA-issued client
- // cert (Maestro's identity) so only Maestro can drive actions (T4) and the
- // peer edge is encrypted. DCNTRLIZED_DEBUG=true keeps the debug fleet plaintext.
- tlsCfg, err := tlsutil.ServerConfig()
- if err != nil {
- logger.Error("TLS setup failed: %v", err)
- os.Exit(1)
- }
- if tlsCfg == nil {
- logger.Info("KeepNote listening plaintext on :8080 (DCNTRLIZED_DEBUG dev mode)")
- if err := http.ListenAndServe(":8080", handler); err != nil {
- logger.Error("HTTP server failed: %v", err)
+ apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux))
+ outer.Handle("/_manifest", apiChain)
+ outer.Handle("/auth/", apiChain)
+ outer.Handle("/action/", apiChain)
+
+ // Browser edge (contract rev 9 §17.2): the SPA and /web/* on the SAME
+ // :8080. DCNTRLIZED_WEB_ENABLED is the mount switch; disabled means no
+ // browser routes at all (there is no socket to bind any more).
+ if web.Enabled() {
+ if err := web.Mount(outer, web.Config{
+ Embedded: embeddedWeb,
+ Dispatch: web.Dispatch{
+ Get: actions.ActionActionNameGet,
+ Post: actions.ActionActionNamePost,
+ Put: actions.ActionActionNamePut,
+ Delete: actions.ActionActionNameDelete,
+ Allowed: browserActions,
+ },
+ Logger: log.New(os.Stdout, "web ", log.LstdFlags|log.LUTC),
+ }); err != nil {
+ logger.Error("browser edge mount failed: %v", err)
os.Exit(1)
}
- return
+ logger.Info("browser edge mounted on :%s (SPA + /web/*)", servicePort)
+ } else {
+ logger.Info("browser edge disabled (DCNTRLIZED_WEB_ENABLED); SPA routes not mounted")
}
- logger.Info("KeepNote listening on :8080 (mTLS, ClientAuth=RequireAndVerify)")
- if err := serveTLS(":8080", tlsCfg, handler); err != nil {
- logger.Error("HTTPS server failed: %v", err)
+ // Contract rev 9 §3.3: ONE plaintext listener per service. No TLS server
+ // configuration, no client certificates, no second socket. RC17 timeouts
+ // cover the API and the SPA alike.
+ server := &http.Server{
+ Addr: ":" + servicePort,
+ Handler: outer,
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ logger.Info("KeepNote listening plaintext on :%s (single port: API + SPA)", servicePort)
+ if err := server.ListenAndServe(); err != nil {
+ logger.Error("HTTP server failed: %v", err)
os.Exit(1)
}
}
-
-// serveTLS runs an HTTPS listener with the given mTLS config.
-func serveTLS(addr string, cfg *tls.Config, handler http.Handler) error {
- ln, err := net.Listen("tcp", addr)
- if err != nil {
- return err
- }
- tlsLn := tls.NewListener(ln, cfg)
- return http.Serve(tlsLn, handler)
-}
diff --git a/src/web/dist/index.html b/src/web/dist/index.html
new file mode 100644
index 0000000..ae4711f
--- /dev/null
+++ b/src/web/dist/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ KeepNote
+
+
+
+
+
KeepNote web bundle not built. Run cd web && bun run build.