diff --git a/.dockerignore b/.dockerignore
index f2c081b..583dd9f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,5 +8,6 @@
docker-compose.yml
README.md
-/meshtender
/cmd/meshtender/meshtender
+/meshtender
+/certs
diff --git a/.env.example b/.env.example
index 1f151f5..0cd8009 100644
--- a/.env.example
+++ b/.env.example
@@ -8,10 +8,44 @@ MESHTENDER_DATABASE_URL=postgres://meshtender:meshtender@localhost:5432/meshtend
MESHTENDER_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
# WebAuthn relying-party settings. RP_ID is the bare domain (no scheme/port).
+# Set it to the ROOT registrable domain (e.g. meshtender.com) so passkeys stay
+# valid across every subdomain — this is permanent, so don't pin it to a
+# subdomain. RP_ORIGIN is a comma-separated list of every origin a ceremony may
+# run from (the auth and app hosts).
MESHTENDER_RP_ID=localhost
MESHTENDER_RP_NAME=MeshTender
MESHTENDER_RP_ORIGIN=http://localhost:8080
+# Optional split-host topology, all served by one binary on one port (browsers
+# route *.localhost to loopback). Roles:
+# ROOT_HOST — public marketing + organization discovery (no session).
+# WWW_HOST — redirects to ROOT_HOST (defaults to "www." + ROOT_HOST).
+# AUTH_HOST — login/signup + WebAuthn ceremonies; hands off to the app host.
+# PRIMARY_HOST — the product/app host (dashboard at /, authenticated area).
+# Leave AUTH_HOST empty for single-host mode (everything on PRIMARY_HOST).
+#
+# NOTE: don't use *.localhost for the split — "localhost" is a public suffix, so
+# browsers reject RP ID "localhost" from a subdomain and passkeys won't work. Use
+# a real registrable dev domain with its subdomains pointed at 127.0.0.1, so RP
+# ID can be the registrable parent. Example (leighthaus.dev):
+# MESHTENDER_RP_ID=leighthaus.dev
+# MESHTENDER_RP_ORIGIN=https://auth.leighthaus.dev:8080,https://app.leighthaus.dev:8080
+# MESHTENDER_ROOT_HOST=leighthaus.dev
+# MESHTENDER_AUTH_HOST=auth.leighthaus.dev
+# MESHTENDER_PRIMARY_HOST=app.leighthaus.dev
+# MESHTENDER_ROOT_HOST=
+# MESHTENDER_WWW_HOST=
+# MESHTENDER_PRIMARY_HOST=
+# MESHTENDER_AUTH_HOST=
+#
+# HSTS-preloaded TLDs (.dev, .app, …) force HTTPS, so plain-HTTP dev won't load.
+# Serve TLS in-process with a locally-trusted mkcert cert (origins above are
+# https for this reason):
+# brew install mkcert && mkcert -install
+# mkcert -cert-file ./certs/dev.pem -key-file ./certs/dev-key.pem "*.leighthaus.dev" leighthaus.dev
+# MESHTENDER_TLS_CERT=./certs/dev.pem
+# MESHTENDER_TLS_KEY=./certs/dev-key.pem
+
# Default LoRa radio params suggested when adding a repeater (EU 869.525 / 250k / SF11 / CR5).
MESHTENDER_RADIO_FREQ_HZ=869525000
MESHTENDER_RADIO_BW_HZ=250000
diff --git a/.gitignore b/.gitignore
index 35f50ae..3eced6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
-/meshtender
-/cmd/meshtender/meshtender
-*.exe
.env
+/certs/
+/cmd/meshtender/meshtender
+/meshtender
+
+*.exe
diff --git a/cmd/meshtender/main.go b/cmd/meshtender/main.go
index 22aec98..18e75ff 100644
--- a/cmd/meshtender/main.go
+++ b/cmd/meshtender/main.go
@@ -8,15 +8,14 @@ import (
"net/http"
"os"
"os/signal"
- "strings"
"syscall"
"time"
"github.com/jleight/meshtender/internal/auth"
"github.com/jleight/meshtender/internal/config"
+ "github.com/jleight/meshtender/internal/core"
"github.com/jleight/meshtender/internal/identity"
"github.com/jleight/meshtender/internal/store"
- "github.com/jleight/meshtender/internal/web"
)
func main() {
@@ -53,23 +52,19 @@ func run(logger *slog.Logger) error {
}
logger.Info("server identity ready", "pubkey", idSvc.PublicKeyHex())
- secure := false
- for _, o := range cfg.RPOrigins {
- if strings.HasPrefix(o, "https://") {
- secure = true
- }
- }
authSvc, err := auth.New(st, st.Pool(), auth.Config{
RPID: cfg.RPID,
RPDisplayName: cfg.RPDisplayName,
RPOrigins: cfg.RPOrigins,
- Secure: secure,
+ AppHost: cfg.PrimaryHost,
+ AuthHost: cfg.AuthHost,
+ Secure: cfg.Secure,
})
if err != nil {
return err
}
- srv, err := web.NewServer(st, authSvc, idSvc, cfg)
+ srv, err := core.NewServer(st, authSvc, idSvc, cfg)
if err != nil {
return err
}
@@ -80,10 +75,17 @@ func run(logger *slog.Logger) error {
ReadHeaderTimeout: 10 * time.Second,
}
+ tls := cfg.TLSCert != "" && cfg.TLSKey != ""
errCh := make(chan error, 1)
go func() {
- logger.Info("listening", "addr", cfg.Addr)
- if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ logger.Info("listening", "addr", cfg.Addr, "tls", tls)
+ var err error
+ if tls {
+ err = httpSrv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey)
+ } else {
+ err = httpSrv.ListenAndServe()
+ }
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
diff --git a/internal/web/account.go b/internal/auth/account.go
similarity index 58%
rename from internal/web/account.go
rename to internal/auth/account.go
index 402b8c3..893b200 100644
--- a/internal/web/account.go
+++ b/internal/auth/account.go
@@ -1,4 +1,4 @@
-package web
+package auth
import (
"encoding/hex"
@@ -6,7 +6,7 @@ import (
"strconv"
"time"
- "github.com/jleight/meshtender/internal/auth"
+ "github.com/jleight/meshtender/internal/web"
)
// passkeyView is display metadata for one registered passkey.
@@ -17,20 +17,27 @@ type passkeyView struct {
}
// accountRedirect bounces back to the account page with a flash message under
-// the given key ("ok" or "error").
+// the given key ("ok" or "error"), shown in the page-level banner.
func accountRedirect(w http.ResponseWriter, r *http.Request, key, msg string) {
- redirectFlash(w, r, "/account", key, msg)
+ web.RedirectFlash(w, r, "/account", key, msg)
+}
+
+// passkeyRedirect bounces back to the account page with a flash shown inside the
+// Passkeys card ("pk" for success, "pkerr" for failure), so passkey messages
+// stay next to the passkey controls rather than in the top banner.
+func passkeyRedirect(w http.ResponseWriter, r *http.Request, key, msg string) {
+ web.RedirectFlash(w, r, "/account", key, msg)
}
// pageAccount renders the current user's account-settings page.
-func (s *Server) pageAccount(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- u, err := s.store.GetUserByID(r.Context(), uid)
+func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ u, err := s.Store.GetUserByID(r.Context(), uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
- creds, err := s.store.ListCredentials(r.Context(), uid)
+ creds, err := s.Store.ListCredentials(r.Context(), uid)
if err != nil {
http.Error(w, "could not load passkeys", http.StatusInternalServerError)
return
@@ -43,21 +50,23 @@ func (s *Server) pageAccount(w http.ResponseWriter, r *http.Request) {
}
views = append(views, passkeyView{ID: c.ID, ShortID: short, Added: c.CreatedAt})
}
- s.render(w, r, "account.html", map[string]any{
+ s.Render(w, r, "account.html", map[string]any{
"User": u,
"DisplayName": u.DisplayName, // nil when unset
"HasPassword": u.PasswordHash != nil,
"Passkeys": views,
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
+ "PKMsg": r.URL.Query().Get("pk"),
+ "PKErr": r.URL.Query().Get("pkerr"),
})
}
// handleUpdateProfile saves the user's display name.
-func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- displayName := auth.NormalizeDisplayName(r.FormValue("display_name"))
- if err := s.store.SetDisplayName(r.Context(), uid, displayName); err != nil {
+func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ displayName := NormalizeDisplayName(r.FormValue("display_name"))
+ if err := s.Store.SetDisplayName(r.Context(), uid, displayName); err != nil {
accountRedirect(w, r, "error", "Could not save your profile.")
return
}
@@ -65,10 +74,10 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
}
// handleChangePassword sets, changes, or removes the user's password.
-func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- uid := s.auth.CurrentUserID(ctx)
- u, err := s.store.GetUserByID(ctx, uid)
+ uid := s.Auth.CurrentUserID(ctx)
+ u, err := s.Store.GetUserByID(ctx, uid)
if err != nil {
accountRedirect(w, r, "error", "Could not load account.")
return
@@ -79,7 +88,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
accountRedirect(w, r, "error", "No password to remove.")
return
}
- n, err := s.store.CountCredentials(ctx, uid)
+ n, err := s.Store.CountCredentials(ctx, uid)
if err != nil {
accountRedirect(w, r, "error", "Could not load passkeys.")
return
@@ -88,7 +97,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
accountRedirect(w, r, "error", "Add a passkey before removing your password — it's your only way to sign in.")
return
}
- if err := s.store.ClearPassword(ctx, uid); err != nil {
+ if err := s.Store.ClearPassword(ctx, uid); err != nil {
accountRedirect(w, r, "error", "Could not remove password.")
return
}
@@ -102,11 +111,11 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
// When a password already exists, require the current one to change it.
- if u.PasswordHash != nil && !s.auth.PasswordMatches(u, r.FormValue("current_password")) {
+ if u.PasswordHash != nil && !s.Auth.PasswordMatches(u, r.FormValue("current_password")) {
accountRedirect(w, r, "error", "Current password is incorrect.")
return
}
- if err := s.auth.SetPassword(ctx, uid, newPassword); err != nil {
+ if err := s.Auth.SetPassword(ctx, uid, newPassword); err != nil {
accountRedirect(w, r, "error", "Could not update password.")
return
}
@@ -115,32 +124,32 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
// handleDeletePasskey removes one of the user's passkeys, refusing to remove
// the last sign-in method.
-func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- uid := s.auth.CurrentUserID(ctx)
+ uid := s.Auth.CurrentUserID(ctx)
credID, err := strconv.ParseInt(r.FormValue("credential_id"), 10, 64)
if err != nil {
- accountRedirect(w, r, "error", "Invalid passkey.")
+ passkeyRedirect(w, r, "pkerr", "Invalid passkey.")
return
}
- u, err := s.store.GetUserByID(ctx, uid)
+ u, err := s.Store.GetUserByID(ctx, uid)
if err != nil {
- accountRedirect(w, r, "error", "Could not load account.")
+ passkeyRedirect(w, r, "pkerr", "Could not load account.")
return
}
- n, err := s.store.CountCredentials(ctx, uid)
+ n, err := s.Store.CountCredentials(ctx, uid)
if err != nil {
- accountRedirect(w, r, "error", "Could not load passkeys.")
+ passkeyRedirect(w, r, "pkerr", "Could not load passkeys.")
return
}
// After removal the user must retain at least one way to sign in.
if n-1 < 1 && u.PasswordHash == nil {
- accountRedirect(w, r, "error", "You can't remove your only sign-in method. Add another passkey or set a password first.")
+ passkeyRedirect(w, r, "pkerr", "You can't remove your only sign-in method. Add another passkey or set a password first.")
return
}
- if err := s.store.DeleteCredential(ctx, uid, credID); err != nil {
- accountRedirect(w, r, "error", "Could not remove that passkey.")
+ if err := s.Store.DeleteCredential(ctx, uid, credID); err != nil {
+ passkeyRedirect(w, r, "pkerr", "Could not remove that passkey.")
return
}
- accountRedirect(w, r, "ok", "Passkey removed.")
+ passkeyRedirect(w, r, "pk", "Passkey removed.")
}
diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go
index b90d02a..e656b1a 100644
--- a/internal/auth/handlers.go
+++ b/internal/auth/handlers.go
@@ -14,10 +14,29 @@ import (
"github.com/jleight/meshtender/internal/store"
)
-// RequireUser is middleware that redirects unauthenticated requests to /login.
+// RequireUser is middleware that sends unauthenticated requests to the sign-in
+// page — local in single-host mode, or the auth host (with a handoff back) when
+// auth lives on a dedicated host.
func (s *Service) RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.CurrentUserID(r.Context()) == 0 {
+ s.StartLogin(w, r, r.URL.RequestURI())
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// RequireSSO guards auth-host-local pages (e.g. account settings) against the
+// auth host's own SSO session. Unlike RequireUser it does NOT hand off to the
+// app afterward: it flags the login as auth-local so PostAuthRedirect returns to
+// the requested path on the auth host once the SSO session exists.
+func (s *Service) RequireSSO(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ if s.CurrentUserID(ctx) == 0 {
+ s.Sessions.Put(ctx, sessKeyAuthLocal, true)
+ s.SetNext(ctx, r.URL.RequestURI())
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
@@ -118,7 +137,7 @@ func (s *Service) RegisterFinish(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "login")
return
}
- writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)})
+ writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, u.ID)})
}
// --- Passkey login ---
@@ -194,7 +213,7 @@ func (s *Service) LoginFinish(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "login")
return
}
- writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)})
+ writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, u.ID)})
}
// --- usernameless (discoverable) passkey login ---
@@ -266,7 +285,7 @@ func (s *Service) LoginDiscoverableFinish(w http.ResponseWriter, r *http.Request
httpError(w, http.StatusInternalServerError, "login")
return
}
- writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)})
+ writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, resolved.ID)})
}
// --- ceremony state helpers ---
diff --git a/internal/auth/handoff.go b/internal/auth/handoff.go
new file mode 100644
index 0000000..f7983df
--- /dev/null
+++ b/internal/auth/handoff.go
@@ -0,0 +1,216 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "net"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// The cross-host sign-in handoff. When AuthHost is configured, all credential
+// ceremonies run on the auth host; on success it mints a single-use code (see
+// store.CreateAuthCode) and redirects the browser to the app host's
+// SessionCallback, which redeems the code and establishes the app host's own
+// host-scoped session. A CSRF "state" nonce, set as a host-only cookie on the
+// app host before the bounce and echoed back through the auth host, ties the
+// returning callback to the request that started it (blocks login-CSRF).
+
+const (
+ sessKeyState = "auth_state" // string: CSRF nonce echoed through the auth host
+ sessKeyAuthLocal = "auth_local" // bool: this login should return to an auth-host page, not hand off
+ stateCookie = "mt_state" // base name; __Host- prefixed over HTTPS
+ stateMaxAge = 10 * time.Minute // window to complete a sign-in
+ maxStateLen = 256 // bound stored/echoed state length
+)
+
+// SplitHost returns true when this Service runs the auth front door on a
+// separate host from the app (cross-host handoff mode).
+func (s *Service) SplitHost() bool { return s.authHost != "" }
+
+// scheme is the URL scheme matching the cookie Secure setting.
+func (s *Service) scheme() string {
+ if s.secure {
+ return "https"
+ }
+ return "http"
+}
+
+// portSuffix returns the ":port" from the request host, or "" for default
+// ports. The auth and app hosts share one listener, so the app callback reuses
+// whatever port the auth request arrived on (covers :8090 dev, bare 443 prod).
+func portSuffix(r *http.Request) string {
+ if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
+ return ":" + port
+ }
+ return ""
+}
+
+func (s *Service) appOrigin(r *http.Request) string {
+ return s.scheme() + "://" + s.appHost + portSuffix(r)
+}
+
+func (s *Service) authOrigin(r *http.Request) string {
+ return s.scheme() + "://" + s.authHost + portSuffix(r)
+}
+
+// SetAuthState stashes the CSRF nonce carried into the auth host so a finisher
+// can echo it back to the app callback. Bounded and dropped if empty.
+func (s *Service) SetAuthState(ctx context.Context, state string) {
+ if state == "" || len(state) > maxStateLen {
+ return
+ }
+ s.Sessions.Put(ctx, sessKeyState, state)
+ // A state nonce means this is a cross-host handoff login, not an auth-local
+ // one — clear any stale auth-local flag so the handoff isn't suppressed.
+ s.Sessions.Remove(ctx, sessKeyAuthLocal)
+}
+
+func (s *Service) popAuthState(ctx context.Context) string {
+ state, _ := s.Sessions.Get(ctx, sessKeyState).(string)
+ s.Sessions.Remove(ctx, sessKeyState)
+ return state
+}
+
+// PostAuthRedirect is the destination after a successful sign-in. In single-host
+// mode it's the stored post-auth path. In split-host mode (on the auth host) it
+// mints a handoff code and points at the app host's callback — UNLESS the login
+// was initiated for an auth-host-local page (e.g. account settings), in which
+// case it returns that local path with no handoff. The caller must already have
+// run login() for the auth host's own (SSO) session.
+func (s *Service) PostAuthRedirect(r *http.Request, userID int64) string {
+ ctx := r.Context()
+ next := s.PopNext(ctx)
+ // Auth-host-local return (account settings et al.): SSO session is enough,
+ // stay on the auth host rather than handing off to the app.
+ if s.Sessions.PopBool(ctx, sessKeyAuthLocal) {
+ return next
+ }
+ if !s.SplitHost() || !s.onAuthHost(r) {
+ return next
+ }
+ code, err := s.store.CreateAuthCode(ctx, userID, next)
+ if err != nil {
+ // Couldn't mint a code; fall back to the app root rather than stranding
+ // the user. They're authenticated on the auth host either way.
+ return s.appOrigin(r) + "/"
+ }
+ q := url.Values{"code": {code}}
+ if state := s.popAuthState(ctx); state != "" {
+ q.Set("state", state)
+ }
+ return s.appOrigin(r) + "/session/callback?" + q.Encode()
+}
+
+func (s *Service) onAuthHost(r *http.Request) bool {
+ return hostOnly(r.Host) == s.authHost
+}
+
+// hostOnly strips a trailing :port from a request Host.
+func hostOnly(host string) string {
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ return h
+ }
+ return host
+}
+
+// StartLogin sends an unauthenticated visitor to the sign-in page (see
+// startAuth). next is preserved so they land back where they wanted.
+func (s *Service) StartLogin(w http.ResponseWriter, r *http.Request, next string) {
+ s.startAuth(w, r, next, "/login")
+}
+
+// StartSignup is StartLogin's sibling for account creation: it routes to the
+// auth host's sign-up page. Used by the app host's /signup entry and marketing
+// "create account" CTAs so signup still flows through the state-protected
+// handoff.
+func (s *Service) StartSignup(w http.ResponseWriter, r *http.Request, next string) {
+ s.startAuth(w, r, next, "/signup")
+}
+
+// startAuth begins a sign-in/sign-up. In split-host mode it redirects to the
+// auth host's page, first dropping a host-only state cookie on the app host that
+// the returning callback must match — which is why auth entry must always go
+// through the app host, never a direct link to the auth host. In single-host
+// mode it just redirects to the local page.
+func (s *Service) startAuth(w http.ResponseWriter, r *http.Request, next, page string) {
+ if !SafeLocalPath(next) {
+ next = "/"
+ }
+ if !s.SplitHost() {
+ http.Redirect(w, r, page+"?next="+url.QueryEscape(next), http.StatusSeeOther)
+ return
+ }
+ state, err := randomState()
+ if err != nil {
+ http.Error(w, "could not start sign-in", http.StatusInternalServerError)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName(stateCookie, s.secure),
+ Value: state,
+ Path: "/",
+ MaxAge: int(stateMaxAge.Seconds()),
+ HttpOnly: true,
+ Secure: s.secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+ q := url.Values{"next": {next}, "state": {state}}
+ http.Redirect(w, r, s.authOrigin(r)+page+"?"+q.Encode(), http.StatusSeeOther)
+}
+
+// SessionCallback runs on the app host. It verifies the state nonce against the
+// host-only cookie, redeems the single-use handoff code, establishes the app
+// host's session, and redirects to the stored post-auth path.
+func (s *Service) SessionCallback(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ clearStateCookie(w, s.secure)
+
+ state := r.URL.Query().Get("state")
+ c, err := r.Cookie(cookieName(stateCookie, s.secure))
+ if err != nil || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(c.Value)) != 1 {
+ s.StartLogin(w, r, "/")
+ return
+ }
+
+ userID, next, ok, err := s.store.ConsumeAuthCode(ctx, r.URL.Query().Get("code"))
+ if err != nil {
+ http.Error(w, "sign-in failed", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ s.StartLogin(w, r, "/")
+ return
+ }
+ if err := s.login(ctx, userID); err != nil {
+ http.Error(w, "could not start session", http.StatusInternalServerError)
+ return
+ }
+ if !SafeLocalPath(next) {
+ next = "/"
+ }
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
+
+func clearStateCookie(w http.ResponseWriter, secure bool) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName(stateCookie, secure),
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func randomState() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/internal/auth/password_handlers.go b/internal/auth/password_handlers.go
index 5cdd726..21f288f 100644
--- a/internal/auth/password_handlers.go
+++ b/internal/auth/password_handlers.go
@@ -36,7 +36,7 @@ func (s *Service) SignupPassword(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "/signup", "Could not start session.")
return
}
- http.Redirect(w, r, s.PopNext(ctx), http.StatusSeeOther)
+ http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther)
}
// LoginPassword handles a form-based password sign-in.
@@ -53,7 +53,7 @@ func (s *Service) LoginPassword(w http.ResponseWriter, r *http.Request) {
redirectErr(w, r, "/login", "Could not start session.")
return
}
- http.Redirect(w, r, s.PopNext(r.Context()), http.StatusSeeOther)
+ http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther)
}
func redirectErr(w http.ResponseWriter, r *http.Request, path, msg string) {
diff --git a/internal/auth/service.go b/internal/auth/service.go
index efb95b6..6862959 100644
--- a/internal/auth/service.go
+++ b/internal/auth/service.go
@@ -27,6 +27,13 @@ type Service struct {
wa *webauthn.WebAuthn
store *store.Store
Sessions *scs.SessionManager
+
+ // Host split: when authHost is set, sign-in happens on a dedicated host and
+ // hands off to appHost via a single-use code. Empty authHost ⇒ single-host
+ // mode (auth served from appHost, no cross-host handoff).
+ appHost string
+ authHost string
+ secure bool
}
// Config configures the auth service.
@@ -34,7 +41,12 @@ type Config struct {
RPID string
RPDisplayName string
RPOrigins []string
- // Secure marks the session cookie Secure (set false for plain-HTTP localhost dev).
+ // AppHost serves the product; AuthHost (optional) serves the login UI and
+ // runs ceremonies, handing off to AppHost. Both are bare hostnames (no
+ // scheme/port). Empty AuthHost selects single-host mode.
+ AppHost string
+ AuthHost string
+ // Secure marks cookies Secure (set false for plain-HTTP localhost dev).
Secure bool
}
@@ -53,12 +65,34 @@ func New(st *store.Store, pool *pgxpool.Pool, cfg Config) (*Service, error) {
sm := scs.New()
sm.Store = pgxstore.New(pool)
sm.Lifetime = 7 * 24 * time.Hour
- sm.Cookie.Name = "meshtender_session"
+ // Host-only cookie: no Domain attribute, so the auth and app hosts each get
+ // their own independent session — the browser never sends one host's cookie
+ // to the other. Over HTTPS we use the __Host- prefix, which the browser
+ // enforces as host-only + Secure + Path=/ (and blocks subdomain injection).
+ // The prefix is illegal over plain HTTP, so dev falls back to a bare name.
+ sm.Cookie.Name = cookieName("meshtender_session", cfg.Secure)
sm.Cookie.HttpOnly = true
sm.Cookie.SameSite = http.SameSiteLaxMode
sm.Cookie.Secure = cfg.Secure
+ sm.Cookie.Path = "/"
- return &Service{wa: wa, store: st, Sessions: sm}, nil
+ return &Service{
+ wa: wa,
+ store: st,
+ Sessions: sm,
+ appHost: cfg.AppHost,
+ authHost: cfg.AuthHost,
+ secure: cfg.Secure,
+ }, nil
+}
+
+// cookieName applies the __Host- prefix over HTTPS (where browsers enforce it)
+// and a bare name over plain-HTTP dev (where the prefix is rejected).
+func cookieName(base string, secure bool) string {
+ if secure {
+ return "__Host-" + base
+ }
+ return base
}
// CurrentUserID returns the authenticated user id from the session, or 0.
diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html
new file mode 100644
index 0000000..d2c6510
--- /dev/null
+++ b/internal/auth/templates/account.html
@@ -0,0 +1,123 @@
+{{define "title"}}Account settings · MeshTender{{end}}
+{{define "header"}}
+
+
+
Your account
+
Account settings
+
+
+{{end}}
+{{define "content"}}
+{{if .Error}}{{.Error}}
{{end}}
+{{if .OK}}{{.OK}}
{{end}}
+
+
+
+
+
+
+
+
+
+
+
Profile
+
Your username can't be changed. Your display name is shown to people you share with.
+
+
+
+
Passkeys
+
Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.
+ {{if .PKErr}}
{{.PKErr}}
{{end}}
+ {{if .PKMsg}}
{{.PKMsg}}
{{end}}
+ {{if .Passkeys}}
+
+ {{range .Passkeys}}
+
+
{{template "icon-key" ""}}
+
+
{{.ShortID}}…
+
Added {{.Added.Format "Jan 2, 2006"}}
+
+
+
+ {{end}}
+
+ {{else}}
+
No passkeys yet. Add one for faster, more secure sign-in.
+ {{end}}
+
+ {{template "icon-key" "me-1"}}Add a passkey
+
+
+
+
+
Password
+ {{if .HasPassword}}
+
Change the password you use as a fallback to passkeys.
+ {{else}}
+
You don't have a password set — you sign in with a passkey. You can add one as a fallback.
+ {{end}}
+
+
+ {{if .HasPassword}}
+
+ {{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/internal/web/templates/login.html b/internal/auth/templates/login.html
similarity index 100%
rename from internal/web/templates/login.html
rename to internal/auth/templates/login.html
diff --git a/internal/web/templates/signup.html b/internal/auth/templates/signup.html
similarity index 100%
rename from internal/web/templates/signup.html
rename to internal/auth/templates/signup.html
diff --git a/internal/auth/web.go b/internal/auth/web.go
new file mode 100644
index 0000000..dd04109
--- /dev/null
+++ b/internal/auth/web.go
@@ -0,0 +1,128 @@
+package auth
+
+import (
+ "embed"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/jleight/meshtender/internal/web"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// Handlers serve the auth host's surface: sign-in/sign-up, the WebAuthn/password
+// ceremonies, single logout, and account/credential management. Keeping them on
+// the auth host means passkeys and passwords are created on the same origin used
+// to sign in.
+type Handlers struct {
+ *web.Env
+ Auth *Service
+}
+
+// NewWeb builds the auth surface handlers with their own renderer.
+func NewWeb(deps web.Deps, svc *Service) (*Handlers, error) {
+ env, err := web.NewEnv(deps, templatesFS)
+ if err != nil {
+ return nil, err
+ }
+ return &Handlers{Env: env, Auth: svc}, nil
+}
+
+func (s *Handlers) baseMW(r chi.Router) {
+ s.CommonMiddleware(r)
+ r.Use(s.Auth.Sessions.LoadAndSave)
+}
+
+// Routes is the auth host's router.
+func (s *Handlers) Routes() chi.Router {
+ r := chi.NewRouter()
+ s.baseMW(r)
+ s.SharedRoutes(r)
+ s.credentialRoutes(r)
+ // Single logout: clears the SSO session (chained to from the app host).
+ r.Get("/logout", s.handleAuthLogout)
+
+ // Account/credential management lives here so credentials are created on the
+ // same origin used to sign in. Guarded by the SSO session.
+ r.Group(func(r chi.Router) {
+ r.Use(s.Auth.RequireSSO)
+ r.Get("/account", s.pageAccount)
+ r.Post("/account/profile", s.handleUpdateProfile)
+ r.Post("/account/password", s.handleChangePassword)
+ r.Post("/account/passkeys/delete", s.handleDeletePasskey)
+ })
+
+ // Bare visits to the auth host go to the sign-in page.
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ })
+ return r
+}
+
+// credentialRoutes mounts the sign-in/sign-up UI and WebAuthn/password ceremony
+// endpoints.
+func (s *Handlers) credentialRoutes(r chi.Router) {
+ r.Get("/login", s.pageLogin)
+ r.Get("/signup", s.pageSignup)
+ // Throttle credential submission per client IP to blunt password guessing
+ // and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try
+ // every 6s; bcrypt's cost is the second line of defense.
+ authLimit := web.NewRateLimiter(10, 6*time.Second)
+ r.With(authLimit.Middleware).Post("/login/password", s.Auth.LoginPassword)
+ r.With(authLimit.Middleware).Post("/signup/password", s.Auth.SignupPassword)
+ r.Post("/api/register/begin", s.Auth.RegisterBegin)
+ r.Post("/api/register/finish", s.Auth.RegisterFinish)
+ r.Post("/api/login/begin", s.Auth.LoginBegin)
+ r.Post("/api/login/finish", s.Auth.LoginFinish)
+ r.Post("/api/login/discoverable/begin", s.Auth.LoginDiscoverableBegin)
+ r.Post("/api/login/discoverable/finish", s.Auth.LoginDiscoverableFinish)
+}
+
+func (s *Handlers) pageLogin(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ s.Auth.SetNext(ctx, r.URL.Query().Get("next"))
+ s.Auth.SetAuthState(ctx, r.URL.Query().Get("state"))
+ // Already signed in (e.g. an existing auth-host SSO session): skip the
+ // ceremony and hand straight off, rather than re-prompting.
+ if uid := s.Auth.CurrentUserID(ctx); uid != 0 {
+ http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther)
+ return
+ }
+ s.Render(w, r, "login.html", map[string]any{
+ "Layout": "authbase",
+ "Error": r.URL.Query().Get("error"),
+ "Next": r.URL.Query().Get("next"),
+ })
+}
+
+func (s *Handlers) pageSignup(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ s.Auth.SetNext(ctx, r.URL.Query().Get("next"))
+ s.Auth.SetAuthState(ctx, r.URL.Query().Get("state"))
+ if uid := s.Auth.CurrentUserID(ctx); uid != 0 {
+ http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther)
+ return
+ }
+ s.Render(w, r, "signup.html", map[string]any{
+ "Layout": "authbase",
+ "Error": r.URL.Query().Get("error"),
+ "Next": r.URL.Query().Get("next"),
+ })
+}
+
+// handleAuthLogout clears the auth host's SSO session and lands the visitor on
+// the public root (or the app in narrower configs).
+func (s *Handlers) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
+ _ = s.Auth.Logout(r.Context())
+ switch {
+ case s.Cfg.RootHost != "":
+ http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/", http.StatusSeeOther)
+ case s.Cfg.PrimaryHost != "":
+ http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+"/", http.StatusSeeOther)
+ default:
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 465b85f..aabd552 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
+ "strings"
)
// Config holds all runtime configuration for the server.
@@ -20,17 +21,48 @@ type Config struct {
MasterKey [32]byte
// WebAuthn relying-party settings. RPID is the effective domain (no
- // scheme/port), RPOrigins are the full origins browsers will send.
+ // scheme/port) — set it to the root registrable domain (e.g.
+ // "meshtender.com") so credentials are valid across every subdomain.
+ // RPOrigins are the full origins browsers will send a ceremony from;
+ // supply a comma-separated list to allow both the auth and app hosts
+ // (e.g. "https://auth.meshtender.com,https://app.meshtender.com").
RPID string
RPDisplayName string
RPOrigins []string
- // PrimaryHost is the canonical app hostname (no scheme/port). Requests
- // arriving on a different, verified org custom domain serve that org's
- // public page; all other paths there redirect back to PrimaryHost (where
- // the WebAuthn RP is valid). Defaults to RPID.
+ // PrimaryHost is the canonical app hostname (no scheme/port) — the host
+ // that serves the product. Requests arriving on a different, verified org
+ // custom domain serve that org's public page; all other paths there
+ // redirect back to PrimaryHost. Defaults to RPID.
PrimaryHost string
+ // AuthHost is the dedicated hostname that serves the login/signup UI and
+ // runs WebAuthn ceremonies (e.g. "auth.meshtender.com"). A successful
+ // sign-in there hands off to PrimaryHost via a single-use code. When
+ // empty, auth is served from PrimaryHost (single-host mode).
+ AuthHost string
+
+ // RootHost is the public marketing + organization-discovery hostname (the
+ // bare apex, e.g. "meshtender.com" / dev "localhost"). It carries no
+ // session (cookies are host-only), so it serves only public content. When
+ // empty, that content stays on PrimaryHost.
+ RootHost string
+
+ // WWWHost redirects to RootHost (e.g. "www.meshtender.com"). Defaults to
+ // "www." + RootHost when RootHost is set.
+ WWWHost string
+
+ // Secure reports whether the deployment is HTTPS (derived from RPOrigins).
+ // Drives cookie Secure/__Host- prefixing and the scheme of cross-host URLs.
+ Secure bool
+
+ // TLSCert and TLSKey, when both set, make the server terminate TLS itself
+ // (https). Used for local HTTPS dev (e.g. an mkcert cert for *.example.dev),
+ // where HSTS-preloaded TLDs like .dev force the browser onto https. In
+ // production TLS usually terminates at a proxy and these stay empty.
+ TLSCert string
+ TLSKey string
+
// DefaultRadio holds fallback LoRa parameters offered when adding a
// repeater. Per-repeater values in the DB take precedence.
DefaultRadio RadioDefaults
@@ -52,8 +84,13 @@ func Load() (*Config, error) {
DatabaseURL: os.Getenv("MESHTENDER_DATABASE_URL"),
RPID: envOr("MESHTENDER_RP_ID", "localhost"),
RPDisplayName: envOr("MESHTENDER_RP_NAME", "MeshTender"),
- RPOrigins: []string{envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")},
+ RPOrigins: splitOrigins(envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")),
PrimaryHost: envOr("MESHTENDER_PRIMARY_HOST", envOr("MESHTENDER_RP_ID", "localhost")),
+ AuthHost: os.Getenv("MESHTENDER_AUTH_HOST"),
+ RootHost: os.Getenv("MESHTENDER_ROOT_HOST"),
+ WWWHost: os.Getenv("MESHTENDER_WWW_HOST"),
+ TLSCert: os.Getenv("MESHTENDER_TLS_CERT"),
+ TLSKey: os.Getenv("MESHTENDER_TLS_KEY"),
DefaultRadio: RadioDefaults{
FreqHz: uint32(envUintOr("MESHTENDER_RADIO_FREQ_HZ", 869525000)),
BwHz: uint32(envUintOr("MESHTENDER_RADIO_BW_HZ", 250000)),
@@ -62,6 +99,17 @@ func Load() (*Config, error) {
},
}
+ if c.RootHost != "" && c.WWWHost == "" {
+ c.WWWHost = "www." + c.RootHost
+ }
+ // HTTPS deployments advertise https:// origins; this drives Secure cookies
+ // and the scheme used when building absolute cross-host URLs.
+ for _, o := range c.RPOrigins {
+ if strings.HasPrefix(o, "https://") {
+ c.Secure = true
+ }
+ }
+
if c.DatabaseURL == "" {
return nil, fmt.Errorf("MESHTENDER_DATABASE_URL is required")
}
@@ -89,6 +137,19 @@ func envOr(key, def string) string {
return def
}
+// splitOrigins parses a comma-separated list of WebAuthn origins, trimming
+// surrounding whitespace and dropping empty entries.
+func splitOrigins(s string) []string {
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
func envUintOr(key string, def uint64) uint64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseUint(v, 10, 64); err == nil {
diff --git a/internal/web/admin.go b/internal/core/admin.go
similarity index 74%
rename from internal/web/admin.go
rename to internal/core/admin.go
index f2b3534..9521c17 100644
--- a/internal/web/admin.go
+++ b/internal/core/admin.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"net/http"
@@ -7,14 +7,15 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
)
// requireCap is middleware that allows the request only if the current user
// satisfies pick; otherwise it 404s (without revealing the admin area exists).
-func (s *Server) requireCap(pick func(*store.User) bool) func(http.Handler) http.Handler {
+func (s *Handlers) requireCap(pick func(*store.User) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- u, err := s.store.GetUserByID(r.Context(), s.auth.CurrentUserID(r.Context()))
+ u, err := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context()))
if err != nil || !pick(u) {
http.NotFound(w, r)
return
@@ -61,27 +62,27 @@ func groupCatalog(catalog []*store.Command) []catalogGroup {
return groupByCategory(catalog, func(c *store.Command) *store.Command { return c })
}
-func (s *Server) pageAdmin(w http.ResponseWriter, r *http.Request) {
- u, _ := s.store.GetUserByID(r.Context(), s.auth.CurrentUserID(r.Context()))
- s.render(w, r, "admin.html", map[string]any{
+func (s *Handlers) pageAdmin(w http.ResponseWriter, r *http.Request) {
+ u, _ := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context()))
+ s.Render(w, r, "admin.html", map[string]any{
"CanCatalog": u.CapManageCatalog,
"CanUsers": u.CapManageUsers,
})
}
-func (s *Server) pageCatalog(w http.ResponseWriter, r *http.Request) {
- catalog, err := s.store.ListCommands(r.Context())
+func (s *Handlers) pageCatalog(w http.ResponseWriter, r *http.Request) {
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load catalog", http.StatusInternalServerError)
return
}
- s.render(w, r, "admin_catalog.html", map[string]any{
+ s.Render(w, r, "admin_catalog.html", map[string]any{
"Groups": groupCatalog(catalog),
"Saved": r.URL.Query().Get("saved") != "",
})
}
-func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleUpdateCommand(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.NotFound(w, r)
@@ -92,7 +93,7 @@ func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) {
return
}
on := func(name string) bool { return r.FormValue(name) != "" }
- if err := s.store.UpdateCommandFlags(r.Context(), id,
+ if err := s.Store.UpdateCommandFlags(r.Context(), id,
on("risky"), on("share"), on("org_member"), on("org_admin")); err != nil {
http.Error(w, "could not save", http.StatusInternalServerError)
return
@@ -100,25 +101,25 @@ func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/catalog?saved=1", http.StatusSeeOther)
}
-func (s *Server) pageUsers(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) {
after := r.URL.Query().Get("after")
- users, hasMore, err := s.store.ListUsersPage(r.Context(), after)
+ users, hasMore, err := s.Store.ListUsersPage(r.Context(), after)
if err != nil {
http.Error(w, "could not load users", http.StatusInternalServerError)
return
}
data := map[string]any{
"Users": users,
- "Self": s.auth.CurrentUserID(r.Context()),
+ "Self": s.Auth.CurrentUserID(r.Context()),
"Error": r.URL.Query().Get("error"),
}
if hasMore && len(users) > 0 {
data["NextAfter"] = users[len(users)-1].Username
}
- s.render(w, r, "admin_users.html", data)
+ s.Render(w, r, "admin_users.html", data)
}
-func (s *Server) handleSetUserCaps(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleSetUserCaps(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.NotFound(w, r)
@@ -129,24 +130,24 @@ func (s *Server) handleSetUserCaps(w http.ResponseWriter, r *http.Request) {
// Guard against removing the last user-manager.
if !manageUsers {
- target, err := s.store.GetUserByID(r.Context(), id)
+ target, err := s.Store.GetUserByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
if target.CapManageUsers {
- n, err := s.store.CountManageUsers(r.Context())
+ n, err := s.Store.CountManageUsers(r.Context())
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
if n <= 1 {
- redirectErr(w, r, "/admin/users", "Can't remove the last user manager.")
+ web.RedirectErr(w, r, "/admin/users", "Can't remove the last user manager.")
return
}
}
}
- if err := s.store.SetCapabilities(r.Context(), id, manageUsers, manageCatalog); err != nil {
+ if err := s.Store.SetCapabilities(r.Context(), id, manageUsers, manageCatalog); err != nil {
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
diff --git a/internal/web/audit.go b/internal/core/audit.go
similarity index 88%
rename from internal/web/audit.go
rename to internal/core/audit.go
index 40d6c28..dac7454 100644
--- a/internal/web/audit.go
+++ b/internal/core/audit.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"encoding/base64"
@@ -11,12 +11,12 @@ import (
// pageCommandLog shows the command audit log for a repeater (owner only),
// keyset-paginated by console session via an opaque ?before token.
-func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageCommandLog(w http.ResponseWriter, r *http.Request) {
rep, id, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
- sessions, hasMore, err := s.store.ListCommandLogSessionsPage(r.Context(), id, decodeLogCursor(r.URL.Query().Get("before")))
+ sessions, hasMore, err := s.Store.ListCommandLogSessionsPage(r.Context(), id, decodeLogCursor(r.URL.Query().Get("before")))
if err != nil {
http.Error(w, "could not load log", http.StatusInternalServerError)
return
@@ -29,7 +29,7 @@ func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) {
last := sessions[len(sessions)-1]
data["NextBefore"] = encodeLogCursor(last.StartedAt, last.ID)
}
- s.render(w, r, "command_log.html", data)
+ s.Render(w, r, "command_log.html", data)
}
// logCursor is the wire form of a command-log keyset position.
diff --git a/internal/web/confirm.go b/internal/core/confirm.go
similarity index 93%
rename from internal/web/confirm.go
rename to internal/core/confirm.go
index 63f5081..6d0779b 100644
--- a/internal/web/confirm.go
+++ b/internal/core/confirm.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -31,12 +31,12 @@ var perTryReply = 10 * time.Second
const maxSendTries = 4
// pageConfirm renders the WebSerial confirm page for a repeater the user can access.
-func (s *Server) pageConfirm(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageConfirm(w http.ResponseWriter, r *http.Request) {
rep, _, ok := s.requireRepeaterAccess(w, r)
if !ok {
return
}
- s.render(w, r, "confirm.html", map[string]any{
+ s.Render(w, r, "confirm.html", map[string]any{
"Repeater": rep,
"Debug": r.URL.Query().Get("debug") == "1",
})
@@ -44,14 +44,14 @@ func (s *Server) pageConfirm(w http.ResponseWriter, r *http.Request) {
// wsConfirm runs the live login round-trip over a WebSocket bridged to the
// browser's WebSerial-attached KISS modem.
-func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
if !ok {
http.NotFound(w, r)
return
}
- rep, err := s.store.GetRepeaterForUser(r.Context(), uid, id)
+ rep, err := s.Store.GetRepeaterForUser(r.Context(), uid, id)
if err != nil {
http.Error(w, "no access", http.StatusForbidden)
return
@@ -80,7 +80,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) {
modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0))
defer modem.Close()
- server := s.identity.Local()
+ server := s.Identity.Local()
debug := r.URL.Query().Get("debug") == "1"
// All sends (login + location queries) go through one exchanger: rate-limited,
@@ -164,7 +164,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) {
return // context cancelled or a build/transmit error already reported
}
- if err := s.store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil {
+ if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil {
_ = bridge.Status("error", "could not save confirmation: "+err.Error())
return
}
@@ -198,7 +198,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) {
lat, okLat := fetchCoord("latitude", "get lat")
lon, okLon := fetchCoord("longitude", "get lon")
if okLat && okLon {
- if err := s.store.SetRepeaterLocation(ctx, id, lat, lon); err != nil {
+ if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil {
_ = bridge.Status("error", "could not store location: "+err.Error())
} else {
_ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon))
diff --git a/internal/web/confirm_integration_test.go b/internal/core/confirm_integration_test.go
similarity index 95%
rename from internal/web/confirm_integration_test.go
rename to internal/core/confirm_integration_test.go
index 1ca7182..2c703d1 100644
--- a/internal/web/confirm_integration_test.go
+++ b/internal/core/confirm_integration_test.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -77,18 +77,7 @@ func TestConfirmRoundTrip(t *testing.T) {
// --- sign up a user (password) and capture the session cookie ---
jar, _ := cookiejar.New(nil)
- client := &http.Client{Jar: jar}
- form := url.Values{"username": {"tester"}, "password": {"supersecret"}}
- resp, err := client.PostForm(ts.URL+"/signup/password", form)
- if err != nil {
- t.Fatalf("signup: %v", err)
- }
- resp.Body.Close()
-
- user, err := st.GetUserByUsername(ctx, "tester")
- if err != nil {
- t.Fatalf("get user: %v", err)
- }
+ user := seedSession(t, ts, st, ctx, jar, "tester")
// --- register a repeater whose private key we (the test) hold ---
repeater, err := meshcore.GenerateLocalIdentity(rand.Reader)
diff --git a/internal/web/confirm_location_test.go b/internal/core/confirm_location_test.go
similarity index 96%
rename from internal/web/confirm_location_test.go
rename to internal/core/confirm_location_test.go
index 35eefbe..a95eb08 100644
--- a/internal/web/confirm_location_test.go
+++ b/internal/core/confirm_location_test.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -75,10 +75,7 @@ func TestConfirmFetchesLocation(t *testing.T) {
defer ts.Close()
jar, _ := cookiejar.New(nil)
- client := &http.Client{Jar: jar}
- resp, _ := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}})
- resp.Body.Close()
- user, _ := st.GetUserByUsername(ctx, "alice")
+ user := seedSession(t, ts, st, ctx, jar, "alice")
repeater, _ := meshcore.GenerateLocalIdentity(rand.Reader)
rep, err := st.CreateRepeater(ctx, &store.Repeater{
diff --git a/internal/web/confirm_retry_test.go b/internal/core/confirm_retry_test.go
similarity index 95%
rename from internal/web/confirm_retry_test.go
rename to internal/core/confirm_retry_test.go
index 4570a3e..1b29fd5 100644
--- a/internal/web/confirm_retry_test.go
+++ b/internal/core/confirm_retry_test.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -62,10 +62,7 @@ func TestConfirmLoginRetry(t *testing.T) {
defer ts.Close()
jar, _ := cookiejar.New(nil)
- client := &http.Client{Jar: jar}
- resp, _ := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}})
- resp.Body.Close()
- user, _ := st.GetUserByUsername(ctx, "alice")
+ user := seedSession(t, ts, st, ctx, jar, "alice")
repeater, _ := meshcore.GenerateLocalIdentity(rand.Reader)
rep, err := st.CreateRepeater(ctx, &store.Repeater{
diff --git a/internal/web/console.go b/internal/core/console.go
similarity index 87%
rename from internal/web/console.go
rename to internal/core/console.go
index abd9d6a..7380c2c 100644
--- a/internal/web/console.go
+++ b/internal/core/console.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -57,11 +57,11 @@ func resolveCommand(typed string, catalog []*store.Command) *store.Command {
// allowedCommands returns the catalog commands the user may run on the repeater:
// all of them for the owner, else the share-granted subset.
-func (s *Server) allowedCommands(ctx context.Context, rep *store.Repeater, userID int64, catalog []*store.Command) []*store.Command {
+func (s *Handlers) allowedCommands(ctx context.Context, rep *store.Repeater, userID int64, catalog []*store.Command) []*store.Command {
if rep.OwnerID == userID {
return catalog
}
- ids, err := s.store.ListShareCommandIDs(ctx, rep.ID, userID)
+ ids, err := s.Store.ListShareCommandIDs(ctx, rep.ID, userID)
if err != nil {
return nil
}
@@ -78,34 +78,34 @@ func (s *Server) allowedCommands(ctx context.Context, rep *store.Repeater, userI
return out
}
-func (s *Server) pageConsole(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) pageConsole(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
rep, _, ok := s.requireRepeaterAccess(w, r)
if !ok {
return
}
- catalog, err := s.store.ListCommands(r.Context())
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
allowed := s.allowedCommands(r.Context(), rep, uid, catalog)
sort.Slice(allowed, func(i, j int) bool { return allowed[i].Template < allowed[j].Template })
- s.render(w, r, "console.html", map[string]any{
+ s.Render(w, r, "console.html", map[string]any{
"Repeater": rep,
"Commands": allowed,
})
}
// wsConsole runs an interactive command session over the modem bridge.
-func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
if !ok {
http.NotFound(w, r)
return
}
- rep, err := s.store.GetRepeaterForUser(r.Context(), uid, id)
+ rep, err := s.Store.GetRepeaterForUser(r.Context(), uid, id)
if err != nil {
http.Error(w, "no access", http.StatusForbidden)
return
@@ -115,7 +115,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
http.Error(w, "stored repeater key invalid", http.StatusInternalServerError)
return
}
- catalog, err := s.store.ListCommands(r.Context())
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
@@ -133,7 +133,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
bridge := wsbridge.New(ctx, ws)
modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0))
defer modem.Close()
- server := s.identity.Local()
+ server := s.Identity.Local()
// All commands go through one exchanger: rate-limited, monotonic timestamps,
// automatic retry of lost packets.
@@ -143,7 +143,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
})
// Group this connection's commands into a session (required for logging).
- sessionID, err := s.store.StartConsoleSession(ctx, id, uid)
+ sessionID, err := s.Store.StartConsoleSession(ctx, id, uid)
if err != nil {
_ = bridge.Status("error", "could not start session")
return
@@ -151,7 +151,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
defer func() {
endCtx, c := context.WithTimeout(context.Background(), 5*time.Second)
defer c()
- _ = s.store.EndConsoleSession(endCtx, sessionID)
+ _ = s.Store.EndConsoleSession(endCtx, sessionID)
}()
if err := modem.Connect(ctx); err != nil {
@@ -238,7 +238,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
_ = bridge.Status("denied", "Unknown command: "+text)
return
}
- allowed, err := s.store.CanSendCommand(ctx, uid, id, cmd.ID)
+ allowed, err := s.Store.CanSendCommand(ctx, uid, id, cmd.ID)
if err != nil {
_ = bridge.Status("error", "permission check failed")
return
@@ -248,7 +248,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
return
}
- logID, _ := s.store.LogCommand(ctx, id, uid, sessionID, cmd.ID, text)
+ logID, _ := s.Store.LogCommand(ctx, id, uid, sessionID, cmd.ID, text)
reply, err := ex.Command(ctx, text, func(attempt, max int) {
if attempt == 1 {
@@ -260,7 +260,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) {
switch {
case err == nil:
if logID != 0 {
- _ = s.store.MarkCommandReply(ctx, logID, reply)
+ _ = s.Store.MarkCommandReply(ctx, logID, reply)
}
_ = bridge.Status("reply", reply)
case errors.Is(err, mesh.ErrNoReply):
diff --git a/internal/web/console_integration_test.go b/internal/core/console_integration_test.go
similarity index 95%
rename from internal/web/console_integration_test.go
rename to internal/core/console_integration_test.go
index 64969af..e1f8986 100644
--- a/internal/web/console_integration_test.go
+++ b/internal/core/console_integration_test.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -71,16 +71,7 @@ func TestConsoleRoundTrip(t *testing.T) {
defer ts.Close()
jar, _ := cookiejar.New(nil)
- client := &http.Client{Jar: jar}
- resp, err := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}})
- if err != nil {
- t.Fatalf("signup: %v", err)
- }
- resp.Body.Close()
- user, err := st.GetUserByUsername(ctx, "alice")
- if err != nil {
- t.Fatalf("get user: %v", err)
- }
+ user := seedSession(t, ts, st, ctx, jar, "alice")
repeater, err := meshcore.GenerateLocalIdentity(rand.Reader)
if err != nil {
diff --git a/internal/web/contribute.go b/internal/core/contribute.go
similarity index 73%
rename from internal/web/contribute.go
rename to internal/core/contribute.go
index 969e87a..bc3af34 100644
--- a/internal/web/contribute.go
+++ b/internal/core/contribute.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"net/http"
@@ -9,20 +9,20 @@ import (
)
// orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to.
-func (s *Server) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
- owner := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
+ owner := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
- orgID, oerr := s.store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
+ orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID"))
if !ok || oerr != nil {
http.NotFound(w, r)
return nil, 0, false
}
- rep, err := s.store.GetRepeaterOwned(r.Context(), owner, id)
+ rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id)
if err != nil {
http.NotFound(w, r)
return nil, 0, false
}
- if _, isMember, err := s.store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
+ if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember {
http.NotFound(w, r) // can only contribute to orgs you belong to
return nil, 0, false
}
@@ -31,27 +31,27 @@ func (s *Server) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repe
// pageContribute shows the org's current permission envelope for the owner to
// review before consenting (also used for re-consent).
-func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) {
rep, orgID, ok := s.orgContext(w, r)
if !ok {
return
}
- org, err := s.store.GetOrg(r.Context(), orgID)
+ org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
http.NotFound(w, r)
return
}
- versionID, version, err := s.store.CurrentVersion(r.Context(), orgID)
+ versionID, version, err := s.Store.CurrentVersion(r.Context(), orgID)
if err != nil {
http.Error(w, "could not load policy", http.StatusInternalServerError)
return
}
- adminIDs, memberIDs, err := s.store.VersionCommandIDs(r.Context(), versionID)
+ adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
if err != nil {
http.Error(w, "could not load policy", http.StatusInternalServerError)
return
}
- catalog, err := s.store.ListCommands(r.Context())
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
@@ -80,10 +80,10 @@ func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) {
// If already contributed and behind the current version, show what changed
// since the owner last consented.
- cvID, contributed, _ := s.store.ConsentedVersionID(r.Context(), orgID, rep.ID)
+ cvID, contributed, _ := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID)
if contributed && cvID != versionID {
- cAdmin, cMember, err1 := s.store.VersionCommandIDs(r.Context(), cvID)
- consentedNum, err2 := s.store.VersionNumber(r.Context(), cvID)
+ cAdmin, cMember, err1 := s.Store.VersionCommandIDs(r.Context(), cvID)
+ consentedNum, err2 := s.Store.VersionNumber(r.Context(), cvID)
if err1 == nil && err2 == nil {
tmpl := map[int64]string{}
for _, c := range catalog {
@@ -95,13 +95,13 @@ func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) {
data["ConsentedVersion"] = consentedNum
data["Added"] = templatesFor(current, consented, tmpl) // newly granted
data["Removed"] = templatesFor(consented, current, tmpl) // no longer granted
- if notes, err := s.store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil {
+ if notes, err := s.Store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil {
data["Notes"] = notes
}
}
}
- s.render(w, r, "contribute.html", data)
+ s.Render(w, r, "contribute.html", data)
}
// union returns the set union of two id sets.
@@ -130,18 +130,18 @@ func templatesFor(in, notIn map[int64]bool, tmpl map[int64]string) []string {
}
// handleContribute pins the repeater to the org's current permission version.
-func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleContribute(w http.ResponseWriter, r *http.Request) {
rep, orgID, ok := s.orgContext(w, r)
if !ok {
return
}
- versionID, _, err := s.store.CurrentVersion(r.Context(), orgID)
+ versionID, _, err := s.Store.CurrentVersion(r.Context(), orgID)
if err != nil {
http.Error(w, "could not load policy", http.StatusInternalServerError)
return
}
- owner := s.auth.CurrentUserID(r.Context())
- if err := s.store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil {
+ owner := s.Auth.CurrentUserID(r.Context())
+ if err := s.Store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil {
http.Error(w, "could not contribute", http.StatusInternalServerError)
return
}
@@ -149,12 +149,12 @@ func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) {
}
// handleWithdraw removes the repeater from the org.
-func (s *Server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleWithdraw(w http.ResponseWriter, r *http.Request) {
rep, orgID, ok := s.orgContext(w, r)
if !ok {
return
}
- if err := s.store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil {
+ if err := s.Store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil {
http.Error(w, "could not withdraw", http.StatusInternalServerError)
return
}
diff --git a/internal/core/handoff_test.go b/internal/core/handoff_test.go
new file mode 100644
index 0000000..77bc5ac
--- /dev/null
+++ b/internal/core/handoff_test.go
@@ -0,0 +1,412 @@
+package core
+
+import (
+ "context"
+ "crypto/rand"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/jleight/meshtender/internal/auth"
+ "github.com/jleight/meshtender/internal/config"
+ "github.com/jleight/meshtender/internal/identity"
+ "github.com/jleight/meshtender/internal/store"
+)
+
+const (
+ testAuthHost = "auth.localhost"
+ testAppHost = "app.localhost"
+ testRootHost = "localhost"
+ testWWWHost = "www.localhost"
+)
+
+// hostEnv holds the host:port for each surface of a split-host test server.
+type hostEnv struct{ auth, app, root, www string }
+
+// splitServer stands up a server in full split-host mode (root/www/auth/app)
+// against the test database, returning the store and the per-surface host:port.
+func splitServer(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv) {
+ t.Helper()
+ dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test")
+ }
+ if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") {
+ t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn)
+ }
+ ctx := context.Background()
+ st, err := store.New(ctx, dsn)
+ if err != nil {
+ t.Fatalf("store: %v", err)
+ }
+ t.Cleanup(st.Close)
+ if err := st.Migrate(ctx); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if _, err := st.Pool().Exec(ctx,
+ `TRUNCATE users, repeaters, server_identity, sessions, auth_codes RESTART IDENTITY CASCADE`); err != nil {
+ t.Fatalf("truncate: %v", err)
+ }
+
+ var masterKey [32]byte
+ _, _ = rand.Read(masterKey[:])
+ idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey)
+ authSvc, err := auth.New(st, st.Pool(), auth.Config{
+ RPID: "localhost", RPDisplayName: "test",
+ RPOrigins: []string{"http://auth.localhost", "http://app.localhost"},
+ AppHost: testAppHost, AuthHost: testAuthHost,
+ })
+ if err != nil {
+ t.Fatalf("auth: %v", err)
+ }
+ srv, err := NewServer(st, authSvc, idSvc, &config.Config{
+ PrimaryHost: testAppHost, AuthHost: testAuthHost,
+ RootHost: testRootHost, WWWHost: testWWWHost,
+ })
+ if err != nil {
+ t.Fatalf("server: %v", err)
+ }
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ port := mustURL(t, ts.URL).Port()
+ hp := func(h string) string { return h + ":" + port }
+ return st, ctx, ts, hostEnv{auth: hp(testAuthHost), app: hp(testAppHost), root: hp(testRootHost), www: hp(testWWWHost)}
+}
+
+// seedSession creates a user and establishes an authenticated app-host session
+// by redeeming a handoff code at /session/callback, storing the session cookie
+// in jar. Integration tests use this instead of the auth-host sign-in UI.
+func seedSession(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Context, jar http.CookieJar, username string) *store.User {
+ t.Helper()
+ u, err := st.CreateUser(ctx, username, "")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ code, err := st.CreateAuthCode(ctx, u.ID, "/")
+ if err != nil {
+ t.Fatalf("create auth code: %v", err)
+ }
+ req, _ := http.NewRequest(http.MethodGet, ts.URL+"/session/callback?code="+code+"&state=s1", nil)
+ req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"})
+ client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("session callback: %v", err)
+ }
+ resp.Body.Close()
+ return u
+}
+
+// noRedirect is a client that surfaces redirects instead of following them.
+func noRedirect() *http.Client {
+ return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+}
+
+// do issues a request to ts with an explicit Host header (so the dispatcher
+// routes by hostname) and optional cookies.
+func do(t *testing.T, ts *httptest.Server, host, path string, cookies ...*http.Cookie) *http.Response {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ req.Host = host
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ resp, err := noRedirect().Do(req)
+ if err != nil {
+ t.Fatalf("do %s%s: %v", host, path, err)
+ }
+ return resp
+}
+
+// post issues a form POST with an explicit Host header and optional cookies.
+func post(t *testing.T, ts *httptest.Server, host, path string, form url.Values, cookies ...*http.Cookie) *http.Response {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ req.Host = host
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ resp, err := noRedirect().Do(req)
+ if err != nil {
+ t.Fatalf("post %s%s: %v", host, path, err)
+ }
+ return resp
+}
+
+func cookieByName(resp *http.Response, name string) *http.Cookie {
+ for _, c := range resp.Cookies() {
+ if c.Name == name {
+ return c
+ }
+ }
+ return nil
+}
+
+// TestRequireUserBouncesToAuthHost: an unauthenticated app-host request to a
+// protected page redirects to the auth host's /login, carrying next and a state
+// nonce that matches the host-only state cookie just set.
+func TestRequireUserBouncesToAuthHost(t *testing.T) {
+ _, _, ts, h := splitServer(t)
+
+ resp := do(t, ts, h.app, "/repeaters")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", resp.StatusCode)
+ }
+ loc, _ := url.Parse(resp.Header.Get("Location"))
+ if loc.Host != h.auth || loc.Path != "/login" {
+ t.Fatalf("Location = %q, want auth host /login", resp.Header.Get("Location"))
+ }
+ if got := loc.Query().Get("next"); got != "/repeaters" {
+ t.Fatalf("next = %q, want /repeaters", got)
+ }
+ state := loc.Query().Get("state")
+ c := cookieByName(resp, "mt_state")
+ if state == "" || c == nil || c.Value != state {
+ t.Fatalf("state cookie %v must match state param %q", c, state)
+ }
+}
+
+// TestAppHostLoginRedirectsToAuth: the credential UI does not live on the app
+// host; /login there bounces to the auth host.
+func TestAppHostLoginRedirectsToAuth(t *testing.T) {
+ _, _, ts, h := splitServer(t)
+ resp := do(t, ts, h.app, "/login")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", resp.StatusCode)
+ }
+ if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth {
+ t.Fatalf("Location host = %q, want %q", loc.Host, h.auth)
+ }
+}
+
+// TestAuthHostServesLogin: the auth host renders the sign-in page in place.
+func TestAuthHostServesLogin(t *testing.T) {
+ _, _, ts, h := splitServer(t)
+ resp := do(t, ts, h.auth, "/login")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+}
+
+// TestHostRouting checks each surface is served on its own host: the root host
+// serves public marketing + org discovery, www redirects to root, and the app
+// host's / and /orgs require auth (bounce to the auth host).
+func TestHostRouting(t *testing.T) {
+ _, _, ts, h := splitServer(t)
+
+ t.Run("root serves landing", func(t *testing.T) {
+ resp := do(t, ts, h.root, "/")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("root / = %d, want 200", resp.StatusCode)
+ }
+ })
+ t.Run("root serves public org directory", func(t *testing.T) {
+ resp := do(t, ts, h.root, "/orgs")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("root /orgs = %d, want 200", resp.StatusCode)
+ }
+ })
+ t.Run("www redirects to root", func(t *testing.T) {
+ resp := do(t, ts, h.www, "/orgs")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusMovedPermanently {
+ t.Fatalf("www /orgs = %d, want 301", resp.StatusCode)
+ }
+ if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.root || loc.Path != "/orgs" {
+ t.Fatalf("www redirect = %q, want root /orgs", resp.Header.Get("Location"))
+ }
+ })
+ t.Run("app root bounces anonymous to auth", func(t *testing.T) {
+ resp := do(t, ts, h.app, "/")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("app / = %d, want 303", resp.StatusCode)
+ }
+ if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth {
+ t.Fatalf("app / redirect host = %q, want auth", loc.Host)
+ }
+ })
+ t.Run("app /orgs requires auth", func(t *testing.T) {
+ resp := do(t, ts, h.app, "/orgs")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("app /orgs anon = %d, want 303 to auth", resp.StatusCode)
+ }
+ })
+}
+
+// TestSingleLogout verifies sign-out clears BOTH sessions: the app host's
+// /logout chains to the auth host, and the auth host's /logout destroys the SSO
+// session so it can't silently re-authenticate.
+func TestSingleLogout(t *testing.T) {
+ st, ctx, ts, h := splitServer(t)
+
+ // An app session (via the handoff) logs out by bouncing to the auth host.
+ u, _ := st.CreateUser(ctx, "logoutuser", "")
+ code, _ := st.CreateAuthCode(ctx, u.ID, "/")
+ cb := do(t, ts, h.app, "/session/callback?code="+code+"&state=s1", &http.Cookie{Name: "mt_state", Value: "s1"})
+ cb.Body.Close()
+ appSess := cookieByName(cb, "meshtender_session")
+ if appSess == nil {
+ t.Fatal("expected an app session from the callback")
+ }
+ out := post(t, ts, h.app, "/logout", url.Values{}, appSess)
+ out.Body.Close()
+ if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Host != h.auth || loc.Path != "/logout" {
+ t.Fatalf("app logout = %d %q, want 303 to auth /logout", out.StatusCode, out.Header.Get("Location"))
+ }
+
+ // Sign up on the auth host to hold an SSO session, then confirm it is live
+ // (auth /login hands off) until /logout, after which it no longer does.
+ su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"ssouser"}, "password": {"supersecret"}})
+ su.Body.Close()
+ sso := cookieByName(su, "meshtender_session")
+ if sso == nil {
+ t.Fatal("expected an SSO session from signup")
+ }
+ live := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
+ live.Body.Close()
+ if live.StatusCode != http.StatusSeeOther {
+ t.Fatalf("authed /login = %d, want 303 handoff (SSO live)", live.StatusCode)
+ }
+ lo := do(t, ts, h.auth, "/logout", sso)
+ lo.Body.Close()
+ if lo.StatusCode != http.StatusSeeOther {
+ t.Fatalf("auth /logout = %d, want 303", lo.StatusCode)
+ }
+ after := do(t, ts, h.auth, "/login", sso)
+ defer after.Body.Close()
+ if after.StatusCode != http.StatusOK {
+ t.Fatalf("post-logout /login = %d, want 200 (SSO cleared, no re-handoff)", after.StatusCode)
+ }
+}
+
+// TestAccountOnAuthHost verifies account/credential management lives on the auth
+// host: the app host no longer serves /account, the auth host guards it with the
+// SSO session, and an SSO-less visit returns LOCALLY to /account after login
+// (rather than handing off to the app).
+func TestAccountOnAuthHost(t *testing.T) {
+ _, _, ts, h := splitServer(t)
+
+ t.Run("app host no longer serves /account", func(t *testing.T) {
+ resp := do(t, ts, h.app, "/account")
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("app /account = %d, want 404", resp.StatusCode)
+ }
+ })
+
+ t.Run("auth /account anon bounces to local login", func(t *testing.T) {
+ resp := do(t, ts, h.auth, "/account")
+ defer resp.Body.Close()
+ loc, _ := url.Parse(resp.Header.Get("Location"))
+ if resp.StatusCode != http.StatusSeeOther || loc.Path != "/login" || loc.Host != "" {
+ t.Fatalf("auth /account anon = %d %q, want 303 to local /login", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ // Completing login carries the auth-local flag, so it returns to /account
+ // on the auth host — NOT a handoff to the app callback.
+ sess := cookieByName(resp, "meshtender_session")
+ if sess == nil {
+ t.Fatal("expected a session cookie carrying the auth-local flag")
+ }
+ fin := post(t, ts, h.auth, "/signup/password",
+ url.Values{"username": {"acctlocal"}, "password": {"supersecret"}}, sess)
+ fin.Body.Close()
+ if got := fin.Header.Get("Location"); got != "/account" {
+ t.Fatalf("post-login redirect = %q, want local /account (no handoff)", got)
+ }
+ })
+
+ t.Run("auth /account with SSO session renders", func(t *testing.T) {
+ su := post(t, ts, h.auth, "/signup/password",
+ url.Values{"username": {"acctsso"}, "password": {"supersecret"}})
+ su.Body.Close()
+ sso := cookieByName(su, "meshtender_session")
+ if sso == nil {
+ t.Fatal("expected an SSO session from signup")
+ }
+ resp := do(t, ts, h.auth, "/account", sso)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("auth /account with SSO = %d, want 200", resp.StatusCode)
+ }
+ })
+}
+
+// TestSessionCallback exercises the handoff redemption: a valid code + matching
+// state establishes an app-host session; tampered state or an unknown code is
+// rejected back to sign-in without a session.
+func TestSessionCallback(t *testing.T) {
+ st, ctx, ts, h := splitServer(t)
+ appHost := h.app
+ u, err := st.CreateUser(ctx, "callbackuser", "")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+
+ t.Run("happy path establishes a session", func(t *testing.T) {
+ code, _ := st.CreateAuthCode(ctx, u.ID, "/repeaters")
+ resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1",
+ &http.Cookie{Name: "mt_state", Value: "s1"})
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", resp.StatusCode)
+ }
+ if loc := resp.Header.Get("Location"); loc != "/repeaters" {
+ t.Fatalf("Location = %q, want /repeaters", loc)
+ }
+ sess := cookieByName(resp, "meshtender_session")
+ if sess == nil || sess.Value == "" {
+ t.Fatalf("expected a session cookie to be set")
+ }
+ // The session must actually authenticate: the protected page now loads.
+ follow := do(t, ts, appHost, "/repeaters", sess)
+ defer follow.Body.Close()
+ if follow.StatusCode != http.StatusOK {
+ t.Fatalf("authenticated /repeaters = %d, want 200", follow.StatusCode)
+ }
+ })
+
+ t.Run("state mismatch is rejected", func(t *testing.T) {
+ code, _ := st.CreateAuthCode(ctx, u.ID, "/repeaters")
+ resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1",
+ &http.Cookie{Name: "mt_state", Value: "different"})
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") == "/repeaters" {
+ t.Fatalf("status=%d loc=%q, want bounce to login", resp.StatusCode, resp.Header.Get("Location"))
+ }
+ if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" {
+ t.Fatalf("no session should be set on state mismatch")
+ }
+ // The code must NOT have been consumed, since state failed first.
+ if _, _, ok, _ := st.ConsumeAuthCode(ctx, code); !ok {
+ t.Fatalf("code should remain valid after a state-mismatch rejection")
+ }
+ })
+
+ t.Run("unknown code is rejected", func(t *testing.T) {
+ resp := do(t, ts, appHost, "/session/callback?code=bogus&state=s1",
+ &http.Cookie{Name: "mt_state", Value: "s1"})
+ defer resp.Body.Close()
+ if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" {
+ t.Fatalf("no session should be set for an unknown code")
+ }
+ })
+}
diff --git a/internal/web/org_domains.go b/internal/core/org_domains.go
similarity index 83%
rename from internal/web/org_domains.go
rename to internal/core/org_domains.go
index 1856613..e292dfe 100644
--- a/internal/web/org_domains.go
+++ b/internal/core/org_domains.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"errors"
@@ -35,7 +35,7 @@ func normalizeHostname(raw string) string {
}
// handleAddOrgDomain registers a new custom domain for an org (admin only).
-func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -45,7 +45,7 @@ func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) {
orgErr(w, r, "Enter a valid domain (e.g. mesh.example.org).")
return
}
- if _, err := s.store.CreateOrgDomain(r.Context(), id, host); errors.Is(err, store.ErrDuplicate) {
+ if _, err := s.Store.CreateOrgDomain(r.Context(), id, host); errors.Is(err, store.ErrDuplicate) {
orgErr(w, r, "That domain is already claimed.")
return
} else if err != nil {
@@ -57,7 +57,7 @@ func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) {
// handleVerifyOrgDomain checks the org's DNS TXT record carries the domain's
// verification token, then marks it verified (admin only).
-func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -67,12 +67,12 @@ func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) {
orgErr(w, r, "Invalid domain.")
return
}
- d, err := s.store.GetOrgDomain(r.Context(), id, domainID)
+ d, err := s.Store.GetOrgDomain(r.Context(), id, domainID)
if err != nil {
orgErr(w, r, "Unknown domain.")
return
}
- records, err := s.lookupTXT(txtRecordPrefix + d.Hostname)
+ records, err := s.LookupTXT(txtRecordPrefix + d.Hostname)
if err != nil {
orgErr(w, r, "Could not read DNS TXT records yet — they may still be propagating.")
return
@@ -81,7 +81,7 @@ func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) {
orgErr(w, r, "TXT record not found yet. Double-check the value, then retry.")
return
}
- if err := s.store.MarkOrgDomainVerified(r.Context(), id, domainID); err != nil {
+ if err := s.Store.MarkOrgDomainVerified(r.Context(), id, domainID); err != nil {
orgErr(w, r, "Could not save verification.")
return
}
@@ -100,7 +100,7 @@ func txtRecordsHaveToken(records []string, token string) bool {
}
// handleDeleteOrgDomain removes a custom domain (admin only).
-func (s *Server) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -110,7 +110,7 @@ func (s *Server) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) {
orgErr(w, r, "Invalid domain.")
return
}
- if err := s.store.DeleteOrgDomain(r.Context(), id, domainID); err != nil {
+ if err := s.Store.DeleteOrgDomain(r.Context(), id, domainID); err != nil {
orgErr(w, r, "Could not remove domain.")
return
}
diff --git a/internal/web/org_domains_test.go b/internal/core/org_domains_test.go
similarity index 87%
rename from internal/web/org_domains_test.go
rename to internal/core/org_domains_test.go
index 153909c..02f3f85 100644
--- a/internal/web/org_domains_test.go
+++ b/internal/core/org_domains_test.go
@@ -1,6 +1,10 @@
-package web
+package core
-import "testing"
+import (
+ "testing"
+
+ "github.com/jleight/meshtender/internal/web"
+)
func TestNormalizeHostname(t *testing.T) {
cases := map[string]string{
@@ -43,8 +47,8 @@ func TestHostWithoutPort(t *testing.T) {
"localhost:8090": "localhost",
}
for in, want := range cases {
- if got := hostWithoutPort(in); got != want {
- t.Errorf("hostWithoutPort(%q) = %q, want %q", in, got, want)
+ if got := web.HostWithoutPort(in); got != want {
+ t.Errorf("web.HostWithoutPort(%q) = %q, want %q", in, got, want)
}
}
}
diff --git a/internal/web/org_permissions.go b/internal/core/org_permissions.go
similarity index 77%
rename from internal/web/org_permissions.go
rename to internal/core/org_permissions.go
index 9280ed2..e10887b 100644
--- a/internal/web/org_permissions.go
+++ b/internal/core/org_permissions.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"net/http"
@@ -37,39 +37,39 @@ func idSet(ids []int64) map[int64]bool {
return m
}
-func (s *Server) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
- org, err := s.store.GetOrg(r.Context(), id)
+ org, err := s.Store.GetOrg(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
- versionID, version, err := s.store.CurrentVersion(r.Context(), id)
+ versionID, version, err := s.Store.CurrentVersion(r.Context(), id)
if err != nil {
http.Error(w, "could not load policy", http.StatusInternalServerError)
return
}
- adminIDs, memberIDs, err := s.store.VersionCommandIDs(r.Context(), versionID)
+ adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID)
if err != nil {
http.Error(w, "could not load policy", http.StatusInternalServerError)
return
}
- catalog, err := s.store.ListCommands(r.Context())
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
- s.render(w, r, "org_permissions.html", map[string]any{
+ s.Render(w, r, "org_permissions.html", map[string]any{
"Org": org,
"Version": version,
"Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)),
})
}
-func (s *Server) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
@@ -87,9 +87,9 @@ func (s *Server) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request
}
return ids
}
- uid := s.auth.CurrentUserID(r.Context())
+ uid := s.Auth.CurrentUserID(r.Context())
note := r.FormValue("note")
- if _, err := s.store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil {
+ if _, err := s.Store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil {
http.Error(w, "could not publish", http.StatusInternalServerError)
return
}
diff --git a/internal/core/orgs.go b/internal/core/orgs.go
new file mode 100644
index 0000000..2c2704f
--- /dev/null
+++ b/internal/core/orgs.go
@@ -0,0 +1,257 @@
+package core
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
+)
+
+// orgID resolves the {id} URL param (a slug) to the internal int64 primary key.
+func (s *Handlers) orgID(r *http.Request) (int64, bool) {
+ id, err := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id"))
+ return id, err == nil
+}
+
+// orgParam returns the raw slug from the {id} URL param, for building redirects.
+func orgParam(r *http.Request) string { return chi.URLParam(r, "id") }
+
+func orgErr(w http.ResponseWriter, r *http.Request, msg string) {
+ web.RedirectErr(w, r, "/orgs/"+orgParam(r), msg)
+}
+
+// pageMyOrgs lists the organizations the signed-in user belongs to (the app
+// host's /orgs). Public discovery lives on the root host; a "Discover" button
+// links there.
+func (s *Handlers) pageMyOrgs(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ mine, err := s.Store.ListOrgsForUser(r.Context(), uid)
+ if err != nil {
+ http.Error(w, "could not load orgs", http.StatusInternalServerError)
+ return
+ }
+ s.Render(w, r, "my_orgs.html", map[string]any{
+ "Orgs": mine,
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// pageNewOrg renders the standalone "create an organization" form.
+func (s *Handlers) pageNewOrg(w http.ResponseWriter, r *http.Request) {
+ s.Render(w, r, "new_org.html", map[string]any{
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// handleCreateOrg creates an org with the current user as its first admin.
+func (s *Handlers) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ name := strings.TrimSpace(r.FormValue("name"))
+ if name == "" || len(name) > 80 {
+ web.RedirectErr(w, r, "/orgs/new", "Enter an organization name.")
+ return
+ }
+ org, err := s.Store.CreateOrg(r.Context(), name, uid)
+ if err != nil {
+ web.RedirectErr(w, r, "/orgs/new", "Could not create organization.")
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
+}
+
+// pageOrg shows an org's home. Members get the full management view; everyone
+// else (anonymous or non-member) gets the public view. Members can preview the
+// public view with ?view=public.
+func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ org, err := s.Store.GetOrg(r.Context(), id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ role, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
+ if !isMember || r.URL.Query().Get("view") == "public" {
+ // Public org content lives on the root host (marketing surface).
+ http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/orgs/"+org.Slug, http.StatusSeeOther)
+ return
+ }
+ members, err := s.Store.ListOrgMembers(r.Context(), id)
+ if err != nil {
+ http.Error(w, "could not load members", http.StatusInternalServerError)
+ return
+ }
+ repeaters, err := s.Store.ListOrgRepeaters(r.Context(), id)
+ if err != nil {
+ http.Error(w, "could not load repeaters", http.StatusInternalServerError)
+ return
+ }
+ mapped := 0
+ for _, rp := range repeaters {
+ if rp.HasLocation {
+ mapped++
+ }
+ }
+ adminCount := 0
+ for _, m := range members {
+ if m.Role == "admin" {
+ adminCount++
+ }
+ }
+ isAdmin := role == "admin"
+ data := map[string]any{
+ "Org": org,
+ "Role": role,
+ "IsAdmin": isAdmin,
+ "Members": members,
+ "Repeaters": repeaters,
+ "HasMap": mapped > 0,
+ "MemberCount": len(members),
+ "RepeaterCount": len(repeaters),
+ "AdminCount": adminCount,
+ "Self": uid,
+ "Error": r.URL.Query().Get("error"),
+ }
+ if isAdmin {
+ domains, err := s.Store.ListOrgDomains(r.Context(), id)
+ if err != nil {
+ http.Error(w, "could not load domains", http.StatusInternalServerError)
+ return
+ }
+ data["Domains"] = domains
+ data["TXTName"] = txtRecordPrefix // org appends its hostname for the full record name
+ }
+ s.Render(w, r, "org.html", data)
+}
+
+// handleEditOrg updates an org's slug, name, description, and region (admin only).
+func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
+ id, ok := s.requireOrgAdmin(w, r)
+ if !ok {
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("name"))
+ slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug")))
+ desc := strings.TrimSpace(r.FormValue("description"))
+ region := strings.TrimSpace(r.FormValue("region"))
+ if name == "" || len(name) > 80 {
+ orgErr(w, r, "Enter an organization name.")
+ return
+ }
+ if !store.ValidOrgSlug(slug) {
+ orgErr(w, r, "Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).")
+ return
+ }
+ if len(desc) > 2000 {
+ desc = desc[:2000]
+ }
+ if len(region) > 120 {
+ region = region[:120]
+ }
+ if err := s.Store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) {
+ orgErr(w, r, "That URL slug is already taken.")
+ return
+ } else if err != nil {
+ orgErr(w, r, "Could not save changes.")
+ return
+ }
+ // The slug may have changed; redirect to the new canonical URL.
+ http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther)
+}
+
+// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
+func (s *Handlers) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return 0, false
+ }
+ admin, err := s.Store.IsOrgAdmin(r.Context(), id, uid)
+ if err != nil || !admin {
+ http.NotFound(w, r)
+ return 0, false
+ }
+ return id, true
+}
+
+func (s *Handlers) handleLeaveOrg(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ err := s.Store.RemoveOrgMember(r.Context(), id, uid)
+ if errors.Is(err, store.ErrLastAdmin) {
+ orgErr(w, r, "You're the last admin — promote someone else first.")
+ return
+ }
+ if err != nil {
+ orgErr(w, r, "Could not leave.")
+ return
+ }
+ http.Redirect(w, r, "/orgs", http.StatusSeeOther)
+}
+
+// handleJoinOrg adds the current user to an org as a member. Orgs are publicly
+// listed and anyone signed in may join directly from the org page (idempotent).
+func (s *Handlers) handleJoinOrg(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if err := s.Store.AddOrgMember(r.Context(), id, uid, "member"); err != nil {
+ orgErr(w, r, "Could not join.")
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther)
+}
+
+// handleSetOrgMember promotes/demotes/removes a member (admin only).
+func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) {
+ id, ok := s.requireOrgAdmin(w, r)
+ if !ok {
+ return
+ }
+ targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ switch r.FormValue("action") {
+ case "promote":
+ err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "admin")
+ case "demote":
+ err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "member")
+ case "remove":
+ err = s.Store.RemoveOrgMember(r.Context(), id, targetID)
+ default:
+ orgErr(w, r, "Unknown action.")
+ return
+ }
+ if errors.Is(err, store.ErrLastAdmin) {
+ orgErr(w, r, "That would leave the org with no admin.")
+ return
+ }
+ if err != nil {
+ orgErr(w, r, "Could not update member.")
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther)
+}
diff --git a/internal/web/presets.go b/internal/core/presets.go
similarity index 99%
rename from internal/web/presets.go
rename to internal/core/presets.go
index e5d172f..3b2623f 100644
--- a/internal/web/presets.go
+++ b/internal/core/presets.go
@@ -1,4 +1,4 @@
-package web
+package core
import "github.com/jleight/meshtender/internal/config"
diff --git a/internal/web/render_test.go b/internal/core/render_test.go
similarity index 84%
rename from internal/web/render_test.go
rename to internal/core/render_test.go
index 973b19e..4206a75 100644
--- a/internal/web/render_test.go
+++ b/internal/core/render_test.go
@@ -1,9 +1,12 @@
-package web
+package core
import (
"io"
"strings"
"testing"
+
+ "github.com/jleight/meshtender/internal/config"
+ "github.com/jleight/meshtender/internal/web"
)
// TestBuildPagesComposeAndExecute verifies every content page is pre-built with
@@ -11,12 +14,13 @@ import (
// or template-resolution error. This guards the startup-time template
// composition (each page redefines content/title/header onto the base set).
func TestBuildPagesComposeAndExecute(t *testing.T) {
- pages, err := buildPages()
+ rn, err := web.NewRenderer(&config.Config{}, templatesFS)
if err != nil {
- t.Fatalf("buildPages: %v", err)
+ t.Fatalf("NewRenderer: %v", err)
}
+ pages := rn.Pages()
if len(pages) == 0 {
- t.Fatal("buildPages returned no pages")
+ t.Fatal("renderer built no pages")
}
// The shared partials are not pages and must not be rendered directly.
for _, name := range []string{"base.html", "icons.html"} {
diff --git a/internal/web/repeaters.go b/internal/core/repeaters.go
similarity index 73%
rename from internal/web/repeaters.go
rename to internal/core/repeaters.go
index e545e42..c1263a8 100644
--- a/internal/web/repeaters.go
+++ b/internal/core/repeaters.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"errors"
@@ -11,25 +11,26 @@ import (
"github.com/jleight/meshtender/internal/config"
"github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
)
// pageAddRepeater drives the add-repeater wizard. Step 1 ("grant") is a
// mandatory acknowledgment that the owner has granted MeshTender admin on the
// repeater; step 2 ("details") collects the repeater's name/key/radio. The two
// post-creation steps (confirm, contribute) live on pageRepeaterAdded.
-func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
step := r.URL.Query().Get("step")
if step != "details" {
step = "grant"
}
- s.render(w, r, "add_repeater.html", map[string]any{
+ s.Render(w, r, "add_repeater.html", map[string]any{
"Step": step,
- "ServerPubKey": s.identity.PublicKeyHex(),
- "SetPermCommand": s.identity.SetPermCommand(),
- "RevokeCommand": s.identity.RevokePermCommand(),
- "Defaults": s.cfg.DefaultRadio,
+ "ServerPubKey": s.Identity.PublicKeyHex(),
+ "SetPermCommand": s.Identity.SetPermCommand(),
+ "RevokeCommand": s.Identity.RevokePermCommand(),
+ "Defaults": s.Cfg.DefaultRadio,
"Presets": radioPresets,
- "DefaultPresetID": defaultPresetID(s.cfg.DefaultRadio),
+ "DefaultPresetID": defaultPresetID(s.Cfg.DefaultRadio),
"Error": r.URL.Query().Get("error"),
})
}
@@ -37,7 +38,7 @@ func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
// pageRepeater shows a repeater's details: status, radio config, location,
// recent activity, and the actions available to the viewer. Owners get full
// management; users with shared/org access see a read-only view plus console.
-func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) {
rep, id, ok := s.requireRepeaterAccess(w, r)
if !ok {
return
@@ -53,12 +54,12 @@ func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) {
"Error": r.URL.Query().Get("error"),
}
if isOwner {
- orgs, err := s.store.ListRepeaterOrgs(r.Context(), id)
+ orgs, err := s.Store.ListRepeaterOrgs(r.Context(), id)
if err != nil {
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
- recent, err := s.store.ListCommandLog(r.Context(), id, 8)
+ recent, err := s.Store.ListCommandLog(r.Context(), id, 8)
if err != nil {
http.Error(w, "could not load activity", http.StatusInternalServerError)
return
@@ -66,37 +67,37 @@ func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) {
data["Orgs"] = orgs
data["Recent"] = recent
}
- s.render(w, r, "repeater.html", data)
+ s.Render(w, r, "repeater.html", data)
}
func addErr(w http.ResponseWriter, r *http.Request, msg string) {
- redirectErr(w, r, "/repeaters/add?step=details", msg)
+ web.RedirectErr(w, r, "/repeaters/add?step=details", msg)
}
// pageRepeaterAdded is the wizard's final two steps for a freshly-added
// repeater: optionally confirm it with a modem now, and optionally contribute
// it to an organization the owner belongs to.
-func (s *Server) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
rep, _, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
- orgs, err := s.store.ListOrgsForUser(r.Context(), uid)
+ orgs, err := s.Store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
- s.render(w, r, "repeater_added.html", map[string]any{
+ s.Render(w, r, "repeater_added.html", map[string]any{
"Repeater": rep,
"Orgs": orgs,
- "RevokeCommand": s.identity.RevokePermCommand(),
+ "RevokeCommand": s.Identity.RevokePermCommand(),
})
}
// handleAddRepeater registers a new repeater (unconfirmed) for the current user.
-func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
pubHex := strings.ToLower(strings.TrimSpace(r.FormValue("public_key")))
@@ -117,7 +118,7 @@ func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
}
storeLocation := r.FormValue("store_location") != ""
- rep, err := s.store.CreateRepeater(r.Context(), &store.Repeater{
+ rep, err := s.Store.CreateRepeater(r.Context(), &store.Repeater{
OwnerID: uid,
Name: name,
PublicKeyHex: pubHex,
@@ -153,30 +154,30 @@ func parseRadioForm(r *http.Request) (freq, bw int64, sf, cr int16, ok bool) {
}
// pageEditRepeater shows the edit form for an owned repeater.
-func (s *Server) pageEditRepeater(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageEditRepeater(w http.ResponseWriter, r *http.Request) {
rep, _, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
- s.render(w, r, "edit_repeater.html", map[string]any{
+ s.Render(w, r, "edit_repeater.html", map[string]any{
"Repeater": rep,
"Presets": radioPresets,
"SelectedPreset": defaultPresetID(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}),
- "RevokeCommand": s.identity.RevokePermCommand(),
+ "RevokeCommand": s.Identity.RevokePermCommand(),
"Error": r.URL.Query().Get("error"),
})
}
// handleEditRepeater saves changes to an owned repeater's settings.
-func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
if !ok {
http.NotFound(w, r)
return
}
editErr := func(msg string) {
- redirectErr(w, r, "/repeaters/"+repeaterParam(r)+"/edit", msg)
+ web.RedirectErr(w, r, "/repeaters/"+repeaterParam(r)+"/edit", msg)
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
@@ -190,7 +191,7 @@ func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
}
storeLocation := r.FormValue("store_location") != ""
publicMap := r.FormValue("public_map") != ""
- if err := s.store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation, publicMap); err != nil {
+ if err := s.Store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation, publicMap); err != nil {
editErr("Could not save changes.")
return
}
@@ -200,26 +201,26 @@ func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
// pageDeleteRepeater shows a confirmation page before deleting, reminding the
// owner that removing the repeater here does not revoke MeshTender's access on
// the device.
-func (s *Server) pageDeleteRepeater(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageDeleteRepeater(w http.ResponseWriter, r *http.Request) {
rep, _, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
- s.render(w, r, "delete_repeater.html", map[string]any{
+ s.Render(w, r, "delete_repeater.html", map[string]any{
"Repeater": rep,
- "RevokeCommand": s.identity.RevokePermCommand(),
+ "RevokeCommand": s.Identity.RevokePermCommand(),
})
}
// handleDeleteRepeater removes a repeater the current user owns.
-func (s *Server) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
if !ok {
dashErr(w, r, "Invalid repeater.")
return
}
- if err := s.store.DeleteRepeaterOwned(r.Context(), uid, id); err != nil {
+ if err := s.Store.DeleteRepeaterOwned(r.Context(), uid, id); err != nil {
dashErr(w, r, "Could not delete repeater (only the owner can).")
return
}
@@ -227,5 +228,5 @@ func (s *Server) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) {
}
func dashErr(w http.ResponseWriter, r *http.Request, msg string) {
- redirectErr(w, r, "/", msg)
+ web.RedirectErr(w, r, "/", msg)
}
diff --git a/internal/web/shares.go b/internal/core/shares.go
similarity index 73%
rename from internal/web/shares.go
rename to internal/core/shares.go
index fca33da..1a8fe15 100644
--- a/internal/web/shares.go
+++ b/internal/core/shares.go
@@ -1,4 +1,4 @@
-package web
+package core
import (
"context"
@@ -10,34 +10,35 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
)
// pageShare renders the sharing page for a repeater the user owns: the current
// share link (if any) and the list of people who have accepted.
-func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
+func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
rep, id, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
- shares, err := s.store.ListShares(r.Context(), id)
+ shares, err := s.Store.ListShares(r.Context(), id)
if err != nil {
http.Error(w, "could not load shares", http.StatusInternalServerError)
return
}
- invites, err := s.store.ListInvites(r.Context(), id)
+ invites, err := s.Store.ListInvites(r.Context(), id)
if err != nil {
http.Error(w, "could not load links", http.StatusInternalServerError)
return
}
// Organizations section: orgs this repeater is contributed to, plus orgs the
// owner belongs to but hasn't contributed it to yet.
- contributed, err := s.store.ListRepeaterOrgs(r.Context(), id)
+ contributed, err := s.Store.ListRepeaterOrgs(r.Context(), id)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
- memberships, err := s.store.ListOrgsForUser(r.Context(), uid)
+ memberships, err := s.Store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load memberships", http.StatusInternalServerError)
return
@@ -52,7 +53,7 @@ func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) {
available = append(available, m.Org)
}
}
- s.render(w, r, "share.html", map[string]any{
+ s.Render(w, r, "share.html", map[string]any{
"Repeater": rep,
"Shares": shares,
"Invites": invites,
@@ -64,7 +65,7 @@ func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) {
}
// handleCreateLink mints a new single-use share link with a description (owner only).
-func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleCreateLink(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOwned(w, r)
if !ok {
return
@@ -73,7 +74,7 @@ func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) {
if len(description) > 100 {
description = description[:100]
}
- if _, err := s.store.CreateInvite(r.Context(), id, description); err != nil {
+ if _, err := s.Store.CreateInvite(r.Context(), id, description); err != nil {
shareErr(w, r, "Could not create share link.")
return
}
@@ -81,7 +82,7 @@ func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) {
}
// handleDeleteInvite revokes (or clears) a single share link by id (owner only).
-func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOwned(w, r)
if !ok {
return
@@ -91,7 +92,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
shareErr(w, r, "Invalid link.")
return
}
- if err := s.store.DeleteInvite(r.Context(), id, inviteID); err != nil {
+ if err := s.Store.DeleteInvite(r.Context(), id, inviteID); err != nil {
shareErr(w, r, "Could not remove link.")
return
}
@@ -99,7 +100,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
}
// handleUnshare revokes a user's access (owner only).
-func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleUnshare(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOwned(w, r)
if !ok {
return
@@ -109,7 +110,7 @@ func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) {
shareErr(w, r, "Invalid user.")
return
}
- if err := s.store.RemoveShare(r.Context(), id, targetID); err != nil {
+ if err := s.Store.RemoveShare(r.Context(), id, targetID); err != nil {
shareErr(w, r, "Could not revoke access.")
return
}
@@ -121,14 +122,14 @@ func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) {
// pageInvite shows the accept page for a share link. It handles logged-out
// users (offer login/register), the owner, already-shared users, and the
// confirm-and-accept case.
-func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageInvite(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
- uid := s.auth.CurrentUserID(r.Context())
+ uid := s.Auth.CurrentUserID(r.Context())
// queryUserID drives the Shared flag; 0 when logged out is fine.
- rep, err := s.store.RepeaterByInviteToken(r.Context(), uid, token)
+ rep, err := s.Store.RepeaterByInviteToken(r.Context(), uid, token)
if errors.Is(err, store.ErrNotFound) {
- s.render(w, r, "invite.html", map[string]any{"State": "invalid"})
+ s.Render(w, r, "invite.html", map[string]any{"State": "invalid"})
return
}
if err != nil {
@@ -144,7 +145,7 @@ func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) {
case rep.OwnerID == uid:
data["State"] = "owner"
default:
- shared, err := s.store.IsShared(r.Context(), rep.ID, uid)
+ shared, err := s.Store.IsShared(r.Context(), rep.ID, uid)
if err != nil {
http.Error(w, "could not check access", http.StatusInternalServerError)
return
@@ -155,18 +156,18 @@ func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) {
data["State"] = "confirm"
}
}
- s.render(w, r, "invite.html", data)
+ s.Render(w, r, "invite.html", data)
}
// handleAcceptInvite grants the logged-in user access via a single-use share
// link, consuming the link atomically.
-func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
- uid := s.auth.CurrentUserID(r.Context())
+ uid := s.Auth.CurrentUserID(r.Context())
- rep, err := s.store.RepeaterByInviteToken(r.Context(), uid, token)
+ rep, err := s.Store.RepeaterByInviteToken(r.Context(), uid, token)
if errors.Is(err, store.ErrNotFound) {
- s.render(w, r, "invite.html", map[string]any{"State": "invalid"})
+ s.Render(w, r, "invite.html", map[string]any{"State": "invalid"})
return
}
if err != nil {
@@ -179,27 +180,27 @@ func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
- if shared, err := s.store.IsShared(r.Context(), rep.ID, uid); err == nil && shared {
+ if shared, err := s.Store.IsShared(r.Context(), rep.ID, uid); err == nil && shared {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Atomically consume the link (single-use guard against concurrent accepts).
- if _, err := s.store.ConsumeInvite(r.Context(), token, uid); errors.Is(err, store.ErrNotFound) {
- s.render(w, r, "invite.html", map[string]any{"State": "invalid"})
+ if _, err := s.Store.ConsumeInvite(r.Context(), token, uid); errors.Is(err, store.ErrNotFound) {
+ s.Render(w, r, "invite.html", map[string]any{"State": "invalid"})
return
} else if err != nil {
http.Error(w, "could not accept invite", http.StatusInternalServerError)
return
}
- added, err := s.store.AddShare(r.Context(), rep.ID, uid)
+ added, err := s.Store.AddShare(r.Context(), rep.ID, uid)
if err != nil {
http.Error(w, "could not accept invite", http.StatusInternalServerError)
return
}
if added {
// Seed the new share with the default command set; owner can adjust.
- _ = s.store.SeedShareCommands(r.Context(), rep.ID, uid)
+ _ = s.Store.SeedShareCommands(r.Context(), rep.ID, uid)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@@ -226,7 +227,7 @@ func groupCommands(catalog []*store.Command, checked map[int64]bool) []commandGr
}
// pageShareCommands lets a repeater owner choose which commands a shared user may run.
-func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) pageShareCommands(w http.ResponseWriter, r *http.Request) {
rep, id, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
@@ -236,26 +237,26 @@ func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- if shared, err := s.store.IsShared(r.Context(), id, targetID); err != nil || !shared {
+ if shared, err := s.Store.IsShared(r.Context(), id, targetID); err != nil || !shared {
http.NotFound(w, r)
return
}
- target, err := s.store.GetUserByID(r.Context(), targetID)
+ target, err := s.Store.GetUserByID(r.Context(), targetID)
if err != nil {
http.NotFound(w, r)
return
}
- catalog, err := s.store.ListCommands(r.Context())
+ catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
http.Error(w, "could not load commands", http.StatusInternalServerError)
return
}
- ids, _ := s.store.ListShareCommandIDs(r.Context(), id, targetID)
+ ids, _ := s.Store.ListShareCommandIDs(r.Context(), id, targetID)
checked := make(map[int64]bool, len(ids))
for _, cid := range ids {
checked[cid] = true
}
- s.render(w, r, "share_commands.html", map[string]any{
+ s.Render(w, r, "share_commands.html", map[string]any{
"Repeater": rep,
"Target": target,
"Groups": groupCommands(catalog, checked),
@@ -263,7 +264,7 @@ func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) {
}
// handleSetShareCommands saves the chosen command set for a shared user.
-func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request) {
+func (s *Handlers) handleSetShareCommands(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOwned(w, r)
if !ok {
return
@@ -273,7 +274,7 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request)
http.NotFound(w, r)
return
}
- if shared, err := s.store.IsShared(r.Context(), id, targetID); err != nil || !shared {
+ if shared, err := s.Store.IsShared(r.Context(), id, targetID); err != nil || !shared {
http.NotFound(w, r)
return
}
@@ -287,7 +288,7 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request)
cmdIDs = append(cmdIDs, cid)
}
}
- if err := s.store.SetShareCommands(r.Context(), id, targetID, cmdIDs); err != nil {
+ if err := s.Store.SetShareCommands(r.Context(), id, targetID, cmdIDs); err != nil {
http.Error(w, "could not save commands", http.StatusInternalServerError)
return
}
@@ -300,21 +301,21 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request)
// current user owns it or has shared/org access, writing the response (404 for an
// unknown id or no access, 500 on an unexpected error) and returning ok=false
// otherwise. It is the control-action gate for pages any authorized user can see.
-func (s *Server) requireRepeaterAccess(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
- return s.loadRepeater(w, r, s.store.GetRepeaterForUser)
+func (s *Handlers) requireRepeaterAccess(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
+ return s.loadRepeater(w, r, s.Store.GetRepeaterForUser)
}
// requireRepeaterOwned is the owner-only variant of requireRepeaterAccess.
-func (s *Server) requireRepeaterOwned(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
- return s.loadRepeater(w, r, s.store.GetRepeaterOwned)
+func (s *Handlers) requireRepeaterOwned(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
+ return s.loadRepeater(w, r, s.Store.GetRepeaterOwned)
}
// loadRepeater resolves {id} and loads the repeater via get (an access-scoped
// lookup), mapping ErrNotFound to 404 and other failures to 500.
-func (s *Server) loadRepeater(w http.ResponseWriter, r *http.Request,
+func (s *Handlers) loadRepeater(w http.ResponseWriter, r *http.Request,
get func(ctx context.Context, userID, repeaterID int64) (*store.Repeater, error),
) (*store.Repeater, int64, bool) {
- uid := s.auth.CurrentUserID(r.Context())
+ uid := s.Auth.CurrentUserID(r.Context())
id, ok := s.repeaterID(r)
if !ok {
http.NotFound(w, r)
@@ -333,15 +334,15 @@ func (s *Server) loadRepeater(w http.ResponseWriter, r *http.Request,
}
// requireOwned is requireRepeaterOwned for callers that only need the id.
-func (s *Server) requireOwned(w http.ResponseWriter, r *http.Request) (int64, bool) {
+func (s *Handlers) requireOwned(w http.ResponseWriter, r *http.Request) (int64, bool) {
_, id, ok := s.requireRepeaterOwned(w, r)
return id, ok
}
// repeaterID resolves the opaque {id} URL param (a public_id) to the internal
// int64 primary key used by the store. Returns ok=false for unknown ids.
-func (s *Server) repeaterID(r *http.Request) (int64, bool) {
- id, err := s.store.RepeaterIDByPublicID(r.Context(), chi.URLParam(r, "id"))
+func (s *Handlers) repeaterID(r *http.Request) (int64, bool) {
+ id, err := s.Store.RepeaterIDByPublicID(r.Context(), chi.URLParam(r, "id"))
return id, err == nil
}
@@ -352,11 +353,11 @@ func repeaterParam(r *http.Request) string { return chi.URLParam(r, "id") }
func sharePath(publicID string) string { return "/repeaters/" + publicID + "/share" }
func shareErr(w http.ResponseWriter, r *http.Request, msg string) {
- redirectErr(w, r, sharePath(repeaterParam(r)), msg)
+ web.RedirectErr(w, r, sharePath(repeaterParam(r)), msg)
}
// absoluteURL builds an absolute URL for a path using the request's scheme/host.
-func (s *Server) absoluteURL(r *http.Request, path string) string {
+func (s *Handlers) absoluteURL(r *http.Request, path string) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
diff --git a/internal/web/templates/add_repeater.html b/internal/core/templates/add_repeater.html
similarity index 100%
rename from internal/web/templates/add_repeater.html
rename to internal/core/templates/add_repeater.html
diff --git a/internal/web/templates/admin.html b/internal/core/templates/admin.html
similarity index 100%
rename from internal/web/templates/admin.html
rename to internal/core/templates/admin.html
diff --git a/internal/web/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html
similarity index 100%
rename from internal/web/templates/admin_catalog.html
rename to internal/core/templates/admin_catalog.html
diff --git a/internal/web/templates/admin_users.html b/internal/core/templates/admin_users.html
similarity index 100%
rename from internal/web/templates/admin_users.html
rename to internal/core/templates/admin_users.html
diff --git a/internal/web/templates/command_log.html b/internal/core/templates/command_log.html
similarity index 100%
rename from internal/web/templates/command_log.html
rename to internal/core/templates/command_log.html
diff --git a/internal/web/templates/confirm.html b/internal/core/templates/confirm.html
similarity index 100%
rename from internal/web/templates/confirm.html
rename to internal/core/templates/confirm.html
diff --git a/internal/web/templates/console.html b/internal/core/templates/console.html
similarity index 100%
rename from internal/web/templates/console.html
rename to internal/core/templates/console.html
diff --git a/internal/web/templates/contribute.html b/internal/core/templates/contribute.html
similarity index 100%
rename from internal/web/templates/contribute.html
rename to internal/core/templates/contribute.html
diff --git a/internal/web/templates/dashboard.html b/internal/core/templates/dashboard.html
similarity index 100%
rename from internal/web/templates/dashboard.html
rename to internal/core/templates/dashboard.html
diff --git a/internal/web/templates/delete_repeater.html b/internal/core/templates/delete_repeater.html
similarity index 100%
rename from internal/web/templates/delete_repeater.html
rename to internal/core/templates/delete_repeater.html
diff --git a/internal/web/templates/edit_repeater.html b/internal/core/templates/edit_repeater.html
similarity index 100%
rename from internal/web/templates/edit_repeater.html
rename to internal/core/templates/edit_repeater.html
diff --git a/internal/web/templates/invite.html b/internal/core/templates/invite.html
similarity index 100%
rename from internal/web/templates/invite.html
rename to internal/core/templates/invite.html
diff --git a/internal/core/templates/my_orgs.html b/internal/core/templates/my_orgs.html
new file mode 100644
index 0000000..630c4ab
--- /dev/null
+++ b/internal/core/templates/my_orgs.html
@@ -0,0 +1,39 @@
+{{define "title"}}Your organizations · MeshTender{{end}}
+{{define "header"}}
+
+{{end}}
+{{define "content"}}
+{{if .Error}}{{.Error}}
{{end}}
+
+ {{if .Orgs}}
+
+ {{else}}
+
+
You're not a member of any organizations yet.
+
+
+ {{end}}
+
+{{end}}
diff --git a/internal/web/templates/new_org.html b/internal/core/templates/new_org.html
similarity index 100%
rename from internal/web/templates/new_org.html
rename to internal/core/templates/new_org.html
diff --git a/internal/web/templates/org.html b/internal/core/templates/org.html
similarity index 100%
rename from internal/web/templates/org.html
rename to internal/core/templates/org.html
diff --git a/internal/web/templates/org_permissions.html b/internal/core/templates/org_permissions.html
similarity index 100%
rename from internal/web/templates/org_permissions.html
rename to internal/core/templates/org_permissions.html
diff --git a/internal/web/templates/repeater.html b/internal/core/templates/repeater.html
similarity index 100%
rename from internal/web/templates/repeater.html
rename to internal/core/templates/repeater.html
diff --git a/internal/web/templates/repeater_added.html b/internal/core/templates/repeater_added.html
similarity index 100%
rename from internal/web/templates/repeater_added.html
rename to internal/core/templates/repeater_added.html
diff --git a/internal/web/templates/repeaters.html b/internal/core/templates/repeaters.html
similarity index 100%
rename from internal/web/templates/repeaters.html
rename to internal/core/templates/repeaters.html
diff --git a/internal/web/templates/share.html b/internal/core/templates/share.html
similarity index 100%
rename from internal/web/templates/share.html
rename to internal/core/templates/share.html
diff --git a/internal/web/templates/share_commands.html b/internal/core/templates/share_commands.html
similarity index 100%
rename from internal/web/templates/share_commands.html
rename to internal/core/templates/share_commands.html
diff --git a/internal/core/web.go b/internal/core/web.go
new file mode 100644
index 0000000..05db28b
--- /dev/null
+++ b/internal/core/web.go
@@ -0,0 +1,301 @@
+// Package core is the application (app host) surface — the authenticated product
+// — and the composition root that assembles the auth, marketing, and core
+// surfaces behind the host dispatcher.
+package core
+
+import (
+ "context"
+ "embed"
+ "net"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/jleight/meshtender/internal/auth"
+ "github.com/jleight/meshtender/internal/config"
+ "github.com/jleight/meshtender/internal/identity"
+ "github.com/jleight/meshtender/internal/marketing"
+ "github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// Handlers carries the shared environment plus the auth service for the app
+// surface's HTTP handlers.
+type Handlers struct {
+ *web.Env
+ Auth *auth.Service
+}
+
+// Server is the built HTTP entry point.
+type Server struct{ handler http.Handler }
+
+// Handler returns the root HTTP handler.
+func (s *Server) Handler() http.Handler { return s.handler }
+
+// NewServer assembles every surface and the host dispatcher. The deployment is
+// always split-host (auth / root / app); the auth host serves sign-in + account,
+// the root host serves marketing + public org discovery, and the app host (plus
+// custom org domains and any unrecognized host) serves the product.
+func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, cfg *config.Config) (*Server, error) {
+ deps := web.Deps{
+ Store: st,
+ Identity: idSvc,
+ Cfg: cfg,
+ UserInfo: func(ctx context.Context) (string, bool, bool) {
+ uid := authSvc.CurrentUserID(ctx)
+ if uid == 0 {
+ return "", false, false
+ }
+ u, err := st.GetUserByID(ctx, uid)
+ if err != nil {
+ return "", false, false
+ }
+ return u.Name(), u.CapManageUsers || u.CapManageCatalog, true
+ },
+ LookupTXT: net.LookupTXT,
+ }
+
+ coreEnv, err := web.NewEnv(deps, templatesFS)
+ if err != nil {
+ return nil, err
+ }
+ app := &Handlers{Env: coreEnv, Auth: authSvc}
+
+ authH, err := auth.NewWeb(deps, authSvc)
+ if err != nil {
+ return nil, err
+ }
+ mkH, err := marketing.New(deps, authSvc)
+ if err != nil {
+ return nil, err
+ }
+
+ // Custom org domains and unrecognized hosts fall through to the app router,
+ // wrapped by marketing's custom-domain interceptor (it serves verified hosts'
+ // public org pages).
+ appHandler := mkH.CustomDomain(app.appRouter())
+ handler := web.Dispatcher(cfg, authH.Routes(), mkH.Routes(), appHandler)
+ return &Server{handler: handler}, nil
+}
+
+// baseMW applies the shared middleware plus session loading.
+func (s *Handlers) baseMW(r chi.Router) {
+ s.CommonMiddleware(r)
+ r.Use(s.Auth.Sessions.LoadAndSave)
+}
+
+// redirectToAuthLogin / redirectToAuthSignup are the app-host entry points that
+// initiate the auth handoff (setting the state cookie) and bounce to the auth
+// host. Cross-host "sign in"/"create account" CTAs target these, never the auth
+// host directly, so the state cookie is always established first.
+func (s *Handlers) redirectToAuthLogin(w http.ResponseWriter, r *http.Request) {
+ s.Auth.StartLogin(w, r, r.URL.Query().Get("next"))
+}
+
+func (s *Handlers) redirectToAuthSignup(w http.ResponseWriter, r *http.Request) {
+ s.Auth.StartSignup(w, r, r.URL.Query().Get("next"))
+}
+
+// appRouter is the product surface served on the app host.
+func (s *Handlers) appRouter() chi.Router {
+ r := chi.NewRouter()
+ s.baseMW(r)
+ s.SharedRoutes(r)
+
+ // The handoff endpoint that turns an auth-host sign-in into an app session.
+ r.Get("/session/callback", s.Auth.SessionCallback)
+ // Credential UI lives on the auth host; these initiate the handoff there.
+ r.Get("/login", s.redirectToAuthLogin)
+ r.Get("/signup", s.redirectToAuthSignup)
+
+ r.Get("/", s.pageHome)
+ r.Get("/invite/{token}", s.pageInvite) // public: handles logged-out state
+
+ // Authenticated area.
+ r.Group(func(r chi.Router) {
+ r.Use(s.Auth.RequireUser)
+ r.Get("/orgs", s.pageMyOrgs) // your organizations; discovery is on root
+ r.Get("/orgs/{id}", s.pageOrg) // member view (non-members redirect to root)
+ r.Get("/repeaters", s.pageRepeaters)
+ r.Post("/logout", s.handleLogout)
+ r.Get("/repeaters/add", s.pageAddRepeater)
+ r.Post("/repeaters", s.handleAddRepeater)
+ r.Get("/repeaters/{id}", s.pageRepeater)
+ r.Get("/repeaters/{id}/added", s.pageRepeaterAdded)
+ r.Get("/repeaters/{id}/edit", s.pageEditRepeater)
+ r.Post("/repeaters/{id}/edit", s.handleEditRepeater)
+ r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater)
+ r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater)
+ r.Get("/repeaters/{id}/confirm", s.pageConfirm)
+ r.Get("/repeaters/{id}/ws", s.wsConfirm)
+ r.Get("/repeaters/{id}/console", s.pageConsole)
+ r.Get("/repeaters/{id}/console/ws", s.wsConsole)
+ r.Get("/repeaters/{id}/log", s.pageCommandLog)
+ r.Get("/repeaters/{id}/share", s.pageShare)
+ r.Post("/repeaters/{id}/share/link", s.handleCreateLink)
+ r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite)
+ r.Post("/repeaters/{id}/unshare", s.handleUnshare)
+ r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
+ r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
+ r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute)
+ r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute)
+ r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
+ r.Post("/invite/{token}/accept", s.handleAcceptInvite)
+
+ r.Get("/orgs/new", s.pageNewOrg)
+ r.Post("/orgs", s.handleCreateOrg)
+ r.Post("/orgs/{id}/edit", s.handleEditOrg)
+ r.Post("/orgs/{id}/join", s.handleJoinOrg)
+ r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
+ r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember)
+ r.Get("/orgs/{id}/permissions", s.pageOrgPermissions)
+ r.Post("/orgs/{id}/permissions", s.handleSaveOrgPermissions)
+ r.Post("/orgs/{id}/domains", s.handleAddOrgDomain)
+ r.Post("/orgs/{id}/domains/verify", s.handleVerifyOrgDomain)
+ r.Post("/orgs/{id}/domains/delete", s.handleDeleteOrgDomain)
+
+ r.Route("/admin", func(r chi.Router) {
+ r.With(s.requireCap(capAny)).Get("/", s.pageAdmin)
+ r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog)
+ r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand)
+ r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers)
+ r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps)
+ })
+ })
+
+ return r
+}
+
+// pageHome serves the signed-in dashboard; anonymous visitors are sent to sign
+// in on the auth host.
+func (s *Handlers) pageHome(w http.ResponseWriter, r *http.Request) {
+ if s.Auth.CurrentUserID(r.Context()) == 0 {
+ s.Auth.StartLogin(w, r, "/")
+ return
+ }
+ s.pageDashboard(w, r)
+}
+
+// pageRepeaters lists every repeater the user owns or has been shared, split
+// into owned and shared sections.
+func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ repeaters, err := s.Store.ListRepeatersForUser(r.Context(), uid)
+ if err != nil {
+ http.Error(w, "could not load repeaters", http.StatusInternalServerError)
+ return
+ }
+ reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(r.Context(), uid)
+ if err != nil {
+ http.Error(w, "could not load org state", http.StatusInternalServerError)
+ return
+ }
+ shareCounts, err := s.Store.RepeaterSharingCounts(r.Context(), uid)
+ if err != nil {
+ http.Error(w, "could not load sharing", http.StatusInternalServerError)
+ return
+ }
+ owned, shared := splitOwnedShared(repeaters)
+ s.Render(w, r, "repeaters.html", map[string]any{
+ "Owned": owned,
+ "Shared": shared,
+ "Reconsent": reconsent,
+ "ShareCounts": shareCounts,
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// pageDashboard renders the signed-in overview: summary stats, short lists of
+// repeaters and organizations, a map of owned repeaters, and recent activity.
+func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ uid := s.Auth.CurrentUserID(ctx)
+
+ repeaters, err := s.Store.ListRepeatersForUser(ctx, uid)
+ if err != nil {
+ http.Error(w, "could not load repeaters", http.StatusInternalServerError)
+ return
+ }
+ owned, shared := splitOwnedShared(repeaters)
+
+ reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(ctx, uid)
+ if err != nil {
+ http.Error(w, "could not load org state", http.StatusInternalServerError)
+ return
+ }
+ orgs, err := s.Store.ListOrgsForUser(ctx, uid)
+ if err != nil {
+ http.Error(w, "could not load organizations", http.StatusInternalServerError)
+ return
+ }
+ recent, err := s.Store.ListRecentCommandsForOwner(ctx, uid, 8)
+ if err != nil {
+ http.Error(w, "could not load activity", http.StatusInternalServerError)
+ return
+ }
+ shareCounts, err := s.Store.RepeaterSharingCounts(ctx, uid)
+ if err != nil {
+ http.Error(w, "could not load sharing", http.StatusInternalServerError)
+ return
+ }
+
+ confirmed, unconfirmed := 0, 0
+ var mapped []*store.Repeater
+ for _, rp := range owned {
+ if rp.Confirmed {
+ confirmed++
+ } else {
+ unconfirmed++
+ }
+ if rp.Latitude != nil && rp.Longitude != nil {
+ mapped = append(mapped, rp)
+ }
+ }
+
+ s.Render(w, r, "dashboard.html", map[string]any{
+ "OwnedCount": len(owned),
+ "SharedCount": len(shared),
+ "OrgCount": len(orgs),
+ "Confirmed": confirmed,
+ "Unconfirmed": unconfirmed,
+ "Owned": first(owned, 5),
+ "Shared": first(shared, 5),
+ "Orgs": first(orgs, 5),
+ "Mapped": mapped,
+ "Recent": recent,
+ "Reconsent": reconsent,
+ "ShareCounts": shareCounts,
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// splitOwnedShared partitions a repeater list into owned and directly-shared.
+func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) {
+ for _, rp := range repeaters {
+ if rp.Shared {
+ shared = append(shared, rp)
+ } else {
+ owned = append(owned, rp)
+ }
+ }
+ return owned, shared
+}
+
+// first returns the first n elements of s, or all of them if there are fewer.
+func first[T any](s []T, n int) []T {
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+// handleLogout signs the user out of the app host, then chains to the auth host's
+// /logout to clear the SSO session too — otherwise the surviving SSO session
+// would silently re-authenticate on the next request.
+func (s *Handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
+ _ = s.Auth.Logout(r.Context())
+ http.Redirect(w, r, s.Origin(r, s.Cfg.AuthHost)+"/logout", http.StatusSeeOther)
+}
diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go
new file mode 100644
index 0000000..4bcdc25
--- /dev/null
+++ b/internal/marketing/marketing.go
@@ -0,0 +1,99 @@
+// Package marketing is the public surface served on the root (apex) host: the
+// marketing landing page and organization discovery. It carries no session
+// (cookies are host-only), so every page renders the anonymous/public view. It
+// also serves verified custom org domains.
+package marketing
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/jleight/meshtender/internal/auth"
+ "github.com/jleight/meshtender/internal/web"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+// Handlers serve the public marketing/discovery pages.
+type Handlers struct {
+ *web.Env
+ Auth *auth.Service
+}
+
+// New builds the marketing handlers with their own renderer (marketing pages
+// composed onto the shared base layout).
+func New(deps web.Deps, svc *auth.Service) (*Handlers, error) {
+ env, err := web.NewEnv(deps, templatesFS)
+ if err != nil {
+ return nil, err
+ }
+ return &Handlers{Env: env, Auth: svc}, nil
+}
+
+// Routes is the root host's router.
+func (s *Handlers) Routes() chi.Router {
+ r := chi.NewRouter()
+ s.CommonMiddleware(r)
+ r.Use(s.Auth.Sessions.LoadAndSave)
+ s.SharedRoutes(r)
+ r.Get("/", s.pageLanding)
+ r.Get("/orgs", s.pageOrgs) // public organization directory
+ r.Get("/orgs/{id}", s.pageOrgPublic) // public org page
+ return r
+}
+
+// pageLanding renders the public marketing page.
+func (s *Handlers) pageLanding(w http.ResponseWriter, r *http.Request) {
+ s.Render(w, r, "landing.html", map[string]any{"Layout": "landingbase"})
+}
+
+// pageOrgPublic renders an organization's public page (resolved by slug).
+func (s *Handlers) pageOrgPublic(w http.ResponseWriter, r *http.Request) {
+ id, ok := s.orgID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ org, err := s.Store.GetOrg(r.Context(), id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ uid := s.Auth.CurrentUserID(r.Context())
+ _, isMember, _ := s.Store.OrgRole(r.Context(), id, uid)
+ s.renderOrgPublic(w, r, org, isMember)
+}
+
+// CustomDomain serves an org's public page when the request arrives on one of
+// the org's verified custom domains. Any non-root path is redirected to the app
+// on the canonical host. Used as the dispatcher's unknown-host fallback (it
+// wraps the app router).
+func (s *Handlers) CustomDomain(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := web.HostWithoutPort(r.Host)
+ if host == "" || host == "localhost" || strings.EqualFold(host, s.Cfg.PrimaryHost) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ org, ok, err := s.Store.OrgByVerifiedDomain(r.Context(), strings.ToLower(host))
+ if err != nil || !ok {
+ next.ServeHTTP(w, r) // unknown host: serve the app normally
+ return
+ }
+ if r.URL.Path == "/" {
+ s.renderOrgPublic(w, r, org, false)
+ return
+ }
+ http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+r.URL.RequestURI(), http.StatusFound)
+ })
+}
+
+// orgID resolves the {id} URL param (a slug) to the internal int64 primary key.
+func (s *Handlers) orgID(r *http.Request) (int64, bool) {
+ id, err := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id"))
+ return id, err == nil
+}
diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go
new file mode 100644
index 0000000..5c9ccea
--- /dev/null
+++ b/internal/marketing/orgs.go
@@ -0,0 +1,155 @@
+package marketing
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/jleight/meshtender/internal/store"
+)
+
+// pageOrgs is the public organization directory. Everyone sees the list; signed-
+// in users also get a marker on orgs they belong to. The directory is keyset-
+// paginated via an opaque ?cursor token.
+func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
+ uid := s.Auth.CurrentUserID(r.Context())
+ q := r.URL.Query()
+
+ sortKey := store.NormalizeOrgSort(q.Get("sort"))
+ query := strings.TrimSpace(q.Get("q"))
+ if len(query) > 100 {
+ query = query[:100]
+ }
+
+ var p store.OrgListParams
+ // A cursor carries the authoritative sort, search, and keyset position for
+ // the page it points at, so paging stays consistent without re-sending the
+ // form; the sort/q params only matter on the first (cursorless) page.
+ if c, ok := decodeOrgCursor(q.Get("cursor")); ok {
+ sortKey = store.NormalizeOrgSort(c.Sort)
+ query = c.Query
+ p.HasCursor = true
+ p.AfterName = c.Name
+ p.AfterCount = c.Count
+ p.AfterID = c.ID
+ if c.Time != "" {
+ p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time)
+ }
+ }
+ p.Sort = sortKey
+ p.Query = query
+
+ all, hasMore, err := s.Store.ListPublicOrgsPage(r.Context(), p)
+ if err != nil {
+ http.Error(w, "could not load orgs", http.StatusInternalServerError)
+ return
+ }
+ data := map[string]any{
+ "All": all,
+ "LoggedIn": uid != 0,
+ "Error": q.Get("error"),
+ "Sort": string(sortKey),
+ "Query": query,
+ }
+ if hasMore && len(all) > 0 {
+ data["NextCursor"] = encodeOrgCursor(nextOrgCursor(sortKey, query, all[len(all)-1]))
+ }
+ if uid != 0 {
+ mine, err := s.Store.ListOrgsForUser(r.Context(), uid)
+ if err != nil {
+ http.Error(w, "could not load orgs", http.StatusInternalServerError)
+ return
+ }
+ memberOf := map[int64]string{}
+ for _, m := range mine {
+ memberOf[m.Org.ID] = m.Role
+ }
+ data["MemberOf"] = memberOf
+ }
+ s.Render(w, r, "orgs.html", data)
+}
+
+// renderOrgPublic renders the public-facing org page (name, description, admins,
+// counts, and a map of repeaters opted into public display).
+func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember bool) {
+ admins, err := s.Store.ListOrgAdminNames(r.Context(), org.ID)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
+ memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
+ pubReps, err := s.Store.ListPublicMapRepeaters(r.Context(), org.ID)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
+ uid := s.Auth.CurrentUserID(r.Context())
+ s.Render(w, r, "org_public.html", map[string]any{
+ "Org": org,
+ "Admins": admins,
+ "MemberCount": memberCount,
+ "RepeaterCount": repeaterCount,
+ "Repeaters": pubReps,
+ "HasMap": len(pubReps) > 0,
+ "IsMember": isMember,
+ "LoggedIn": uid != 0,
+ "CanJoin": uid != 0 && !isMember,
+ })
+}
+
+// orgCursor is an opaque directory position: the sort and search it belongs to,
+// plus the sort key and id of the last org on the current page.
+type orgCursor struct {
+ Sort string `json:"s,omitempty"`
+ Query string `json:"q,omitempty"`
+ Name string `json:"n,omitempty"`
+ Count int `json:"c,omitempty"`
+ Time string `json:"t,omitempty"` // RFC3339Nano, for the "newest" sort
+ ID int64 `json:"i"`
+}
+
+// nextOrgCursor builds the cursor pointing just past last, carrying the current
+// sort and search so the next page reproduces them.
+func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgCursor {
+ c := orgCursor{Sort: string(sort), Query: query, ID: last.ID}
+ switch sort {
+ case store.OrgSortName:
+ c.Name = last.Name
+ case store.OrgSortRepeaters:
+ c.Count = last.RepeaterCount
+ case store.OrgSortNewest:
+ c.Time = last.CreatedAt.Format(time.RFC3339Nano)
+ default: // OrgSortMembers
+ c.Count = last.MemberCount
+ }
+ return c
+}
+
+// encodeOrgCursor packs a directory position into an opaque, URL-safe token.
+func encodeOrgCursor(c orgCursor) string {
+ b, _ := json.Marshal(c)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor decodes
+// to ok=false, i.e. the first page.
+func decodeOrgCursor(tok string) (orgCursor, bool) {
+ if tok == "" {
+ return orgCursor{}, false
+ }
+ b, err := base64.RawURLEncoding.DecodeString(tok)
+ if err != nil {
+ return orgCursor{}, false
+ }
+ var c orgCursor
+ if json.Unmarshal(b, &c) != nil {
+ return orgCursor{}, false
+ }
+ return c, true
+}
diff --git a/internal/web/orgs_cursor_test.go b/internal/marketing/orgs_cursor_test.go
similarity index 98%
rename from internal/web/orgs_cursor_test.go
rename to internal/marketing/orgs_cursor_test.go
index c95cb63..2749032 100644
--- a/internal/web/orgs_cursor_test.go
+++ b/internal/marketing/orgs_cursor_test.go
@@ -1,4 +1,4 @@
-package web
+package marketing
import "testing"
diff --git a/internal/web/templates/landing.html b/internal/marketing/templates/landing.html
similarity index 94%
rename from internal/web/templates/landing.html
rename to internal/marketing/templates/landing.html
index 6ef0565..cd92c84 100644
--- a/internal/web/templates/landing.html
+++ b/internal/marketing/templates/landing.html
@@ -11,7 +11,7 @@
in sync without needing to share admin passwords.
@@ -102,8 +102,8 @@
Ready to tend your mesh?
Register a repeater in minutes and bring your team along.
diff --git a/internal/web/templates/org_public.html b/internal/marketing/templates/org_public.html
similarity index 98%
rename from internal/web/templates/org_public.html
rename to internal/marketing/templates/org_public.html
index 1c44c0e..c1d0d04 100644
--- a/internal/web/templates/org_public.html
+++ b/internal/marketing/templates/org_public.html
@@ -13,7 +13,7 @@
{{template "icon-users" "me-1"}}Join organization
{{else}}
- Sign in to join
+ Sign in to join
{{end}}
diff --git a/internal/web/templates/orgs.html b/internal/marketing/templates/orgs.html
similarity index 97%
rename from internal/web/templates/orgs.html
rename to internal/marketing/templates/orgs.html
index f105437..acfdb1d 100644
--- a/internal/web/templates/orgs.html
+++ b/internal/marketing/templates/orgs.html
@@ -61,7 +61,7 @@
{{if not .LoggedIn}}
{{end}}
diff --git a/internal/store/auth_codes.go b/internal/store/auth_codes.go
new file mode 100644
index 0000000..2c372ab
--- /dev/null
+++ b/internal/store/auth_codes.go
@@ -0,0 +1,51 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// authCodeTTL bounds how long a handoff code is valid between the auth host
+// minting it and the app host consuming it. Kept short: the browser redirects
+// immediately, so this only needs to cover one network round-trip.
+const authCodeTTL = 60 * time.Second
+
+// CreateAuthCode mints a single-use handoff code for userID, to be redeemed at
+// the app host's callback. next is the validated app-local path to land on.
+func (s *Store) CreateAuthCode(ctx context.Context, userID int64, next string) (string, error) {
+ code, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ if next == "" {
+ next = "/"
+ }
+ _, err = s.pool.Exec(ctx,
+ `INSERT INTO auth_codes (code, user_id, next, expiry) VALUES ($1, $2, $3, now() + $4)`,
+ code, userID, next, authCodeTTL)
+ if err != nil {
+ return "", fmt.Errorf("create auth code: %w", err)
+ }
+ return code, nil
+}
+
+// ConsumeAuthCode atomically redeems a handoff code, returning its user id and
+// post-auth path. The code is deleted on success (single-use) and only matches
+// while unexpired. ok is false when the code is unknown, expired, or already
+// used — the caller should treat that as an auth failure, not an error.
+func (s *Store) ConsumeAuthCode(ctx context.Context, code string) (userID int64, next string, ok bool, err error) {
+ err = s.pool.QueryRow(ctx,
+ `DELETE FROM auth_codes WHERE code = $1 AND expiry > now() RETURNING user_id, next`,
+ code).Scan(&userID, &next)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return 0, "", false, nil
+ }
+ if err != nil {
+ return 0, "", false, fmt.Errorf("consume auth code: %w", err)
+ }
+ return userID, next, true, nil
+}
diff --git a/internal/store/auth_codes_test.go b/internal/store/auth_codes_test.go
new file mode 100644
index 0000000..2429458
--- /dev/null
+++ b/internal/store/auth_codes_test.go
@@ -0,0 +1,50 @@
+package store
+
+import "testing"
+
+func TestAuthCodes(t *testing.T) {
+ st, ctx := orgTestStore(t)
+ u, err := st.CreateUser(ctx, "codeuser", "")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+
+ // Round-trip: a freshly minted code redeems once to its user and next path.
+ code, err := st.CreateAuthCode(ctx, u.ID, "/repeaters")
+ if err != nil {
+ t.Fatalf("create auth code: %v", err)
+ }
+ gotID, gotNext, ok, err := st.ConsumeAuthCode(ctx, code)
+ if err != nil {
+ t.Fatalf("consume: %v", err)
+ }
+ if !ok || gotID != u.ID || gotNext != "/repeaters" {
+ t.Fatalf("consume = (%d, %q, %v), want (%d, /repeaters, true)", gotID, gotNext, ok, u.ID)
+ }
+
+ // Single-use: the same code can't be redeemed twice.
+ if _, _, ok, err := st.ConsumeAuthCode(ctx, code); err != nil || ok {
+ t.Fatalf("second consume = (ok=%v, err=%v), want (false, nil)", ok, err)
+ }
+
+ // Unknown codes are a soft failure, not an error.
+ if _, _, ok, err := st.ConsumeAuthCode(ctx, "nope"); err != nil || ok {
+ t.Fatalf("unknown consume = (ok=%v, err=%v), want (false, nil)", ok, err)
+ }
+
+ // Empty next defaults to "/".
+ code2, _ := st.CreateAuthCode(ctx, u.ID, "")
+ if _, gotNext, _, _ := st.ConsumeAuthCode(ctx, code2); gotNext != "/" {
+ t.Fatalf("empty next = %q, want /", gotNext)
+ }
+
+ // Expired codes don't redeem.
+ if _, err := st.pool.Exec(ctx,
+ `INSERT INTO auth_codes (code, user_id, next, expiry) VALUES ('stale', $1, '/', now() - interval '1 minute')`,
+ u.ID); err != nil {
+ t.Fatalf("insert expired: %v", err)
+ }
+ if _, _, ok, err := st.ConsumeAuthCode(ctx, "stale"); err != nil || ok {
+ t.Fatalf("expired consume = (ok=%v, err=%v), want (false, nil)", ok, err)
+ }
+}
diff --git a/internal/store/migrations/0015_auth_codes.sql b/internal/store/migrations/0015_auth_codes.sql
new file mode 100644
index 0000000..e5337c4
--- /dev/null
+++ b/internal/store/migrations/0015_auth_codes.sql
@@ -0,0 +1,15 @@
+-- +goose Up
+-- Single-use codes that hand off an authenticated session from the auth host to
+-- the app host. The auth host mints a row after a successful sign-in and
+-- redirects the browser to the app host's callback, which consumes it (deleting
+-- the row) to establish its own host-scoped session. Short-lived and one-shot.
+CREATE TABLE auth_codes (
+ code TEXT PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ next TEXT NOT NULL DEFAULT '/', -- validated app-local post-auth path
+ expiry TIMESTAMPTZ NOT NULL
+);
+CREATE INDEX auth_codes_expiry_idx ON auth_codes (expiry);
+
+-- +goose Down
+DROP TABLE auth_codes;
diff --git a/internal/web/env.go b/internal/web/env.go
new file mode 100644
index 0000000..4a52f4d
--- /dev/null
+++ b/internal/web/env.go
@@ -0,0 +1,239 @@
+// Package web is the shared HTTP foundation: template rendering, common
+// middleware/helpers, and the host dispatcher that the marketing/auth/core
+// surface packages build on. It deliberately does NOT import the surface
+// packages (or internal/auth), so those can import web without a cycle.
+package web
+
+import (
+ "context"
+ "embed"
+ "html/template"
+ "io/fs"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+
+ "github.com/jleight/meshtender/internal/config"
+ "github.com/jleight/meshtender/internal/identity"
+ "github.com/jleight/meshtender/internal/store"
+)
+
+//go:embed templates/base.html templates/icons.html
+var sharedTemplatesFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+// UserInfoFunc reports the signed-in user's display name and admin flag for the
+// page chrome. ok is false when no user is signed in. Injected by the assembler
+// (which wires it from the auth service + store) so web stays auth-free.
+type UserInfoFunc func(ctx context.Context) (name string, canAdmin bool, ok bool)
+
+// Deps are the shared dependencies every surface needs.
+type Deps struct {
+ Store *store.Store
+ Identity *identity.Service
+ Cfg *config.Config
+ UserInfo UserInfoFunc
+ LookupTXT func(name string) ([]string, error)
+}
+
+// Env is the shared environment a surface's Handlers embeds. It carries the
+// store/identity/config, the surface's renderer, and the DNS lookup.
+type Env struct {
+ Store *store.Store
+ Identity *identity.Service
+ Cfg *config.Config
+ Renderer *Renderer
+ // LookupTXT resolves DNS TXT records; injectable so domain verification is
+ // testable. Defaults to net.LookupTXT.
+ LookupTXT func(name string) ([]string, error)
+}
+
+// NewEnv builds a surface environment from shared Deps plus that surface's own
+// page templates (composed onto the shared base layout).
+func NewEnv(d Deps, surfaceTemplates fs.FS) (*Env, error) {
+ r, err := NewRenderer(d.Cfg, surfaceTemplates)
+ if err != nil {
+ return nil, err
+ }
+ r.userInfo = d.UserInfo
+ lookup := d.LookupTXT
+ if lookup == nil {
+ lookup = net.LookupTXT
+ }
+ return &Env{Store: d.Store, Identity: d.Identity, Cfg: d.Cfg, Renderer: r, LookupTXT: lookup}, nil
+}
+
+// Render delegates to the shared renderer (convenience for handlers via Env).
+func (e *Env) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
+ e.Renderer.Render(w, r, page, data)
+}
+
+// Origin builds an absolute scheme://host[:port] for a sibling surface, reusing
+// the port the request arrived on (one binary serves all hosts on one port).
+func (e *Env) Origin(r *http.Request, host string) string {
+ return originFor(e.Cfg, r, host)
+}
+
+func originFor(cfg *config.Config, r *http.Request, host string) string {
+ scheme := "http"
+ if cfg.Secure {
+ scheme = "https"
+ }
+ port := ""
+ if _, p, err := net.SplitHostPort(r.Host); err == nil && p != "" {
+ port = ":" + p
+ }
+ return scheme + "://" + host + port
+}
+
+// Renderer composes content pages onto the shared base layout and executes them,
+// injecting the cross-host URLs and current-user info every page's chrome needs.
+type Renderer struct {
+ cfg *config.Config
+ pages map[string]*template.Template
+ userInfo UserInfoFunc
+}
+
+// NewRenderer parses the shared base layout (base.html + icons.html) and composes
+// each of the surface's own *.html pages onto it. Each page redefines the
+// content/title/header blocks, so every page gets its own cloned template set.
+func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
+ base, err := template.New("").ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html")
+ if err != nil {
+ return nil, err
+ }
+ all, err := fs.Glob(surfaceTemplates, "templates/*.html")
+ if err != nil {
+ return nil, err
+ }
+ // The shared layout/partials are composed in above; skip them if they happen
+ // to live in the surface's template dir.
+ shared := map[string]bool{"templates/base.html": true, "templates/icons.html": true}
+ pages := map[string]*template.Template{}
+ for _, p := range all {
+ if shared[p] {
+ continue
+ }
+ clone, err := base.Clone()
+ if err != nil {
+ return nil, err
+ }
+ if _, err := clone.ParseFS(surfaceTemplates, p); err != nil {
+ return nil, err
+ }
+ pages[strings.TrimPrefix(p, "templates/")] = clone
+ }
+ return &Renderer{cfg: cfg, pages: pages}, nil
+}
+
+// Pages returns the composed page templates keyed by file name. Intended for
+// startup/composition tests.
+func (rn *Renderer) Pages() map[string]*template.Template { return rn.pages }
+
+// Render executes a content page within its layout (default "base"; pages can
+// opt into another via the "Layout" data key).
+func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
+ if data == nil {
+ data = map[string]any{}
+ }
+ // Absolute origins for cross-host links (sessions are host-scoped, so a link
+ // from root/app to a sibling surface must be absolute). Empty in single-host
+ // mode, where templates' relative paths already resolve correctly.
+ if rn.cfg.AuthHost != "" {
+ data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost)
+ data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost)
+ if rn.cfg.RootHost != "" {
+ data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost)
+ }
+ }
+ if rn.userInfo != nil {
+ if name, canAdmin, ok := rn.userInfo(r.Context()); ok {
+ data["UserName"] = name
+ data["CanAdmin"] = canAdmin
+ }
+ }
+ t, ok := rn.pages[page]
+ if !ok {
+ http.Error(w, "unknown page: "+page, http.StatusInternalServerError)
+ return
+ }
+ layout, _ := data["Layout"].(string)
+ if layout == "" {
+ layout = "base"
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := t.ExecuteTemplate(w, layout, data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+// --- shared HTTP helpers (used across surfaces) ---
+
+// CommonMiddleware applies the generic middleware every surface needs. Session
+// loading is added by each surface (it owns the auth service), so web stays
+// auth-free. chi requires all Use() calls before any route is registered.
+func (e *Env) CommonMiddleware(r chi.Router) {
+ r.Use(middleware.RequestID)
+ r.Use(middleware.RealIP)
+ r.Use(middleware.Recoverer)
+}
+
+// SharedRoutes registers endpoints every surface needs (health, static assets).
+func (e *Env) SharedRoutes(r chi.Router) {
+ r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ })
+ staticSub, _ := fs.Sub(staticFS, "static")
+ r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
+}
+
+// HostWithoutPort strips a trailing :port from a request Host, if present.
+func HostWithoutPort(host string) string {
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ return h
+ }
+ return host
+}
+
+// RedirectFlash 303-redirects to path with a single flash query param (key=msg,
+// escaped), picking ? or & as the separator.
+func RedirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string) {
+ sep := "?"
+ if strings.Contains(path, "?") {
+ sep = "&"
+ }
+ http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
+}
+
+// RedirectErr is RedirectFlash with the conventional "error" key.
+func RedirectErr(w http.ResponseWriter, r *http.Request, path, msg string) {
+ RedirectFlash(w, r, path, "error", msg)
+}
+
+// Dispatcher routes by hostname across the surfaces. authH/rootH/appH are the
+// per-surface handlers; rootH may be nil (single-host). When AuthHost is empty,
+// appH serves everything (single-host mode).
+func Dispatcher(cfg *config.Config, authH, rootH, appH http.Handler) http.Handler {
+ if cfg.AuthHost == "" {
+ return appH
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := HostWithoutPort(r.Host)
+ switch {
+ case strings.EqualFold(host, cfg.AuthHost):
+ authH.ServeHTTP(w, r)
+ case rootH != nil && strings.EqualFold(host, cfg.RootHost):
+ rootH.ServeHTTP(w, r)
+ case cfg.WWWHost != "" && strings.EqualFold(host, cfg.WWWHost):
+ http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently)
+ default:
+ appH.ServeHTTP(w, r)
+ }
+ })
+}
diff --git a/internal/web/orgs.go b/internal/web/orgs.go
deleted file mode 100644
index e4c0273..0000000
--- a/internal/web/orgs.go
+++ /dev/null
@@ -1,388 +0,0 @@
-package web
-
-import (
- "encoding/base64"
- "encoding/json"
- "errors"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "github.com/go-chi/chi/v5"
-
- "github.com/jleight/meshtender/internal/store"
-)
-
-// orgID resolves the {id} URL param (a slug) to the internal int64 primary key.
-func (s *Server) orgID(r *http.Request) (int64, bool) {
- id, err := s.store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id"))
- return id, err == nil
-}
-
-// orgParam returns the raw slug from the {id} URL param, for building redirects.
-func orgParam(r *http.Request) string { return chi.URLParam(r, "id") }
-
-func orgErr(w http.ResponseWriter, r *http.Request, msg string) {
- redirectErr(w, r, "/orgs/"+orgParam(r), msg)
-}
-
-// pageOrgs is the public organization directory. Everyone sees the list; signed-
-// in users also get the create form and a marker on orgs they belong to. The
-// directory is keyset-paginated via an opaque ?cursor token.
-func (s *Server) pageOrgs(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- q := r.URL.Query()
-
- sortKey := store.NormalizeOrgSort(q.Get("sort"))
- query := strings.TrimSpace(q.Get("q"))
- if len(query) > 100 {
- query = query[:100]
- }
-
- var p store.OrgListParams
- // A cursor carries the authoritative sort, search, and keyset position for
- // the page it points at, so paging stays consistent without re-sending the
- // form; the sort/q params only matter on the first (cursorless) page.
- if c, ok := decodeOrgCursor(q.Get("cursor")); ok {
- sortKey = store.NormalizeOrgSort(c.Sort)
- query = c.Query
- p.HasCursor = true
- p.AfterName = c.Name
- p.AfterCount = c.Count
- p.AfterID = c.ID
- if c.Time != "" {
- p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time)
- }
- }
- p.Sort = sortKey
- p.Query = query
-
- all, hasMore, err := s.store.ListPublicOrgsPage(r.Context(), p)
- if err != nil {
- http.Error(w, "could not load orgs", http.StatusInternalServerError)
- return
- }
- data := map[string]any{
- "All": all,
- "LoggedIn": uid != 0,
- "Error": q.Get("error"),
- "Sort": string(sortKey),
- "Query": query,
- }
- if hasMore && len(all) > 0 {
- data["NextCursor"] = encodeOrgCursor(nextOrgCursor(sortKey, query, all[len(all)-1]))
- }
- if uid != 0 {
- mine, err := s.store.ListOrgsForUser(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load orgs", http.StatusInternalServerError)
- return
- }
- memberOf := map[int64]string{}
- for _, m := range mine {
- memberOf[m.Org.ID] = m.Role
- }
- data["MemberOf"] = memberOf
- }
- s.render(w, r, "orgs.html", data)
-}
-
-// orgCursor is an opaque directory position: the sort and search it belongs to,
-// plus the sort key and id of the last org on the current page. The next page
-// seeks strictly past it. Only the key field relevant to Sort is populated.
-type orgCursor struct {
- Sort string `json:"s,omitempty"`
- Query string `json:"q,omitempty"`
- Name string `json:"n,omitempty"`
- Count int `json:"c,omitempty"`
- Time string `json:"t,omitempty"` // RFC3339Nano, for the "newest" sort
- ID int64 `json:"i"`
-}
-
-// nextOrgCursor builds the cursor pointing just past last, carrying the current
-// sort and search so the next page reproduces them.
-func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgCursor {
- c := orgCursor{Sort: string(sort), Query: query, ID: last.ID}
- switch sort {
- case store.OrgSortName:
- c.Name = last.Name
- case store.OrgSortRepeaters:
- c.Count = last.RepeaterCount
- case store.OrgSortNewest:
- c.Time = last.CreatedAt.Format(time.RFC3339Nano)
- default: // OrgSortMembers
- c.Count = last.MemberCount
- }
- return c
-}
-
-// encodeOrgCursor packs a directory position into an opaque, URL-safe token.
-func encodeOrgCursor(c orgCursor) string {
- b, _ := json.Marshal(c)
- return base64.RawURLEncoding.EncodeToString(b)
-}
-
-// decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor
-// decodes to ok=false, i.e. the first page — so a tampered URL just resets
-// paging rather than erroring.
-func decodeOrgCursor(tok string) (orgCursor, bool) {
- if tok == "" {
- return orgCursor{}, false
- }
- b, err := base64.RawURLEncoding.DecodeString(tok)
- if err != nil {
- return orgCursor{}, false
- }
- var c orgCursor
- if json.Unmarshal(b, &c) != nil {
- return orgCursor{}, false
- }
- return c, true
-}
-
-// pageNewOrg renders the standalone "create an organization" form.
-func (s *Server) pageNewOrg(w http.ResponseWriter, r *http.Request) {
- s.render(w, r, "new_org.html", map[string]any{
- "Error": r.URL.Query().Get("error"),
- })
-}
-
-// handleCreateOrg creates an org with the current user as its first admin.
-func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- name := strings.TrimSpace(r.FormValue("name"))
- if name == "" || len(name) > 80 {
- redirectErr(w, r, "/orgs/new", "Enter an organization name.")
- return
- }
- org, err := s.store.CreateOrg(r.Context(), name, uid)
- if err != nil {
- redirectErr(w, r, "/orgs/new", "Could not create organization.")
- return
- }
- http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
-}
-
-// pageOrg shows an org's home. Members get the full management view; everyone
-// else (anonymous or non-member) gets the public view. Members can preview the
-// public view with ?view=public.
-func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return
- }
- org, err := s.store.GetOrg(r.Context(), id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- role, isMember, err := s.store.OrgRole(r.Context(), id, uid)
- if err != nil {
- http.Error(w, "could not load org", http.StatusInternalServerError)
- return
- }
- if !isMember || r.URL.Query().Get("view") == "public" {
- s.renderOrgPublic(w, r, org, isMember)
- return
- }
- members, err := s.store.ListOrgMembers(r.Context(), id)
- if err != nil {
- http.Error(w, "could not load members", http.StatusInternalServerError)
- return
- }
- repeaters, err := s.store.ListOrgRepeaters(r.Context(), id)
- if err != nil {
- http.Error(w, "could not load repeaters", http.StatusInternalServerError)
- return
- }
- mapped := 0
- for _, rp := range repeaters {
- if rp.HasLocation {
- mapped++
- }
- }
- adminCount := 0
- for _, m := range members {
- if m.Role == "admin" {
- adminCount++
- }
- }
- isAdmin := role == "admin"
- data := map[string]any{
- "Org": org,
- "Role": role,
- "IsAdmin": isAdmin,
- "Members": members,
- "Repeaters": repeaters,
- "HasMap": mapped > 0,
- "MemberCount": len(members),
- "RepeaterCount": len(repeaters),
- "AdminCount": adminCount,
- "Self": uid,
- "Error": r.URL.Query().Get("error"),
- }
- if isAdmin {
- domains, err := s.store.ListOrgDomains(r.Context(), id)
- if err != nil {
- http.Error(w, "could not load domains", http.StatusInternalServerError)
- return
- }
- data["Domains"] = domains
- data["TXTName"] = txtRecordPrefix // org appends its hostname for the full record name
- }
- s.render(w, r, "org.html", data)
-}
-
-// renderOrgPublic renders the public-facing org page (name, description, admins,
-// counts, and a map of repeaters opted into public display).
-func (s *Server) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember bool) {
- admins, err := s.store.ListOrgAdminNames(r.Context(), org.ID)
- if err != nil {
- http.Error(w, "could not load org", http.StatusInternalServerError)
- return
- }
- memberCount, repeaterCount, err := s.store.OrgCounts(r.Context(), org.ID)
- if err != nil {
- http.Error(w, "could not load org", http.StatusInternalServerError)
- return
- }
- pubReps, err := s.store.ListPublicMapRepeaters(r.Context(), org.ID)
- if err != nil {
- http.Error(w, "could not load org", http.StatusInternalServerError)
- return
- }
- uid := s.auth.CurrentUserID(r.Context())
- s.render(w, r, "org_public.html", map[string]any{
- "Org": org,
- "Admins": admins,
- "MemberCount": memberCount,
- "RepeaterCount": repeaterCount,
- "Repeaters": pubReps,
- "HasMap": len(pubReps) > 0,
- "IsMember": isMember,
- "LoggedIn": uid != 0,
- "CanJoin": uid != 0 && !isMember,
- })
-}
-
-// handleEditOrg updates an org's slug, name, description, and region (admin only).
-func (s *Server) handleEditOrg(w http.ResponseWriter, r *http.Request) {
- id, ok := s.requireOrgAdmin(w, r)
- if !ok {
- return
- }
- name := strings.TrimSpace(r.FormValue("name"))
- slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug")))
- desc := strings.TrimSpace(r.FormValue("description"))
- region := strings.TrimSpace(r.FormValue("region"))
- if name == "" || len(name) > 80 {
- orgErr(w, r, "Enter an organization name.")
- return
- }
- if !store.ValidOrgSlug(slug) {
- orgErr(w, r, "Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).")
- return
- }
- if len(desc) > 2000 {
- desc = desc[:2000]
- }
- if len(region) > 120 {
- region = region[:120]
- }
- if err := s.store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) {
- orgErr(w, r, "That URL slug is already taken.")
- return
- } else if err != nil {
- orgErr(w, r, "Could not save changes.")
- return
- }
- // The slug may have changed; redirect to the new canonical URL.
- http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther)
-}
-
-// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
-func (s *Server) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
- uid := s.auth.CurrentUserID(r.Context())
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return 0, false
- }
- admin, err := s.store.IsOrgAdmin(r.Context(), id, uid)
- if err != nil || !admin {
- http.NotFound(w, r)
- return 0, false
- }
- return id, true
-}
-
-func (s *Server) handleLeaveOrg(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return
- }
- err := s.store.RemoveOrgMember(r.Context(), id, uid)
- if errors.Is(err, store.ErrLastAdmin) {
- orgErr(w, r, "You're the last admin — promote someone else first.")
- return
- }
- if err != nil {
- orgErr(w, r, "Could not leave.")
- return
- }
- http.Redirect(w, r, "/orgs", http.StatusSeeOther)
-}
-
-// handleJoinOrg adds the current user to an org as a member. Orgs are publicly
-// listed and anyone signed in may join directly from the org page (idempotent).
-func (s *Server) handleJoinOrg(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- id, ok := s.orgID(r)
- if !ok {
- http.NotFound(w, r)
- return
- }
- if err := s.store.AddOrgMember(r.Context(), id, uid, "member"); err != nil {
- orgErr(w, r, "Could not join.")
- return
- }
- http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther)
-}
-
-// handleSetOrgMember promotes/demotes/removes a member (admin only).
-func (s *Server) handleSetOrgMember(w http.ResponseWriter, r *http.Request) {
- id, ok := s.requireOrgAdmin(w, r)
- if !ok {
- return
- }
- targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- switch r.FormValue("action") {
- case "promote":
- err = s.store.SetOrgMemberRole(r.Context(), id, targetID, "admin")
- case "demote":
- err = s.store.SetOrgMemberRole(r.Context(), id, targetID, "member")
- case "remove":
- err = s.store.RemoveOrgMember(r.Context(), id, targetID)
- default:
- orgErr(w, r, "Unknown action.")
- return
- }
- if errors.Is(err, store.ErrLastAdmin) {
- orgErr(w, r, "That would leave the org with no admin.")
- return
- }
- if err != nil {
- orgErr(w, r, "Could not update member.")
- return
- }
- http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther)
-}
diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go
index d4f1af5..7f4a843 100644
--- a/internal/web/ratelimit.go
+++ b/internal/web/ratelimit.go
@@ -27,7 +27,7 @@ type tokenBucket struct {
// newRateLimiter builds a limiter allowing bursts up to burst requests, then
// one further request every refill interval.
-func newRateLimiter(burst float64, refill time.Duration) *rateLimiter {
+func NewRateLimiter(burst float64, refill time.Duration) *rateLimiter {
return &rateLimiter{
buckets: map[string]*tokenBucket{},
ratePerSec: 1 / refill.Seconds(),
@@ -76,7 +76,7 @@ func (l *rateLimiter) sweep(now time.Time) {
// middleware rejects requests from a client that has exceeded its rate, keyed by
// client IP.
-func (l *rateLimiter) middleware(next http.Handler) http.Handler {
+func (l *rateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.allow(clientIP(r)) {
http.Error(w, "Too many attempts. Please wait a moment and try again.", http.StatusTooManyRequests)
diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go
index a26f2ef..45710d3 100644
--- a/internal/web/ratelimit_test.go
+++ b/internal/web/ratelimit_test.go
@@ -7,7 +7,7 @@ import (
func TestRateLimiterBurstThenThrottle(t *testing.T) {
now := time.Unix(0, 0)
- l := newRateLimiter(3, time.Second)
+ l := NewRateLimiter(3, time.Second)
l.now = func() time.Time { return now }
// Burst of 3 is allowed from a cold start.
@@ -36,7 +36,7 @@ func TestRateLimiterBurstThenThrottle(t *testing.T) {
func TestRateLimiterSweepReclaims(t *testing.T) {
now := time.Unix(0, 0)
- l := newRateLimiter(2, time.Second)
+ l := NewRateLimiter(2, time.Second)
l.now = func() time.Time { return now }
l.allow("1.2.3.4")
diff --git a/internal/web/static/webauthn.js b/internal/web/static/webauthn.js
index c905bd1..982d765 100644
--- a/internal/web/static/webauthn.js
+++ b/internal/web/static/webauthn.js
@@ -129,7 +129,7 @@ async function addPasskey() {
});
const data = await result.json().catch(() => ({}));
if (!result.ok) throw new Error(data.error || "could not add passkey");
- window.location = "/account?ok=" + encodeURIComponent("Passkey added.");
+ window.location = "/account?pk=" + encodeURIComponent("Passkey added.");
} catch (e) {
setStatus("Error: " + e.message);
}
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
deleted file mode 100644
index f2e33f2..0000000
--- a/internal/web/templates/account.html
+++ /dev/null
@@ -1,115 +0,0 @@
-{{define "title"}}Account settings · MeshTender{{end}}
-{{define "header"}}
-
-
-
Your account
-
Account settings
-
-
-{{end}}
-{{define "content"}}
-{{if .Error}}{{.Error}}
{{end}}
-{{if .OK}}{{.OK}}
{{end}}
-
-
-
-
-
-
-
-
-
-
-
Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.
-
-
- {{if .Passkeys}}
-
- {{range .Passkeys}}
-
-
{{template "icon-key" ""}}
-
-
{{.ShortID}}…
-
Added {{.Added.Format "Jan 2, 2006"}}
-
-
-
- {{end}}
-
- {{else}}
-
No passkeys yet. Add one for faster, more secure sign-in.
- {{end}}
-
-
-
-
-
-
-
-
- {{if .HasPassword}}
-
Change the password you use as a fallback to passkeys.
- {{else}}
-
You don't have a password set — you sign in with a passkey. You can add a password as a fallback.
- {{end}}
-
-
- {{if .HasPassword}}
-
-
- {{end}}
-
-
-
-
-
-
-{{end}}
diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html
index 97b7e95..b54cc55 100644
--- a/internal/web/templates/base.html
+++ b/internal/web/templates/base.html
@@ -17,7 +17,7 @@
-
+
{{template "icon-tower" "me-1"}}MeshTender
{{else}}
- {{template "icon-login" "me-1"}}Sign in
+ {{template "icon-login" "me-1"}}Sign in
{{end}}
diff --git a/internal/web/web.go b/internal/web/web.go
deleted file mode 100644
index d694213..0000000
--- a/internal/web/web.go
+++ /dev/null
@@ -1,430 +0,0 @@
-// Package web assembles the HTTP server: routing, templates, and static
-// assets, wiring together auth, identity, and the data store.
-package web
-
-import (
- "embed"
- "html/template"
- "io/fs"
- "net"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
-
- "github.com/jleight/meshtender/internal/auth"
- "github.com/jleight/meshtender/internal/config"
- "github.com/jleight/meshtender/internal/identity"
- "github.com/jleight/meshtender/internal/store"
-)
-
-//go:embed templates/*.html
-var templatesFS embed.FS
-
-//go:embed static/*
-var staticFS embed.FS
-
-// Server holds the dependencies shared by HTTP handlers.
-type Server struct {
- store *store.Store
- auth *auth.Service
- identity *identity.Service
- cfg *config.Config
- // pages holds each content page pre-composed with the shared layouts and
- // partials, keyed by file name (e.g. "dashboard.html"). Built once at
- // startup since the templates are embedded and never change at runtime.
- pages map[string]*template.Template
- router chi.Router
- // lookupTXT resolves DNS TXT records; injectable so domain verification is
- // testable. Defaults to net.LookupTXT.
- lookupTXT func(name string) ([]string, error)
-}
-
-// NewServer constructs the HTTP server and its routes.
-func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, cfg *config.Config) (*Server, error) {
- pages, err := buildPages()
- if err != nil {
- return nil, err
- }
- s := &Server{store: st, auth: authSvc, identity: idSvc, cfg: cfg, pages: pages, lookupTXT: net.LookupTXT}
- s.routes()
- return s, nil
-}
-
-// sharedTemplates are the layouts and partials shared by every page (the root
-// layouts live in base.html; reusable snippets like the icon set in icons.html).
-// They define no "content"/"title" blocks of their own, so they can be the
-// common base each page is composed onto.
-var sharedTemplates = []string{"templates/base.html", "templates/icons.html"}
-
-// buildPages composes each content page with the shared layouts/partials once,
-// returning a map keyed by the page's file name. Each page redefines the
-// "content"/"title"/"header" blocks, so every page needs its own template set
-// rather than one shared set (where the blocks would collide).
-func buildPages() (map[string]*template.Template, error) {
- base, err := template.New("").ParseFS(templatesFS, sharedTemplates...)
- if err != nil {
- return nil, err
- }
- all, err := fs.Glob(templatesFS, "templates/*.html")
- if err != nil {
- return nil, err
- }
- shared := map[string]bool{}
- for _, p := range sharedTemplates {
- shared[p] = true
- }
- pages := map[string]*template.Template{}
- for _, p := range all {
- if shared[p] {
- continue
- }
- clone, err := base.Clone()
- if err != nil {
- return nil, err
- }
- if _, err := clone.ParseFS(templatesFS, p); err != nil {
- return nil, err
- }
- pages[strings.TrimPrefix(p, "templates/")] = clone
- }
- return pages, nil
-}
-
-// Handler returns the root HTTP handler.
-func (s *Server) Handler() http.Handler { return s.router }
-
-func (s *Server) routes() {
- r := chi.NewRouter()
- r.Use(middleware.RequestID)
- r.Use(middleware.RealIP)
- r.Use(middleware.Recoverer)
- // scs must wrap everything that touches the session.
- r.Use(s.auth.Sessions.LoadAndSave)
- // Custom org domains: a verified CNAMEd host serves that org's public page.
- r.Use(s.customDomain)
-
- r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte("ok"))
- })
-
- staticSub, _ := fs.Sub(staticFS, "static")
- r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
-
- // Root: marketing landing for visitors, overview dashboard for members.
- r.Get("/", s.pageHome)
-
- // Public auth pages + JSON ceremony endpoints.
- r.Get("/login", s.pageLogin)
- r.Get("/signup", s.pageSignup)
- // Throttle credential submission per client IP to blunt password guessing
- // and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try
- // every 6s; bcrypt's cost is the second line of defense.
- authLimit := newRateLimiter(10, 6*time.Second)
- r.With(authLimit.middleware).Post("/login/password", s.auth.LoginPassword)
- r.With(authLimit.middleware).Post("/signup/password", s.auth.SignupPassword)
- r.Post("/api/register/begin", s.auth.RegisterBegin)
- r.Post("/api/register/finish", s.auth.RegisterFinish)
- r.Post("/api/login/begin", s.auth.LoginBegin)
- r.Post("/api/login/finish", s.auth.LoginFinish)
- r.Post("/api/login/discoverable/begin", s.auth.LoginDiscoverableBegin)
- r.Post("/api/login/discoverable/finish", s.auth.LoginDiscoverableFinish)
- r.Get("/invite/{token}", s.pageInvite) // public: handles logged-out state
- r.Get("/orgs", s.pageOrgs) // public: organization directory
- r.Get("/orgs/{id}", s.pageOrg) // public: org page (public view for non-members)
-
- // Authenticated area.
- r.Group(func(r chi.Router) {
- r.Use(s.auth.RequireUser)
- r.Get("/repeaters", s.pageRepeaters)
- r.Post("/logout", s.handleLogout)
- r.Get("/account", s.pageAccount)
- r.Post("/account/profile", s.handleUpdateProfile)
- r.Post("/account/password", s.handleChangePassword)
- r.Post("/account/passkeys/delete", s.handleDeletePasskey)
- r.Get("/repeaters/add", s.pageAddRepeater)
- r.Post("/repeaters", s.handleAddRepeater)
- r.Get("/repeaters/{id}", s.pageRepeater)
- r.Get("/repeaters/{id}/added", s.pageRepeaterAdded)
- r.Get("/repeaters/{id}/edit", s.pageEditRepeater)
- r.Post("/repeaters/{id}/edit", s.handleEditRepeater)
- r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater)
- r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater)
- r.Get("/repeaters/{id}/confirm", s.pageConfirm)
- r.Get("/repeaters/{id}/ws", s.wsConfirm)
- r.Get("/repeaters/{id}/console", s.pageConsole)
- r.Get("/repeaters/{id}/console/ws", s.wsConsole)
- r.Get("/repeaters/{id}/log", s.pageCommandLog)
- r.Get("/repeaters/{id}/share", s.pageShare)
- r.Post("/repeaters/{id}/share/link", s.handleCreateLink)
- r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite)
- r.Post("/repeaters/{id}/unshare", s.handleUnshare)
- r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
- r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
- r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute)
- r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute)
- r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
- r.Post("/invite/{token}/accept", s.handleAcceptInvite)
-
- r.Get("/orgs/new", s.pageNewOrg)
- r.Post("/orgs", s.handleCreateOrg)
- r.Post("/orgs/{id}/edit", s.handleEditOrg)
- r.Post("/orgs/{id}/join", s.handleJoinOrg)
- r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
- r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember)
- r.Get("/orgs/{id}/permissions", s.pageOrgPermissions)
- r.Post("/orgs/{id}/permissions", s.handleSaveOrgPermissions)
- r.Post("/orgs/{id}/domains", s.handleAddOrgDomain)
- r.Post("/orgs/{id}/domains/verify", s.handleVerifyOrgDomain)
- r.Post("/orgs/{id}/domains/delete", s.handleDeleteOrgDomain)
-
- r.Route("/admin", func(r chi.Router) {
- r.With(s.requireCap(capAny)).Get("/", s.pageAdmin)
- r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog)
- r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand)
- r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers)
- r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps)
- })
- })
-
- s.router = r
-}
-
-// customDomain serves an org's public page when the request arrives on one of
-// the org's verified custom domains. Any non-root path is redirected to the
-// canonical host, where auth and management (and the WebAuthn RP) live.
-func (s *Server) customDomain(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- host := hostWithoutPort(r.Host)
- if host == "" || host == "localhost" || strings.EqualFold(host, s.cfg.PrimaryHost) {
- next.ServeHTTP(w, r)
- return
- }
- org, ok, err := s.store.OrgByVerifiedDomain(r.Context(), strings.ToLower(host))
- if err != nil || !ok {
- next.ServeHTTP(w, r) // unknown host: serve the app normally
- return
- }
- if r.URL.Path == "/" {
- s.renderOrgPublic(w, r, org, false)
- return
- }
- http.Redirect(w, r, "https://"+s.cfg.PrimaryHost+r.URL.RequestURI(), http.StatusFound)
- })
-}
-
-// hostWithoutPort strips a trailing :port from a request Host, if present.
-func hostWithoutPort(host string) string {
- if h, _, err := net.SplitHostPort(host); err == nil {
- return h
- }
- return host
-}
-
-// redirectFlash 303-redirects to path with a single flash query param (key=msg,
-// the value escaped), picking ? or & as the separator. The per-page error/flash
-// helpers (orgErr, shareErr, accountRedirect, …) all build on it.
-func redirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string) {
- sep := "?"
- if strings.Contains(path, "?") {
- sep = "&"
- }
- http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
-}
-
-// redirectErr is redirectFlash with the conventional "error" key.
-func redirectErr(w http.ResponseWriter, r *http.Request, path, msg string) {
- redirectFlash(w, r, path, "error", msg)
-}
-
-// render executes a content template within the base layout.
-func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
- if data == nil {
- data = map[string]any{}
- }
- // Attach the current user's display name for the layout, when authenticated.
- if uid := s.auth.CurrentUserID(r.Context()); uid != 0 {
- if u, err := s.store.GetUserByID(r.Context(), uid); err == nil {
- data["UserName"] = u.Name()
- data["CanAdmin"] = u.CapManageUsers || u.CapManageCatalog
- }
- }
- t, ok := s.pages[page]
- if !ok {
- http.Error(w, "unknown page: "+page, http.StatusInternalServerError)
- return
- }
- // Pages may opt into an alternate root layout (e.g. the centered "authbase"
- // for sign-in/up) via the "Layout" data key; everything else uses "base".
- layout, _ := data["Layout"].(string)
- if layout == "" {
- layout = "base"
- }
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := t.ExecuteTemplate(w, layout, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
-}
-
-func (s *Server) pageLogin(w http.ResponseWriter, r *http.Request) {
- if s.auth.CurrentUserID(r.Context()) != 0 {
- http.Redirect(w, r, "/", http.StatusSeeOther)
- return
- }
- s.auth.SetNext(r.Context(), r.URL.Query().Get("next"))
- s.render(w, r, "login.html", map[string]any{
- "Layout": "authbase",
- "Error": r.URL.Query().Get("error"),
- "Next": r.URL.Query().Get("next"),
- })
-}
-
-func (s *Server) pageSignup(w http.ResponseWriter, r *http.Request) {
- if s.auth.CurrentUserID(r.Context()) != 0 {
- http.Redirect(w, r, "/", http.StatusSeeOther)
- return
- }
- s.auth.SetNext(r.Context(), r.URL.Query().Get("next"))
- s.render(w, r, "signup.html", map[string]any{
- "Layout": "authbase",
- "Error": r.URL.Query().Get("error"),
- "Next": r.URL.Query().Get("next"),
- })
-}
-
-// pageHome serves the marketing landing page to visitors and the overview
-// dashboard to signed-in members.
-func (s *Server) pageHome(w http.ResponseWriter, r *http.Request) {
- if s.auth.CurrentUserID(r.Context()) == 0 {
- s.render(w, r, "landing.html", map[string]any{"Layout": "landingbase"})
- return
- }
- s.pageDashboard(w, r)
-}
-
-// pageRepeaters lists every repeater the user owns or has been shared, split
-// into owned and shared sections.
-func (s *Server) pageRepeaters(w http.ResponseWriter, r *http.Request) {
- uid := s.auth.CurrentUserID(r.Context())
- repeaters, err := s.store.ListRepeatersForUser(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load repeaters", http.StatusInternalServerError)
- return
- }
- reconsent, err := s.store.OwnedRepeatersNeedingReconsent(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load org state", http.StatusInternalServerError)
- return
- }
- shareCounts, err := s.store.RepeaterSharingCounts(r.Context(), uid)
- if err != nil {
- http.Error(w, "could not load sharing", http.StatusInternalServerError)
- return
- }
- owned, shared := splitOwnedShared(repeaters)
- s.render(w, r, "repeaters.html", map[string]any{
- "Owned": owned,
- "Shared": shared,
- "Reconsent": reconsent,
- "ShareCounts": shareCounts,
- "Error": r.URL.Query().Get("error"),
- })
-}
-
-// pageDashboard renders the signed-in overview: summary stats, short lists of
-// repeaters and organizations, a map of owned repeaters, and recent activity.
-func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- uid := s.auth.CurrentUserID(ctx)
-
- repeaters, err := s.store.ListRepeatersForUser(ctx, uid)
- if err != nil {
- http.Error(w, "could not load repeaters", http.StatusInternalServerError)
- return
- }
- owned, shared := splitOwnedShared(repeaters)
-
- reconsent, err := s.store.OwnedRepeatersNeedingReconsent(ctx, uid)
- if err != nil {
- http.Error(w, "could not load org state", http.StatusInternalServerError)
- return
- }
- orgs, err := s.store.ListOrgsForUser(ctx, uid)
- if err != nil {
- http.Error(w, "could not load organizations", http.StatusInternalServerError)
- return
- }
- recent, err := s.store.ListRecentCommandsForOwner(ctx, uid, 8)
- if err != nil {
- http.Error(w, "could not load activity", http.StatusInternalServerError)
- return
- }
- shareCounts, err := s.store.RepeaterSharingCounts(ctx, uid)
- if err != nil {
- http.Error(w, "could not load sharing", http.StatusInternalServerError)
- return
- }
-
- // Summary counts and the owned repeaters that have a stored location.
- confirmed, unconfirmed := 0, 0
- var mapped []*store.Repeater
- for _, rp := range owned {
- if rp.Confirmed {
- confirmed++
- } else {
- unconfirmed++
- }
- if rp.Latitude != nil && rp.Longitude != nil {
- mapped = append(mapped, rp)
- }
- }
-
- s.render(w, r, "dashboard.html", map[string]any{
- "OwnedCount": len(owned),
- "SharedCount": len(shared),
- "OrgCount": len(orgs),
- "Confirmed": confirmed,
- "Unconfirmed": unconfirmed,
- "Owned": first(owned, 5),
- "Shared": first(shared, 5),
- "Orgs": first(orgs, 5),
- "Mapped": mapped,
- "Recent": recent,
- "Reconsent": reconsent,
- "ShareCounts": shareCounts,
- "Error": r.URL.Query().Get("error"),
- })
-}
-
-// splitOwnedShared partitions a repeater list into owned and directly-shared.
-func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) {
- for _, rp := range repeaters {
- if rp.Shared {
- shared = append(shared, rp)
- } else {
- owned = append(owned, rp)
- }
- }
- return owned, shared
-}
-
-// first returns the first n elements of s, or all of them if there are fewer.
-func first[T any](s []T, n int) []T {
- if len(s) > n {
- return s[:n]
- }
- return s
-}
-
-func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
- next := r.FormValue("next")
- _ = s.auth.Logout(r.Context())
- dest := "/login"
- if auth.SafeLocalPath(next) {
- dest = "/login?next=" + url.QueryEscape(next)
- }
- http.Redirect(w, r, dest, http.StatusSeeOther)
-}