diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index fafbf810..73c7b042 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -31,6 +31,7 @@ MODULES=( "lib/dream/websocket.sx" "lib/dream/static.sx" "lib/dream/error.sx" + "lib/dream/cors.sx" "lib/dream/run.sx" "lib/dream/demos/hello.sx" "lib/dream/demos/counter.sx" @@ -49,6 +50,7 @@ SUITES=( "websocket dream-ws-tests-run! lib/dream/tests/websocket.sx" "static dream-st-tests-run! lib/dream/tests/static.sx" "error dream-er-tests-run! lib/dream/tests/error.sx" + "cors dream-co-tests-run! lib/dream/tests/cors.sx" "run dream-rn-tests-run! lib/dream/tests/run.sx" "demos dream-dm-tests-run! lib/dream/tests/demos.sx" ) diff --git a/lib/dream/cors.sx b/lib/dream/cors.sx new file mode 100644 index 00000000..c0d25756 --- /dev/null +++ b/lib/dream/cors.sx @@ -0,0 +1,51 @@ +;; lib/dream/cors.sx — Dream-on-SX CORS middleware. +;; Decorates responses with Access-Control-Allow-* headers and short-circuits +;; preflight OPTIONS requests with a 204. Depends on types.sx. + +(define dream-cors-defaults {:methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" :headers "Content-Type" :max-age 86400 :credentials false :origin "*"}) + +(define + dr/cors-origin-headers + (fn + (opts resp) + (let + ((r1 (dream-add-header resp "access-control-allow-origin" (get opts :origin)))) + (if + (get opts :credentials) + (dream-add-header r1 "access-control-allow-credentials" "true") + r1)))) + +(define + dr/cors-preflight + (fn + (opts) + (dr/cors-origin-headers + opts + (dream-add-header + (dream-add-header + (dream-add-header + (dream-empty 204) + "access-control-allow-methods" + (get opts :methods)) + "access-control-allow-headers" + (get opts :headers)) + "access-control-max-age" + (str (get opts :max-age)))))) + +(define + dream-cors-with + (fn + (opts) + (fn + (next) + (fn + (req) + (if + (= (dream-method req) "OPTIONS") + (dr/cors-preflight opts) + (dr/cors-origin-headers opts (next req))))))) + +(define dream-cors (dream-cors-with dream-cors-defaults)) +(define + dream-cors-origin + (fn (origin) (dream-cors-with (assoc dream-cors-defaults :origin origin)))) diff --git a/lib/dream/tests/cors.sx b/lib/dream/tests/cors.sx new file mode 100644 index 00000000..51ee9dc7 --- /dev/null +++ b/lib/dream/tests/cors.sx @@ -0,0 +1,93 @@ +;; lib/dream/tests/cors.sx — CORS decoration + preflight. + +(define dream-co-pass 0) +(define dream-co-fail 0) +(define dream-co-fails (list)) + +(define + dream-co-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-co-pass (+ dream-co-pass 1)) + (begin + (set! dream-co-fail (+ dream-co-fail 1)) + (append! dream-co-fails {:name name :actual actual :expected expected}))))) + +(define dream-co-h (fn (req) (dream-text "payload"))) +(define dream-co-app (dream-cors dream-co-h)) + +;; ── decoration of normal responses ───────────────────────────────── +(define dream-co-get (dream-co-app (dream-request "GET" "/" {} ""))) +(dream-co-test + "allow-origin star" + (dream-resp-header dream-co-get "access-control-allow-origin") + "*") +(dream-co-test "body preserved" (dream-resp-body dream-co-get) "payload") +(dream-co-test "status preserved" (dream-status dream-co-get) 200) +(dream-co-test + "no credentials by default" + (dream-resp-header dream-co-get "access-control-allow-credentials") + nil) + +;; ── preflight OPTIONS ────────────────────────────────────────────── +(define + dream-co-pre + (dream-co-app (dream-request "OPTIONS" "/" {} ""))) +(dream-co-test "preflight 204" (dream-status dream-co-pre) 204) +(dream-co-test + "preflight origin" + (dream-resp-header dream-co-pre "access-control-allow-origin") + "*") +(dream-co-test + "preflight methods" + (contains? + (dream-resp-header dream-co-pre "access-control-allow-methods") + "POST") + true) +(dream-co-test + "preflight headers" + (dream-resp-header dream-co-pre "access-control-allow-headers") + "Content-Type") +(dream-co-test + "preflight max-age" + (dream-resp-header dream-co-pre "access-control-max-age") + "86400") + +;; ── custom origin ────────────────────────────────────────────────── +(define + dream-co-custom + ((dream-cors-origin "https://app.example.com") dream-co-h)) +(dream-co-test + "custom origin" + (dream-resp-header + (dream-co-custom (dream-request "GET" "/" {} "")) + "access-control-allow-origin") + "https://app.example.com") + +;; ── credentials enabled ──────────────────────────────────────────── +(define + dream-co-cred + ((dream-cors-with (assoc dream-cors-defaults :credentials true)) + dream-co-h)) +(dream-co-test + "credentials header" + (dream-resp-header + (dream-co-cred (dream-request "GET" "/" {} "")) + "access-control-allow-credentials") + "true") + +;; ── composes around a router ─────────────────────────────────────── +(define + dream-co-router + (dream-cors + (dream-router (list (dream-get "/api" (fn (req) (dream-json "{}"))))))) +(dream-co-test + "router cors origin" + (dream-resp-header + (dream-co-router (dream-request "GET" "/api" {} "")) + "access-control-allow-origin") + "*") + +(define dream-co-tests-run! (fn () {:total (+ dream-co-pass dream-co-fail) :passed dream-co-pass :failed dream-co-fail :fails dream-co-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 99ec7e39..b8d54a6d 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -99,6 +99,11 @@ with extensions + hardening below. custom page receiving `(req e)`); normal responses pass through untouched, composes around a router. (`guard` catches explicit `(error …)` raises; `e` stringifies to the message.) +- **2026-06-07 — Ext: CORS** (`lib/dream/cors.sx`, 12 tests, 294 total). `dream-cors` + decorates responses with `Access-Control-Allow-Origin` (+ credentials), and + short-circuits preflight `OPTIONS` with a 204 carrying Allow-Methods/Headers/Max-Age. + `dream-cors-origin` for a specific origin, `dream-cors-with opts` for full control + (origin/methods/headers/credentials/max-age). Composes around a router. ## Extensions (post-roadmap) @@ -107,7 +112,7 @@ The five-types core is complete; these harden it toward a production HTTP front - [x] **Router HTTP correctness**: 405 Method Not Allowed + `Allow` header; automatic HEAD (serve the GET handler with an empty body). - [x] **Status reason phrases** + `dream-status-text` (`lib/dream/error.sx`). -- [ ] **CORS middleware** (`dream-cors`). +- [x] **CORS middleware** (`dream-cors`). - [x] **Error-handling middleware** (`dream-catch` / custom 500 templates; `guard`-based). - [ ] **Signed session cookies** (the noted hardening — sign the sid). - [ ] **JSON helpers** (build from dict; parse to dict).