feat: framework v1 slice 2 — the remaining ledger, ten items

- After seam: interface After + Aw + use_after; dispatch funnels every
  response (handler/short-circuit/404/405) through the after chain;
  the WS 101 sentinel skips it (never serialized)
- http/secure.wo: SecurityHeaders (nosniff/DENY/referrer; HSTS stays
  at the TLS proxy), Cors (preflight 204 before + origin stamp after,
  one class both halves), HostAllow (421), client_ip (XFF parsing —
  peer VERIFY stays story 35)
- http/nego.wo: accepts() (exact, type/*, */*; q stripped not ranked),
  etag_for (quoted base64 sha256), with_etag (If-None-Match -> 304)
- router: *rest wildcard (last segment, empty rest matches), Group
  (prefix + routes + group middleware) + Gmw prefix-scoped entries,
  App.mount; new App fields carry defaults so standing ctor literals
  keep compiling
- Req grows ctx bag; parse rejects duplicate Content-Length (400,
  RFC 9112 §6.3)
- web-app exercises all of it; gate grows 26 -> 38 checks (wildcards,
  group+ctx, etag+304, 406/200 negotiation, sec headers, 421,
  preflight+origin stamp, dup-CL 400)
- ledger rows flipped; dep graph section 3 grown (slice-2 done nodes,
  crypto gate cleared, cookie/CSRF/session/webhook/JWT now ready)
- merges: chat-ws-lifecycle (digests for ETag; WS + lifecycle ride
  along) + site-sample (second consumer gate); battery 13/13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-23 06:45:51 +02:00
parent e8563bed16
commit 40127bf53e
10 changed files with 467 additions and 54 deletions

View file

@ -148,38 +148,38 @@ flowchart TD
classDef gate fill:#8250df,color:#fff,stroke:none
classDef ready fill:#1a7f37,color:#fff,stroke:none
classDef blocked fill:#eac54f,color:#000,stroke:none
classDef done fill:#6e7781,color:#fff,stroke:none
CORS["CORS middleware"]:::ready
SECH["security-headers middleware"]:::ready
HOSTV["host validation"]:::ready
STRICT["strict-parsing audit (dup/conflicting Content-Length)"]:::ready
WILD["wildcard segments *rest"]:::ready
PREC["specificity precedence"]:::blocked
GROUPS["route groups"]:::ready
CTX["req.ctx bag"]:::ready
XFF["X-Forwarded-For/-Proto parsing"]:::ready
ACCEPT["Accept-driven negotiation"]:::ready
CORS["CORS middleware ✅ slice 2"]:::done
SECH["security-headers middleware ✅ slice 2"]:::done
HOSTV["host validation (421) ✅ slice 2"]:::done
STRICT["strict-parsing audit (dup Content-Length = 400) ✅ slice 2"]:::done
WILD["wildcard segments *rest ✅ slice 2"]:::done
PREC["precedence: registration order stands, wildcards last by construction ✅"]:::done
GROUPS["route groups + group middleware ✅ slice 2"]:::done
CTX["req.ctx bag ✅ slice 2"]:::done
XFF["client_ip: X-Forwarded-For parsing ✅ slice 2 (peer VERIFY stays gated)"]:::done
ACCEPT["accepts(): response-side negotiation ✅ slice 2"]:::done
NETSEAM["GATE: net runtime seams (timeouts, unix socket, peer address) — story 35 owns"]:::gate
TMOUT["read/write/idle timeouts"]:::blocked
UNIX["unix socket binding"]:::blocked
PEERV["trusted-proxy PEER verification"]:::blocked
CRYPTO["GATE: story 34 crypto — C builtins vs pure-.wo (bitwise landed with 36, both possible; brainstorm decides); carriers (Bytes, base64) landed with 19"]:::gate
SHA["SHA-256/512, HMAC, CRC32"]:::blocked
ETAG["ETag + conditional requests"]:::blocked
COOKIE["signed cookies"]:::blocked
CSRF["CSRF"]:::blocked
SESS["session integrity"]:::blocked
HOOKV["webhook verification"]:::blocked
JWT["JWT HS256 (HARD STOP after)"]:::blocked
CRYPTO["GATE CLEARED: iteration 34 landed C builtins — sha1/sha256/hmac_sha256 (ids 85-87)"]:::done
SHA["sha1/sha256/hmac_sha256 ✅ iteration 34; SHA-512/CRC32 wait for a consumer"]:::done
ETAG["ETag + If-None-Match 304 ✅ slice 2"]:::done
COOKIE["signed cookies"]:::ready
CSRF["CSRF"]:::ready
SESS["session integrity"]:::ready
HOOKV["webhook verification"]:::ready
JWT["JWT HS256 (HARD STOP after)"]:::ready
RADIX["radix-tree routing"]:::blocked
I9E3["GATE: router scan unmeasured — 22's harness landed but benched the DB, not the router; perf-targets entry first"]:::gate
STORAGE["storage-integration rows: migrations (future story), eager loading + tenant roots (query-surface work, 9-series)"]:::blocked
WILD --> PREC
NETSEAM --> TMOUT
NETSEAM --> UNIX
NETSEAM --> PEERV
@ -193,12 +193,18 @@ flowchart TD
I9E3 --> RADIX
```
Green nodes (CORS, security headers, host validation, strict-parsing
audit, wildcards, route groups, `req.ctx`, XFF parsing, Accept
negotiation) need nothing — startable in any order, gated by
`just web-app`. Note: 21's keypair crypto is its own C implementation
(already on branch `keypair-auth`) — it neither waits for nor feeds the
crypto-fork gate.
**Slice 2 landed (2026-08-23, branch `framework-v1b`):** every
formerly-green node plus the crypto chain's first consumers — CORS,
security headers, host validation (421), strict dup-Content-Length,
`*rest` wildcards, route groups + group middleware, `req.ctx`,
`client_ip`, `accepts()`, ETag/304 — all gated by `just web-app`
(38 checks). The after-middleware seam (`After`/`use_after`) carries
the response-header half. Now READY with the digest builtins landed:
signed cookies, CSRF, session integrity, webhook verification, JWT
HS256 (the hard stop). Still gated: timeouts/unix-socket/peer-verify
(story 35's net seams) and the radix tree (router scan unmeasured).
Note: 21's keypair crypto is its own C implementation (already on
branch `keypair-auth`) — it neither waits for nor feeds this chain.
## 4. Framework v2 (iteration 18) — internal order

View file

@ -118,6 +118,57 @@ class DeleteProduct {
}
}
-- ---- framework v1 slice 2: the storefront exercises the new surface ----
-- wildcard capture: GET /files/*path echoes the rest
class EchoPath {
pad: Int
fn handle(req: Req) -> Resp {
let p = req.params["path"];
if p == nil { return ok_text("path="); }
return ok_text("path=${p}");
}
}
-- group middleware writes the request-scoped ctx bag; the handler reads it
class StampCtx {
pad: Int
fn before(mut req: Req) -> ?Resp {
req.ctx["via"] = "api-group";
return nil;
}
}
class ApiPing {
pad: Int
fn handle(req: Req) -> Resp {
let via = req.ctx["via"];
if via == nil { return ok_text("pong via="); }
return ok_text("pong via=${via}");
}
}
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
class EtagProbe {
pad: Int
fn handle(req: Req) -> Resp {
return with_etag(req, ok_json("{\"v\":1}"));
}
}
-- response-side negotiation: JSON or nothing
class NegoProbe {
pad: Int
fn handle(req: Req) -> Resp {
if accepts(req, "application/json") == false {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
return Resp { status: 406, headers: h, body: "{\"error\":\"json only\"}" };
}
return ok_json("{\"ok\":true}");
}
}
fn main(args: multi Text) -> Int {
if len(args) < 1 {
print_err("usage: web-app <port> (WA_TOKEN and WO_DATA must be set)");
@ -135,13 +186,27 @@ fn main(args: multi Text) -> Int {
}
let app = App { middleware: [], routes: [] };
-- the framework's Bearer mechanism: constant-time compare, principal
-- attached to req.principal for handlers that want "who is this"
-- v1 slice 2: host gate first (421 before anything runs), CORS preflight
-- next, then the framework's Bearer mechanism (constant-time compare,
-- principal attached to req.principal for handlers that want "who")
app.use_mw(Mw { m: HostAllow { host: "a" } });
app.use_mw(Mw { m: Cors { allow_origin: "*" } });
app.use_mw(Mw { m: BearerAuth { token: token, principal: "api" } });
-- the response half: security headers + the CORS origin stamp on every
-- response that leaves dispatch (404/405/401 included)
app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
app.use_after(Aw { a: Cors { allow_origin: "*" } });
app.get("/products", ListProducts { pad: 0 });
app.get("/products/:name", ShowProduct { pad: 0 });
app.post("/products", CreateProduct { pad: 0 });
app.post("/orders", CreateOrder { pad: 0 });
app.delete_("/products/:name", DeleteProduct { pad: 0 });
app.get("/files/*path", EchoPath { pad: 0 });
app.get("/etag-probe", EtagProbe { pad: 0 });
app.get("/nego", NegoProbe { pad: 0 });
let g = Group { prefix: "/api" };
g.use_mw(Mw { m: StampCtx { pad: 0 } });
g.get("/ping", ApiPing { pad: 0 });
app.mount(g);
return app.serve("127.0.0.1", port);
}

View file

@ -80,7 +80,7 @@ first (pure `.wo` cannot express it yet).
| Item | State |
| --- | --- |
| HTTP/1.1 parsing | 🔶 parses + 400-and-survive; STRICT ambiguity rejection (duplicate/conflicting `Content-Length`, oversize checks beyond BODY_MAX) not audited — hardening slice |
| HTTP/1.1 parsing | ✅ parses + 400-and-survive; duplicate `Content-Length` rejected outright (RFC 9112 §6.3, slice 2); BODY_MAX bounds headers and body |
| Keep-alive | ✅ pipelined-serve / close-when-idle (arc landed 2026-08-21; retirement of close-when-idle rides iteration 24's fiber-per-connection slice) |
| Read/write/idle timeouts | 🔧 `net` has no timeout surface — story 35 owns the seam (park_deadline infra already exists for sleeps), then a framework knob |
| Request size limits | ✅ BODY_MAX bounds headers AND body |
@ -93,9 +93,9 @@ first (pure `.wo` cannot express it yet).
| --- | --- |
| Path matching | 🔶 linear scan, first-match-wins; a radix tree waits on a MEASUREMENT first — 22's harness landed (benched the DB, not the router); needs a perf-targets register entry |
| Method dispatch · path params · 404 · 405+`Allow` | ✅ |
| Wildcards | ⬜ only `:param` today; `*rest` capture is a candidate slice |
| Precedence rules | 🔶 registration order IS the rule (documented); specificity-based precedence unneeded until wildcards exist |
| Route groups | ⬜ candidate slice (prefix + per-group middleware) |
| Wildcards | ✅ `*rest` as the LAST pattern segment captures the joined tail (empty rest matches) — slice 2 |
| Precedence rules | ✅ registration order IS the rule; wildcards capture only in last position, so order stays the whole story |
| Route groups | ✅ `Group { prefix }` + per-group before-middleware, mounted in one move — slice 2 |
### Request/response
@ -103,18 +103,18 @@ first (pure `.wo` cannot express it yet).
| --- | --- |
| Case-insensitive headers · query parsing | ✅ (names lowercased on read) |
| JSON · form-urlencoded · multipart | ✅ all three hooks (`json.decode`, `form_values`, `multipart_parts`) |
| Content negotiation | 🔶 `media_type(req)` covers the request side; `Accept`-driven response negotiation ⬜ |
| Trusted-proxy client IP | 🔶 `X-Forwarded-For/-Proto` parsing is expressible (candidate slice); VERIFYING the peer is the trusted proxy needs a peer-address seam 🔧 — story 35 owns it |
| Content negotiation | ✅ `media_type(req)` request-side; `accepts(req, mtype)` response-side (exact, type/*, */*; q-values stripped not ranked — ranking waits for an app serving alternates) — slice 2 |
| Trusted-proxy client IP | 🔶 `client_ip(req)` parses X-Forwarded-For (slice 2); VERIFYING the peer is the trusted proxy still needs the peer-address seam 🔧 — story 35 owns it |
| Status/header setting · redirects | ✅ builders + `set_header` |
| Lazy body streaming + backpressure · streaming responses · explicit commit point | ⏸ UNBLOCKED by the arc (8/11 landed 2026-08-21) — stays parked until its own slice |
| ETag + conditional requests | ⬜ candidate; wants story 34's digests (bitwise landed with 36 — pure-`.wo` vs C-builtin is 34's brainstorm) |
| ETag + conditional requests | ✅ `etag_for` (quoted base64 SHA-256) + `with_etag` (If-None-Match → 304) over iteration 34's digest builtins — slice 2 |
### Context & middleware
| Item | State |
| --- | --- |
| Ordered middleware chain | ✅ registration order, `?Resp` short-circuits |
| Request-scoped context | 🔶 `req.params` + `req.principal` are the context today; a general `req.ctx` bag is a candidate slice |
| Request-scoped context | ✅ `req.ctx` map (slice 2): middleware writes, handlers read; identity stays in `principal` |
| Guaranteed teardown | 🔶 every fd closes on every path (gate-proven); no user teardown hooks yet |
| Cancellation into pending storage ops | ⏸ UNBLOCKED by the arc (8/11 landed 2026-08-21) — stays parked until its own slice |
| Panic recovery | 🔶 trap = 500 and the server survives ✅; "rolls back the transaction" is framework v2 (needs `transaction { }`, iteration 18) |
@ -134,18 +134,18 @@ first (pure `.wo` cannot express it yet).
| Item | State |
| --- | --- |
| Constant-time comparison · Authorization parsing · Basic auth · principal | ✅ `http/auth.wo`, `req.principal` |
| CORS | ⬜ candidate slice (middleware + preflight answers) |
| Security headers | ⬜ candidate slice (one middleware, a header set) |
| Host validation | ⬜ candidate slice (middleware against a host allowlist) |
| Strict parsing | 🔶 same item as Transport's hardening slice |
| CORS | ✅ `Cors { allow_origin }` — preflight 204 (before) + origin stamp on every response (after) — slice 2 |
| Security headers | ✅ `SecurityHeaders` after-middleware (nosniff, DENY, referrer-policy); HSTS stays at the TLS proxy by design — slice 2 |
| Host validation | ✅ `HostAllow { host }` answers 421 before any route — slice 2 |
| Strict parsing | ✅ same item as Transport's row: duplicate Content-Length is a 400 |
### Crypto (self-written, hard-stop after JWT HS256)
| Item | State |
| --- | --- |
| base64 | ✅ pure `.wo` (`http/auth.wo`) |
| SHA-256 · SHA-512 · HMAC · CRC32 | 🔧 the language has NO bitwise operators — these are C runtime builtins (libc-only doctrine permits hand-rolled crypto in the runtime) or the language grows bit ops first; the fork goes to a brainstorm before the slice |
| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ framework slices AFTER the hash primitives exist; **hard stop there** — no RS256, no JOSE zoo |
| SHA-1 · SHA-256 · HMAC-SHA256 | ✅ C runtime builtins (iteration 34, ids 85–87, RFC-vector gated); SHA-512/CRC32 wait for a consumer |
| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ UNBLOCKED (the primitives exist since iteration 34); each is its own slice; **hard stop at JWT HS256** — no RS256, no JOSE zoo |
## Layout and privacy (iteration 17)

View file

@ -1,15 +1,21 @@
-- app.wo — the assembly: an App holds the middleware chain and the route
-- app.wo — the assembly: an App holds the middleware chains and the route
-- table, satisfies internal's Dispatcher interface, and serves.
--
-- let app = App { middleware: [], routes: [] };
-- let app = App { middleware: [], gmw: [], afters: [], routes: [] };
-- app.use_mw(Mw { m: Auth { token: t } });
-- app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
-- app.add(Route { method: "GET", pattern: "/products/:id", h: Show {} });
-- return app.serve("127.0.0.1", port);
--
-- Dispatch order: middleware in registration order (a Resp short-circuits),
-- then the first matching route (method + pattern), else the framework 404.
-- The serve loop wraps dispatch in `try`, so a trapping handler answers 500
-- and the server survives.
-- Dispatch order: global middleware in registration order (a Resp
-- short-circuits), prefix-scoped group middleware next (framework v1
-- slice 2), then the first matching route (method + pattern), else the
-- framework 404/405 — and EVERY one of those responses passes the after
-- chain (security headers, CORS response headers) before it leaves.
-- The one exception is the WS hijack sentinel (status 101): that
-- response is never serialized, so afters skip it.
-- The serve loop wraps dispatch in `try`, so a trapping handler answers
-- 500 and the server survives.
use http
use router
-- iteration 17: the serve loop is library-internal now (internal/serve.wo).
@ -18,16 +24,36 @@ use internal
pub class App {
middleware: multi Mw
-- v1 slice 2 additions carry defaults so the standing ctor literal
-- `App { middleware: [], routes: [] }` keeps compiling everywhere.
gmw: multi Gmw = []
afters: multi Aw = []
routes: multi Route
fn use_mw(take m: Mw) {
push(self.middleware, m);
}
fn use_after(take a: Aw) {
push(self.afters, a);
}
fn add(take r: Route) {
push(self.routes, r);
}
-- Mount a group: its (already prefixed) routes join the table in
-- order; its middleware becomes prefix-scoped entries.
fn mount(take g: Group) {
while len(g.routes) > 0 {
push(self.routes, shift(g.routes));
}
while len(g.middleware) > 0 {
let m = shift(g.middleware);
push(self.gmw, Gmw { prefix: g.prefix, m: m.m });
}
}
-- Registration helpers — the ctor-literal-into-take shape, per method.
fn get(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "GET", pattern: pattern, h: h });
@ -45,11 +71,18 @@ pub class App {
push(self.routes, Route { method: "DELETE", pattern: pattern, h: h });
}
fn dispatch(mut req: Req) -> Resp {
-- The pre-after half of dispatch: first Resp wins.
fn route_req(mut req: Req) -> Resp {
for mw in self.middleware {
let short = mw.m.before(req);
if short != nil { return short; }
}
for g in self.gmw {
if starts_with(req.path, g.prefix) {
let short = g.m.before(req);
if short != nil { return short; }
}
}
-- Path-first matching so a wrong-method hit on a known path answers
-- 405 with the Allow header (registration order) instead of a 404.
let allow = "";
@ -69,7 +102,17 @@ pub class App {
return not_found();
}
fn dispatch(mut req: Req) -> Resp {
let resp = self.route_req(req);
if resp.status != 101 {
for aw in self.afters {
aw.a.after(req, resp);
}
}
return resp;
}
fn serve(host: Text, port: Int) -> Int {
return internal.serve(host, port, self);
}
}
}

View file

@ -0,0 +1,49 @@
-- http/nego.wo — framework v1 slice 2: response-side content negotiation
-- and ETag / conditional requests (the crypto slice's first ledger
-- consumer beyond the WS handshake).
-- Does the request accept this media type? Absent Accept = yes (RFC 9110
-- §12.5.1: no header means anything goes). Matching is exact, type/*,
-- or */*; q-values are stripped, not ranked — v1 answers CAN I send
-- this, not WHICH ONE is best (a ranking negotiation waits for an app
-- that serves alternates).
pub fn accepts(req: Req, mtype: Text) -> Bool {
let acc = req.headers["accept"];
if acc == nil { return true; }
let slash = index_of(mtype, "/");
let major = mtype;
if slash >= 0 { major = substr(mtype, 0, slash); }
for part in split(to_lower("${acc}"), ",") {
let item = trim(part);
let semi = index_of(item, ";");
if semi >= 0 { item = trim(substr(item, 0, semi)); }
if item == mtype { return true; }
if item == "*/*" { return true; }
if item == "${major}/*" { return true; }
}
return false;
}
-- A strong ETag for a body: quoted base64 of its SHA-256. Deterministic,
-- content-addressed — two identical bodies share one tag across
-- restarts and shards.
pub fn etag_for(body: Text) -> Text {
return "\"${base64_encode(sha256(bytes_of_text(body)))}\"";
}
-- Stamp the response's ETag and collapse it to 304 when the request's
-- If-None-Match already has it. The 304 keeps the etag header and
-- drops the body (RFC 9110 §15.4.5). Call it last in a handler:
-- return with_etag(req, ok_json(body));
pub fn with_etag(req: Req, take r: Resp) -> Resp {
let tag = etag_for(r.body);
r.headers["etag"] = tag;
let inm = req.headers["if-none-match"];
if inm != nil {
if trim(inm) == tag {
r.status = 304;
r.body = "";
}
}
return r;
}

View file

@ -0,0 +1,76 @@
-- http/secure.wo — framework v1 slice 2: the security middlewares and the
-- trusted-proxy parsing helper. Mechanism here, POLICY in the app — the
-- same split http/auth.wo keeps. The classes satisfy router's Middleware/
-- After interfaces STRUCTURALLY at the registration site — no import here.
-- The response headers every deployment wants and nobody remembers.
-- HSTS is deliberately absent: TLS terminates at the proxy (the
-- framework's standing decision), so Strict-Transport-Security belongs
-- in the proxy config next to the certificates.
pub class SecurityHeaders {
pad: Int
fn after(req: Req, mut r: Resp) {
r.headers["x-content-type-options"] = "nosniff";
r.headers["x-frame-options"] = "DENY";
r.headers["referrer-policy"] = "strict-origin-when-cross-origin";
}
}
-- CORS, both halves in one class: `before` answers the OPTIONS preflight
-- (204 with the allow set), `after` stamps Access-Control-Allow-Origin on
-- every response to a request that carried an Origin. Register it twice —
-- once as Mw, once as Aw — the structural interfaces make one value
-- satisfy both. allow_origin is the policy knob ("*" or one origin).
pub class Cors {
allow_origin: Text
fn before(mut req: Req) -> ?Resp {
if req.method != "OPTIONS" { return nil; }
let origin = req.headers["origin"];
if origin == nil { return nil; }
let want = req.headers["access-control-request-method"];
if want == nil { return nil; }
let h: map<Text, Text> = {};
h["access-control-allow-origin"] = self.allow_origin;
h["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS";
h["access-control-allow-headers"] = "authorization, content-type";
h["access-control-max-age"] = "600";
return Resp { status: 204, headers: h, body: "" };
}
fn after(req: Req, mut r: Resp) {
let origin = req.headers["origin"];
if origin != nil {
r.headers["access-control-allow-origin"] = self.allow_origin;
}
}
}
-- Host validation: a request whose Host header is missing or not the
-- one this app serves answers 421 (misdirected request) before any
-- route runs. Port suffixes count as part of the host on purpose —
-- behind the proxy the forwarded Host is exactly one known value.
pub class HostAllow {
host: Text
fn before(mut req: Req) -> ?Resp {
let got = req.headers["host"];
if got != nil {
if trim(got) == self.host { return nil; }
}
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
return Resp { status: 421, headers: h, body: "{\"error\":\"misdirected request\"}" };
}
}
-- The PARSING half of trusted-proxy client identity: the left-most
-- X-Forwarded-For entry, trimmed; "" when absent. VERIFYING that the
-- peer actually is the trusted proxy needs a peer-address runtime seam —
-- story 35's, not this slice's.
pub fn client_ip(req: Req) -> Text {
let xff = req.headers["x-forwarded-for"];
if xff == nil { return ""; }
let parts = split("${xff}", ",");
if len(parts) == 0 { return ""; }
return trim(parts[0]);
}

View file

@ -11,6 +11,10 @@ pub typedef Req = {
body: Text, -- exactly Content-Length bytes ("" if none)
principal: Text, -- who this is: "" until an auth middleware
-- (http/auth.wo) authenticates the request
ctx: map<Text, Text>, -- request-scoped bag (v1 slice 2): middleware
-- writes, handlers read — request ids,
-- tenant keys, anything per-request that is
-- not identity (identity is `principal`)
conn: net.Conn -- the connection the request arrived on.
-- INTERNAL plumbing for http/ws.wo's
-- upgrade (iteration 24): handlers never

View file

@ -116,6 +116,7 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed {
path = url_decode(path, false);
let headers: map<Text, Text> = {};
let cl_seen = 0;
let i = 1;
while i < len(lines) {
let line = trim(lines[i]);
@ -123,7 +124,16 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed {
if line == "" { continue; }
let colon = index_of(line, ":");
if colon > 0 {
headers[to_lower(substr(line, 0, colon))] = trim(substr(line, colon + 1, len(line) - colon - 1));
let hname = to_lower(substr(line, 0, colon));
-- v1 slice 2, the strict-ambiguity audit: a SECOND Content-Length
-- header is request smuggling's favorite tool — reject the request
-- outright instead of letting last-one-wins pick a body length
-- (RFC 9112 §6.3: such a message MUST be treated as an error).
if hname == "content-length" {
cl_seen = cl_seen + 1;
if cl_seen > 1 { return malformed(""); }
}
headers[hname] = trim(substr(line, colon + 1, len(line) - colon - 1));
}
}
@ -150,6 +160,7 @@ pub fn parse_request(c: net.Conn, carry: Text) -> Parsed {
}
let req = Req { method: method, path: path, params: {}, query: query,
headers: headers, body: body, principal: "", conn: c };
headers: headers, body: body, principal: "", ctx: {},
conn: c };
return Parsed { closed: false, ok: true, req: req, rest: rest };
}

View file

@ -17,6 +17,20 @@ pub interface Middleware {
fn before(mut req: Req) -> ?Resp
}
-- framework v1 slice 2: the response half of the chain. `after` runs on
-- EVERY response leaving dispatch — handler answers, middleware
-- short-circuits, the framework 404/405 — so security headers and CORS
-- reach all of them. It mutates, never replaces (no ?Resp: an after that
-- could swallow the response would be a second handler).
pub interface After {
fn after(req: Req, mut r: Resp)
}
-- Wrapper record, same reason as Mw/Route.
pub class Aw {
a: After
}
-- One route: method + pattern + the handler value. Built with a ctor
-- literal at the registration site (`Route { method: "GET", pattern:
-- "/products/:id", h: ProductShow {} }`) — the exact ownership shape the
@ -46,7 +60,11 @@ pub class Logging {
-- Does `pattern` match `path`? Fills `params` with :name captures.
-- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the
-- root "/" is the empty segment list). First mismatch wins; a :segment
-- captures anything non-empty.
-- captures anything non-empty. A LAST segment `*name` (framework v1
-- slice 2) captures the whole rest — zero or more segments, re-joined
-- with '/' — so "/files/*path" matches "/files" (path = "") and
-- "/files/a/b" (path = "a/b"). A `*` anywhere else never matches:
-- precedence stays registration order, wildcards last by construction.
pub fn route_match(pattern: Text, path: Text, mut params: map<Text, Text>) -> Bool {
let ps = split(pattern, "/");
let xs = split(path, "/");
@ -54,10 +72,30 @@ pub fn route_match(pattern: Text, path: Text, mut params: map<Text, Text>) -> Bo
for s in ps { if s != "" { push(psegs, s); } }
let xsegs: multi Text = [];
for s in xs { if s != "" { push(xsegs, s); } }
if len(psegs) != len(xsegs) { return false; }
let np = len(psegs);
let wild = false;
if np > 0 {
if starts_with(psegs[np - 1], "*") { wild = true; }
}
if wild == false {
if np != len(xsegs) { return false; }
} else {
if len(xsegs) < np - 1 { return false; }
}
let i = 0;
while i < len(psegs) {
while i < np {
let p = psegs[i];
if wild and i == np - 1 {
let rest = "";
let j = i;
while j < len(xsegs) {
if rest == "" { rest = xsegs[j]; } else { rest = "${rest}/${xsegs[j]}"; }
j = j + 1;
}
params[substr(p, 1, len(p) - 1)] = rest;
return true;
}
if starts_with(p, "*") { return false; }
let x = xsegs[i];
if starts_with(p, ":") {
params[substr(p, 1, len(p) - 1)] = x;
@ -68,3 +106,41 @@ pub fn route_match(pattern: Text, path: Text, mut params: map<Text, Text>) -> Bo
}
return true;
}
-- framework v1 slice 2: a route GROUP — a prefix plus its own routes and
-- its own before-middleware, mounted into an App in one move. The group
-- prefixes patterns at REGISTRATION (the mount is a plain move); its
-- middleware becomes prefix-scoped on the App: it runs only for paths
-- under the prefix, after the global chain, before the route scan.
pub class Group {
prefix: Text
routes: multi Route = []
middleware: multi Mw = []
fn get(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "GET", pattern: "${self.prefix}${pattern}", h: h });
}
fn post(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "POST", pattern: "${self.prefix}${pattern}", h: h });
}
fn put(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "PUT", pattern: "${self.prefix}${pattern}", h: h });
}
fn delete_(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "DELETE", pattern: "${self.prefix}${pattern}", h: h });
}
fn use_mw(take m: Mw) {
push(self.middleware, m);
}
}
-- A prefix-scoped middleware entry on the App (what mounting a group's
-- middleware becomes).
pub class Gmw {
prefix: Text
m: Middleware
}

View file

@ -213,6 +213,89 @@ IFS='|' read -r hs same hb gb <<<"$hd"
&& ok "HEAD answers GET's Content-Length with no body" \
|| bad "HEAD" "status=$hs same-length=$same head-body=$hb get-body=$gb"
# ---- 12b. framework v1 slice 2 ----
# raw client with header control: prints "STATUS|HEADERS|BODY"
# (headers ;-joined, lowercased names)
hraw() { # extra_header_lines(\n-separated) method path
timeout 5 python3 - "$PORT" "$1" "$2" "$3" <<'PYEOF'
import socket, sys
port, extra, method, path = int(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
s = socket.create_connection(("127.0.0.1", port), timeout=5)
h = f"{method} {path} HTTP/1.1\r\n"
for line in extra.split("\n"):
if line: h += line + "\r\n"
h += "content-length: 0\r\nconnection: close\r\n\r\n"
s.sendall(h.encode())
d = b""
try:
while True:
c = s.recv(4000)
if not c: break
d += c
except Exception: pass
s.close()
head, _, body = d.partition(b"\r\n\r\n")
lines = head.decode().splitlines()
status = lines[0].split(" ")[1]
def norm(l):
n, _, v = l.partition(":")
return n.lower() + ":" + v
hdrs = ";".join(norm(l) for l in lines[1:])
print(status + "|" + hdrs + "|" + body.decode(errors="replace"))
PYEOF
}
AUTH="host: a
authorization: Bearer s3cr3t"
r="$(hraw "$AUTH" GET /files/a/b/c)"
[[ "$r" == 200\|*"path=a/b/c"* ]] && ok "wildcard *rest captures the tail" || bad "wildcard" "$r"
r="$(hraw "$AUTH" GET /files)"
[[ "$r" == 200\|*"path="* ]] && ok "wildcard matches the empty rest" || bad "wildcard-empty" "$r"
r="$(hraw "$AUTH" GET /api/ping)"
[[ "$r" == 200\|*"pong via=api-group"* ]] && ok "group route + group middleware + req.ctx" || bad "group" "$r"
r="$(hraw "$AUTH" GET /etag-probe)"
[[ "$r" == 200\|*"etag: \""* ]] && ok "ETag stamped on the response" || bad "etag" "$r"
tag="$(printf '%s' "$r" | tr ';' '\n' | grep -m1 '^etag: ' | cut -d' ' -f2)"
r="$(hraw "$AUTH
if-none-match: $tag" GET /etag-probe)"
[[ "$r" == 304\|* ]] && ok "If-None-Match answers 304" || bad "etag-304" "$r"
r="$(hraw "$AUTH
accept: text/html" GET /nego)"
[[ "$r" == 406\|* ]] && ok "Accept negotiation refuses non-JSON (406)" || bad "nego-406" "$r"
r="$(hraw "$AUTH
accept: application/*" GET /nego)"
[[ "$r" == 200\|*'"ok":true'* ]] && ok "Accept type/* matches" || bad "nego-200" "$r"
r="$(hraw "$AUTH" GET /products)"
[[ "$r" == 200\|*"x-content-type-options: nosniff"*"x-frame-options: DENY"* ]] \
&& ok "security headers on responses" || bad "sec-headers" "$r"
r="$(hraw "host: evil
authorization: Bearer s3cr3t" GET /products)"
[[ "$r" == 421\|* ]] && ok "host validation answers 421" || bad "host-421" "$r"
r="$(hraw "host: a
origin: http://x
access-control-request-method: POST" OPTIONS /products)"
[[ "$r" == 204\|*"access-control-allow-origin: *"*"access-control-allow-methods:"* ]] \
&& ok "CORS preflight answers 204 + allow set" || bad "cors-preflight" "$r"
r="$(hraw "$AUTH
origin: http://x" GET /products)"
[[ "$r" == 200\|*"access-control-allow-origin: *"* ]] \
&& ok "CORS origin stamped on real responses" || bad "cors-after" "$r"
r="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=5)
s.sendall(b"GET /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\ncontent-length: 0\r\ncontent-length: 5\r\nconnection: close\r\n\r\n")
d = b""
try:
while True:
c = s.recv(2000)
if not c: break
d += c
except Exception: pass
print(d.decode(errors="replace").splitlines()[0].split(" ")[1])
PYEOF
)"
[[ "$r" == "400" ]] && ok "duplicate Content-Length rejected (400)" || bad "dup-cl" "got $r"
# ---- 13. pipelined keep-alive: two requests, one connection ----
n="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys