feat(app): fladen kan tale med en valgfri server, med sessionen som bearer

Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver
sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i
appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind.
Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er
præcis, hvad det var før.

- `apiURL()` og `authorize()`: relative stier og cookie i en browser; den
  valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`.
- Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra
  telefonen, også når serveren ikke svarede.
- Live-synk i appen beder om en billet og åbner socket'en på serveren med den.
- Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen
  prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router
  eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af
  første konto vises ikke i appen; begge hører til serverens eget domæne.
- Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob,
  i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og
  `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse.
- Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem.

Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes
for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme
server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger
server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en,
viser et billede fra en blob og logger ud.

Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer,
socket uden billet, billeder ikke hentet, token efterladt ved log ud.

Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-23 14:16:33 +02:00
co-authored by Claude Opus 5.5
parent a71af41d9e
commit b9028e8f7a
20 changed files with 728 additions and 23 deletions
+22
View File
@@ -8,6 +8,8 @@ import (
"time"
"github.com/coder/websocket"
"github.com/kristianwind/verdande/internal/config"
)
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
@@ -200,3 +202,23 @@ func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
t.Errorf("control: the instance's own origin: %v", err)
}
}
// The end-to-end suite runs the interface from a second host name and tells the
// server to count it as the app's. That origin is then the app's in every
// respect, and capacitor://localhost stays one too.
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.AppOrigins = []string{"http://app.localhost:8097"}
})
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Errorf("%s: Allow-Origin %q", origin, got)
}
}
// Not a prefix match: a neighbour on another port is nobody.
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("a neighbouring origin was answered: %q", got)
}
}
+3 -3
View File
@@ -23,10 +23,10 @@ import (
// something this header ever needs to unlock. Every other origin gets no CORS
// headers at all, exactly as before — the browser then keeps a foreign page from
// reading anything, which is the default this must not loosen.
func appCORS(next http.Handler) http.Handler {
func (s *Server) appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !isAppOrigin(origin) {
if !s.isAppOrigin(origin) {
next.ServeHTTP(w, r)
return
}
@@ -129,7 +129,7 @@ func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
// ticket is not the app. Refused here rather than left to the origin
// check, which compares host names only: capacitor://localhost and an
// instance served at http://localhost have the same one.
if isAppOrigin(r.Header.Get("Origin")) {
if s.isAppOrigin(r.Header.Get("Origin")) {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
return
}
+10 -2
View File
@@ -222,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
// this Origin — the browser writes the header, not the page — and an app
// login sets no cookie, so there is no session here for a forged request
// to ride on.
if isAppOrigin(r.Header.Get("Origin")) {
if s.isAppOrigin(r.Header.Get("Origin")) {
next.ServeHTTP(w, r)
return
}
@@ -251,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
// makes to a server arrives from here, whichever server it is.
var appOrigins = []string{"capacitor://localhost"}
func isAppOrigin(origin string) bool {
func (s *Server) isAppOrigin(origin string) bool {
if origin == "" {
return false
}
for _, o := range appOrigins {
if origin == o {
return true
}
}
for _, o := range s.cfg.AppOrigins {
if origin == o {
return true
}
}
return false
}
+1 -1
View File
@@ -279,7 +279,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Route("/api/v1", func(r chi.Router) {
// Before the CSRF lock, so a preflight is answered and never reaches it.
r.Use(appCORS)
r.Use(s.appCORS)
r.Use(s.requireCSRF)
r.Get("/ping", s.handlePing)