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

70
lib/host/session.sx Normal file
View File

@@ -0,0 +1,70 @@
;; lib/host/session.sx — durable, signed sessions for the host.
;; Backs Dream's session middleware ops (session/create|exists|get|set|clear)
;; with the SAME durable persist KV the blog uses, so a login survives restarts.
;; The session cookie carries only a signed sid (dream-sessions-signed): the sid
;; itself is a persisted monotonic counter ("s1", "s2", …) — cheap and ordered —
;; and the HMAC signature (dr/sess-hash, keyed by host/session-secret) makes a
;; guessed or forged cookie unusable. http-listen serialises handler calls under a
;; mutex, so the counter increment is race-free.
;;
;; Depends on lib/dream/session.sx (dream-sessions-signed + cookie helpers) and
;; lib/persist/* (the KV backend). Wired into host/make-app via host/sessions.
;; ── store (durable persist KV, injectable; mirrors host/blog-store) ──
(define host/session-store (persist/open))
(define host/session-use-store! (fn (b) (set! host/session-store b)))
;; ── signing secret (override from $SX_SESSION_SECRET in serve.sh) ────
(define host/session-secret "rose-ash-host-dev-secret-change-me")
(define host/session-set-secret! (fn (s) (set! host/session-secret s)))
;; ── keys ────────────────────────────────────────────────────────────
(define host/-sess-key (fn (sid) (str "session:" sid)))
(define host/-sess-counter-key "session:-counter")
;; mint the next sid from a persisted counter (signature guards guessability)
(define host/-sess-next-sid
(fn ()
(let ((n (+ 1 (or (persist/backend-kv-get host/session-store host/-sess-counter-key) 0))))
(begin
(persist/backend-kv-put host/session-store host/-sess-counter-key n)
(str "s" n)))))
;; ── backend io fn: dispatch session/* ops onto the persist KV ───────
(define host/session-backend
(fn (op)
(let ((kind (get op :op)))
(cond
((= kind "session/create")
(let ((sid (host/-sess-next-sid)))
(begin
(persist/backend-kv-put host/session-store (host/-sess-key sid) {})
sid)))
((= kind "session/exists")
(persist/backend-kv-has? host/session-store (host/-sess-key (get op :sid))))
((= kind "session/get")
(get
(or (persist/backend-kv-get host/session-store (host/-sess-key (get op :sid))) {})
(get op :key)))
((= kind "session/set")
(let ((sid (get op :sid)))
(persist/backend-kv-put host/session-store (host/-sess-key sid)
(assoc
(or (persist/backend-kv-get host/session-store (host/-sess-key sid)) {})
(get op :key)
(get op :val)))))
((= kind "session/load")
(or (persist/backend-kv-get host/session-store (host/-sess-key (get op :sid))) {}))
((= kind "session/clear")
(persist/backend-kv-delete host/session-store (host/-sess-key (get op :sid))))
(else nil)))))
;; ── middleware for the host pipeline: signed cookie + durable backend ─
(define host/sessions
(fn () (dream-sessions-signed host/session-backend host/session-secret)))
;; ── handler-facing helpers ──────────────────────────────────────────
;; The logged-in principal (or nil), and login/logout writing the session field.
(define host/current-principal (fn (req) (dream-session-field req :principal)))
(define host/login! (fn (req principal) (dream-set-session-field req :principal principal)))
(define host/logout! (fn (req) (dream-invalidate-session req)))