Files
rose-ash/lib/host/session.sx
giles b0b0a0592b host: durable lazy sessions — logins survive a restart
Sessions were in-memory, so a restart logged everyone out (same class as the
relation wipe). Move them to the durable store, but LAZILY so anonymous/crawler
traffic doesn't spam it: session/create mints a sid with no row; the row appears
on the first session/set (a login). A per-boot epoch (one durable write at
startup, host/session-init!) keeps sids unique across restarts without a write
per request.

- lib/host/session.sx: lazy backend (create = no row, set = create row,
  exists = row written) + epoch/in-memory-counter sid generation.
- serve.sh: point the session store at the durable backend + host/session-init!.
- blog.sx: host/current-principal is now a durable read, so host/auth-footer
  (home + post footers) had to move OUT of the quasiquote into let bindings —
  a perform during page-tree build raises VmSuspended (the whole site 500'd for
  a beat). Principal computed once per page.
- 2 session tests: create writes no row, set creates the row.

249/249. Verified live: site renders (anon + authed), login + footer survive a
container force-recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:37:26 +00:00

82 lines
4.3 KiB
Plaintext

;; 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-epoch-key "session:-epoch")
;; sid generation: a per-BOOT epoch (one durable write at startup) + an in-memory
;; counter. The epoch keeps sids unique across restarts WITHOUT a write per
;; request, so anonymous traffic costs no disk. host/session-init! bumps the epoch
;; on boot (serve.sh); without it (e.g. tests) epoch 0 is fine within one process.
(define host/session-epoch 0)
(define host/session-ctr 0)
(define host/session-init!
(fn ()
(let ((e (+ 1 (or (persist/backend-kv-get host/session-store host/-sess-epoch-key) 0))))
(begin
(persist/backend-kv-put host/session-store host/-sess-epoch-key e)
(set! host/session-epoch e)
(set! host/session-ctr 0)))))
(define host/-sess-next-sid
(fn ()
(begin
(set! host/session-ctr (+ host/session-ctr 1))
(str "s" host/session-epoch "-" host/session-ctr))))
;; ── backend io fn: dispatch session/* ops onto the persist KV ───────
;; LAZY: session/create mints a sid but writes NO row, so an anonymous request
;; (which never sets a field) leaves no durable trace — the store isn't spammed by
;; crawlers. The row appears on the first session/set (i.e. login), so a logged-in
;; session persists and survives a restart; session/exists is "has a written row".
(define host/session-backend
(fn (op)
(let ((kind (get op :op)))
(cond
((= kind "session/create") (host/-sess-next-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)))