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:
parent
e8563bed16
commit
40127bf53e
10 changed files with 467 additions and 54 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
docs/examples/writeonce-framework/http/nego.wo
Normal file
49
docs/examples/writeonce-framework/http/nego.wo
Normal 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;
|
||||
}
|
||||
76
docs/examples/writeonce-framework/http/secure.wo
Normal file
76
docs/examples/writeonce-framework/http/secure.wo
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue