host: live writes via signed sessions + kernel multi-Set-Cookie (193/193)

Unblock the guarded blog write routes for browsers: a login form sets a
signed session cookie that the same routes accept (alongside Bearer), so
publishing works end-to-end on blog.rose-ash.com without Quart.

- kernel: http-listen emit serialises a response :set-cookies LIST as one
  Set-Cookie header each (a headers dict can't hold more than one). Purely
  additive — responses without :set-cookies are unchanged.
- server.sx: host/-dream->native forwards :set-cookies to the native resp.
- lib/host/session.sx: durable, signed sessions on the persist KV
  (session/create|exists|get|set|clear), wired via dream-sessions-signed.
- lib/host/auth.sx: GET/POST /login + POST /logout; host/require-user accepts
  a session principal OR a Bearer token.
- router.sx: host/make-app wraps the whole app in the session middleware and
  auto-mounts /login + /logout — the front door always has sessions.
- blog.sx: write routes use host/require-user; serve.sh flips POST /new from
  the experimental UNGUARDED route to the guarded write routes, with admin
  creds + signing secret + ACL grant from the container env.
- session conformance suite (12): login->cookie->guarded write 201; no
  cookie/forged/logged-out -> 401; Bearer fallback still works.

Verified live on blog.rose-ash.com: 401 unauthenticated, 303 login, 303
publish, anonymous read renders, post persists across container recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 21:51:41 +00:00
parent 2713636e36
commit 3b8e1dfe2e
10 changed files with 357 additions and 15 deletions

96
lib/host/auth.sx Normal file
View File

@@ -0,0 +1,96 @@
;; lib/host/auth.sx — browser login on top of host sessions (lib/host/session.sx).
;; A login form posts credentials; on success the principal is written to the
;; session cookie. The guarded write routes then accept EITHER a logged-in session
;; OR a Bearer token (host/require-user), so the same routes serve browsers and API
;; clients. Single admin user; credentials come from $SX_ADMIN_USER / _PASSWORD
;; (set in serve.sh) — the in-source defaults are dev-only.
;;
;; Depends on lib/host/session.sx, lib/host/{handler,middleware}.sx, lib/dream/*
;; (form/types/session) + the kernel render-page primitive.
;; ── page shell (own copy; render-page renders the static SX tree) ───
(define host/-auth-page
(fn (title body)
(str "<!doctype html>"
(render-page
(quasiquote
(html
(head (meta :charset "utf-8") (title (unquote title)))
(body (unquote body))))))))
;; ── admin credential (override from env in serve.sh) ────────────────
(define host/admin-user "admin")
(define host/admin-password "letmein")
(define host/auth-set-admin!
(fn (u p) (begin (set! host/admin-user u) (set! host/admin-password p))))
(define host/-verify-cred
(fn (user pass)
(and (not (= pass ""))
(= user host/admin-user)
(= pass host/admin-password))))
;; ── GET /login — minimal SX login form ──────────────────────────────
(define host/login-page
(fn (req)
(dream-html
(host/-auth-page "Log in"
(quasiquote
(div
(h1 "Log in")
(form :method "post" :action "/login"
(p (input :name "username" :placeholder "username"))
(p (input :name "password" :type "password" :placeholder "password"))
(p (button :type "submit" "Log in")))))))))
;; ── POST /login — verify, write session principal, redirect home ────
;; The session middleware (host/sessions) has already created/loaded the session
;; and will set the cookie on this response, so writing :principal here lands on
;; the right sid and the browser keeps the cookie.
(define host/login-submit
(fn (req)
(let ((user (dream-form-field req "username"))
(pass (dream-form-field req "password")))
(if (host/-verify-cred user pass)
(begin
(host/login! req user)
(dream-redirect "/"))
(dream-html-status 401
(host/-auth-page "Log in"
(quasiquote
(div (h1 "Log in")
(p "Invalid credentials.")
(p (a :href "/login" "Try again."))))))))))
;; ── POST /logout — clear the session, redirect home ─────────────────
(define host/logout-submit
(fn (req)
(begin
(host/logout! req)
(dream-redirect "/"))))
;; ── login routes (mounted by host/make-app) ─────────────────────────
(define host/auth-routes
(list
(dream-get "/login" host/login-page)
(dream-post "/login" host/login-submit)
(dream-post "/logout" host/logout-submit)))
;; ── auth middleware: session principal OR bearer token ──────────────
;; Place AFTER the session middleware (so host/current-principal can read the
;; session) and BEFORE host/require-permission. resolve : token -> principal | nil
;; is the bearer fallback for API clients; a logged-in browser needs no token.
(define host/require-user
(fn (resolve)
(fn (next)
(fn (req)
(let ((sp (host/current-principal req)))
(let ((principal
(if (and sp (not (= sp "")))
sp
(let ((tok (dream-bearer-token req)))
(if tok (resolve tok) nil)))))
(if (or (nil? principal) (= principal ""))
(dream-add-header
(host/error 401 "unauthorized")
"www-authenticate" "Bearer")
(next (assoc req :dream-principal principal)))))))))