host: Phase 2 — middleware (auth+ACL+error) + guarded POST /feed, 43/43

Composable handler->handler layers over Dream's primitives, with auth and
permission POLICY injected so the layer is policy-free and testable:

- middleware.sx: host/wrap-errors (JSON 500 via dream-catch-with),
  host/require-auth (bearer->principal via dream-bearer-token, JSON 401,
  injected token resolver), host/require-permission (lib/acl acl/permit? gate,
  JSON 403, injected resource extractor), host/pipeline (first = outermost)
- feed.sx: POST /feed via host/feed-write-routes — auth ∘ ACL(post,feed) ∘
  wrap-errors over host/feed-create (parse JSON body -> feed/post -> 201;
  non-object -> 400). Created activity reads back via GET /feed.
- middleware suite (9) + feed write tests (6 new); conformance preloads now
  include the Datalog engine + ACL subsystem + Dream auth/error.

ACL works with string atoms (no symbol coercion). Mute/prefs layer and sxtp.sx
deferred to the next tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:48:18 +00:00
parent d5a1c8370c
commit 2ffdd6f078
6 changed files with 291 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
;; lib/host/tests/feed.sx — the first migrated endpoint, GET /feed. Includes a
;; golden test: the host response body must equal the feed subsystem's own
;; recent-first stream wrapped in the standard envelope — the endpoint adds the
;; HTTP/JSON shell and nothing else.
;; lib/host/tests/feed.sx — the migrated feed endpoints, GET /feed (read) and
;; POST /feed (guarded write). Includes a golden test: the host read response
;; body must equal the feed subsystem's own recent-first stream wrapped in the
;; standard envelope — the endpoint adds the HTTP/JSON shell and nothing else.
(define host-fd-pass 0)
(define host-fd-fail 0)
@@ -43,7 +43,7 @@
(feed/post {:actor "bob" :verb "post" :object "p2" :at 2})
(feed/post {:actor "alice" :verb "like" :object "p2" :at 3})
;; recent-first: newest activity (at 3) leads, so its object p2 appears before p1.
;; recent-first: newest activity (at 3) leads, so its marker precedes the oldest.
(host-fd-test
"timeline recent-first"
(let ((body (dream-resp-body (host-fd-app (host-fd-req "/feed")))))
@@ -88,6 +88,47 @@
(feed/items (feed/by-actor (feed/recent (feed/all)) "alice")))
"}"))
;; ── write: POST /feed (auth + ACL + action) ────────────────────────
(acl/load! (list (acl-grant "alice" "post" "feed")))
(define host-fd-resolve (fn (tok) (if (= tok "good") "alice" nil)))
(define
host-fd-wapp
(host/make-app
(list host/feed-routes (host/feed-write-routes host-fd-resolve))))
(define
host-fd-post
(fn (auth body)
(dream-request "POST" "/feed" (if auth {:authorization auth} {}) body)))
(feed/reset!)
(host-fd-test
"post no auth -> 401"
(dream-status (host-fd-wapp (host-fd-post nil "{}")))
401)
(host-fd-test
"post unchanged feed after 401"
(feed/size)
0)
(host-fd-test
"post authed+permitted -> 201"
(dream-status
(host-fd-wapp
(host-fd-post
"Bearer good"
"{\"actor\":\"alice\",\"verb\":\"post\",\"object\":\"p9\",\"at\":9}")))
201)
(host-fd-test "post grew feed" (feed/size) 1)
(host-fd-test
"created activity visible in timeline"
(contains?
(dream-resp-body (host-fd-wapp (host-fd-req "/feed")))
"p9")
true)
(host-fd-test
"post non-object body -> 400"
(dream-status (host-fd-wapp (host-fd-post "Bearer good" "[1,2]")))
400)
(define
host-fd-tests-run!
(fn

View File

@@ -0,0 +1,107 @@
;; lib/host/tests/middleware.sx — auth (bearer -> principal), ACL gate, and error
;; trapping, composed via host/pipeline. ACL facts: alice may "post" on "feed".
(define host-mw-pass 0)
(define host-mw-fail 0)
(define host-mw-fails (list))
(define
host-mw-test
(fn
(name actual expected)
(if
(= actual expected)
(set! host-mw-pass (+ host-mw-pass 1))
(begin
(set! host-mw-fail (+ host-mw-fail 1))
(append! host-mw-fails {:name name :actual actual :expected expected})))))
;; ── fixtures ───────────────────────────────────────────────────────
(acl/load! (list (acl-grant "alice" "post" "feed")))
(define host-mw-resolve
(fn (tok) (if (= tok "good") "alice" nil)))
(define host-mw-handler
(fn (req) (host/ok-status 201 (host/principal req))))
;; protected: needs auth + post/feed permission
(define host-mw-protected
(host/pipeline
(list
(host/require-auth host-mw-resolve)
(host/require-permission "post" (fn (req) "feed")))
host-mw-handler))
;; protected with an action alice is NOT granted
(define host-mw-protected-del
(host/pipeline
(list
(host/require-auth host-mw-resolve)
(host/require-permission "delete" (fn (req) "feed")))
host-mw-handler))
(define
host-mw-req
(fn (auth)
(dream-request "POST" "/feed"
(if auth {:authorization auth} {})
"")))
;; ── auth ───────────────────────────────────────────────────────────
(host-mw-test
"no token -> 401"
(dream-status (host-mw-protected (host-mw-req nil)))
401)
(host-mw-test
"401 has www-authenticate"
(dream-resp-header (host-mw-protected (host-mw-req nil)) "www-authenticate")
"Bearer")
(host-mw-test
"bad token -> 401"
(dream-status (host-mw-protected (host-mw-req "Bearer wrong")))
401)
;; ── authz ──────────────────────────────────────────────────────────
(host-mw-test
"authed + permitted -> 201"
(dream-status (host-mw-protected (host-mw-req "Bearer good")))
201)
(host-mw-test
"principal threaded to handler"
(contains?
(dream-resp-body (host-mw-protected (host-mw-req "Bearer good")))
"\"data\":\"alice\"")
true)
(host-mw-test
"authed but not permitted -> 403"
(dream-status (host-mw-protected-del (host-mw-req "Bearer good")))
403)
(host-mw-test
"403 envelope"
(contains?
(dream-resp-body (host-mw-protected-del (host-mw-req "Bearer good")))
"\"error\":\"forbidden\"")
true)
;; ── error trapping ─────────────────────────────────────────────────
(define host-mw-boom (fn (req) (error "kaboom")))
(host-mw-test
"wrap-errors -> 500"
(dream-status ((host/wrap-errors host-mw-boom) (host-mw-req nil)))
500)
(host-mw-test
"500 envelope"
(contains?
(dream-resp-body ((host/wrap-errors host-mw-boom) (host-mw-req nil)))
"\"ok\":false")
true)
(define
host-mw-tests-run!
(fn
()
{:total (+ host-mw-pass host-mw-fail)
:passed host-mw-pass
:failed host-mw-fail
:fails host-mw-fails}))