docs(porch): give the framework its own story track, iterations 1-8
- docs/stories/porch/ — a TRACK folder, not a status folder: status still
lives only in frontmatter. Adds `track: porch` so a query over
docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
what the track explicitly does NOT own (binding -> 29, cache -> 18,
proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
criteria, out-of-scope and the forks a spec must settle:
1 store-backed middleware (limiter + idempotency — needs nothing new,
first on purpose so the store pattern is proven cheaply)
2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
`Resp.headers` being a map cannot emit two Set-Cookie lines)
3 sessions 4 CSRF 5 routing/response ergonomics (independent)
6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
must survive) 7 SSE + compression 8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
mapping each of its goals to the porch iteration that took it. Kept, not
deleted: the Fiber study cites it and its randomness argument is what
this track is built on
- board gains a porch section; board-views gains porch and both-track
Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
linkcheck 0 broken / 0 anchors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ffd791d05b
commit
01df75245f
14 changed files with 1347 additions and 10 deletions
|
|
@ -12,7 +12,9 @@
|
|||
> handler body mentions it.
|
||||
|
||||
A web framework **written in writeonce**, consumed as a `[deps]` dependency
|
||||
(iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
|
||||
(iteration 15). Roadmap: [`docs/stories/porch/`](../../stories/porch/00-story.md)
|
||||
— its own track, eight iterations, numbered from 1, derived from
|
||||
[the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
|
||||
|
||||
```toml
|
||||
[deps]
|
||||
|
|
|
|||
|
|
@ -190,13 +190,16 @@ Not gaps. Each was decided and the reasoning is on file.
|
|||
|
||||
## 7. What this study feeds
|
||||
|
||||
[**Iteration 39 — web framework parity**](../../../stories/language-runtime-database/39-web-framework-parity.md)
|
||||
takes §0–§2 and the cheap half of §5, in that order, because §0 gates §2 and §1
|
||||
gates most of it.
|
||||
The **[`porch` track](../../../stories/porch/00-story.md)** — eight iterations
|
||||
numbered from 1, which superseded language iteration 39 on the day this study
|
||||
was written. §0–§2 and the cheap half of §5 became porch 1–5, in dependency
|
||||
order because §0 gates §2 and §1 gates most of it; §3's streaming seam and what
|
||||
falls out of it became porch 6–8, so nothing in this study is now unscheduled
|
||||
except what the table below hands to someone else.
|
||||
|
||||
Explicitly *not* iteration 39's, with owners:
|
||||
|
||||
- streaming, SSE, compression, byte ranges (§3) — the parked streaming slice
|
||||
- streaming, SSE, compression, byte ranges (§3) — [porch 6](../../../stories/porch/06-streaming-core.md)–[8](../../../stories/porch/08-static-and-lifecycle.md), which unparked them
|
||||
- typed binding (§4) — [iteration 29](../../../stories/language-runtime-database/29-compile-time-metaprogramming.md)
|
||||
- TTL cache middleware — [iteration 18](../../../stories/language-runtime-database/18-memory-db-features.md)
|
||||
- `proxy` — [iteration 38](../../../stories/language-runtime-database/38-content-platform-capabilities.md)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,14 @@ The single place to learn where this project stands. Organised in six buckets:
|
|||
folders** — a doc stays where it was authored, and only its frontmatter, its
|
||||
banner and this board change.
|
||||
|
||||
**Two tracks** (2026-08-26): [`language-runtime-database/`](language-runtime-database/00-story.md)
|
||||
— the language, runtime and database — and [`porch/`](porch/00-story.md), the web
|
||||
framework written in it. Each numbers its iterations from 1, so a porch 3 is not
|
||||
a language 3; porch stories carry `track: porch` in frontmatter to keep queries
|
||||
honest. Track folders are fine; **status** folders are not.
|
||||
|
||||
**Status lives in frontmatter, nowhere else** (directive 2026-08-26). Every
|
||||
story iteration file sits flat in
|
||||
[`language-runtime-database/`](language-runtime-database/00-story.md) and
|
||||
story iteration file sits flat in its track folder and
|
||||
carries `status:` in its YAML header; the active slice's marker doc sits flat
|
||||
in `docs/`. **No directory anywhere encodes state.** This replaces the
|
||||
2026-08-20/21 convention under which files moved between `done/`, `refine/`,
|
||||
|
|
@ -594,6 +599,27 @@ precedence notes for resumption.
|
|||
**30** — observability, CI, fuzz: named 2026-08-20, still row-only (no
|
||||
story file); slots in when scheduled — nothing in the chain depends on it.
|
||||
|
||||
### ▸ porch — the web framework track
|
||||
|
||||
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md).
|
||||
Supersedes language iteration 39, now a pointer. All eight are ⬜ `refine` —
|
||||
none has an approved spec yet. Ordered by dependency; the first slice is
|
||||
deliberately the cheapest so the store pattern and gate shape are proven before
|
||||
the runtime and `Resp` are touched.
|
||||
|
||||
| # | Iteration | State |
|
||||
| --- | --- | --- |
|
||||
| 1 | [Store-backed middleware](porch/01-store-backed-middleware.md) | ⬜ **startable today** — rate limiter + idempotency over a `@table`; needs no new primitive, only `time.ticks`. Durable counters are the differentiator over Fiber's in-memory default, so the gate includes a restart |
|
||||
| 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ⬜ the foundation. Phase A is **language-track work**: a CSPRNG builtin (id 96+; 89/90 are iteration 31's reserved holes). Then repeated response headers — `Resp.headers` is a `map<Text,Text>` and structurally cannot emit two `Set-Cookie` lines — then `Cookie:` parsing and signed cookies |
|
||||
| 3 | [Sessions](porch/03-sessions.md) | ⬜ after 2. Server-side rows keyed by a random id, idle **and** absolute timeout, id rotation on login, revoke-all-for-principal, durable across restart |
|
||||
| 4 | [CSRF](porch/04-csrf.md) | ⬜ after 2 + 3. Session-bound tokens, trusted origins as the second layer, opt-in single use, and refusal classes that are distinguishable in logs |
|
||||
| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) |
|
||||
| 6 | [Streaming core](porch/06-streaming-core.md) | ⬜ the riskiest and highest-leverage slice: incremental writes + chunked framing + an explicit commit point. `serialize()` always emits `Content-Length` today. Chunked REQUEST bodies are deliberately refused (request smuggling) and that refusal must survive |
|
||||
| 7 | [SSE + compression](porch/07-sse-and-compression.md) | ⬜ after 6. SSE fits the actor/fiber model unusually well; compression carries a real fork — pure-`.wo` DEFLATE (now expressible after iteration 36's bit operators) vs a C builtin. CRC32 finally gets its consumer |
|
||||
| 8 | [Static files + lifecycle](porch/08-static-and-lifecycle.md) | ⬜ static half after 6. Byte ranges, `Last-Modified`/`Cache-Control`, index resolution, listing off-by-default, shutdown hooks (the ledger's "no user teardown hooks yet"), plus healthcheck/favicon/redirect/rewrite/skip |
|
||||
|
||||
---
|
||||
|
||||
⏸ **Held** (2026-08-21, developer decision): 18, 20, 21, 25, 26, 27, 28,
|
||||
29 — every story carrying `status: hold` in its frontmatter (25's story
|
||||
file removed; its
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ is the source of truth**:
|
|||
|
||||
```yaml
|
||||
---
|
||||
iteration: "8" # immutable id (string: "7b", "9b" exist)
|
||||
track: porch # OMITTED on language-runtime-database stories
|
||||
iteration: "8" # immutable id, LOCAL TO ITS TRACK (string: "7b", "9b" exist)
|
||||
status: in-progress # done | in-progress | refine | hold — the ONLY place status lives
|
||||
chain: 1 # concurrency-chain position, chain stories only (1–6)
|
||||
---
|
||||
|
|
@ -27,6 +28,13 @@ standup narrative; these queries are the live views over the same facts.
|
|||
Adjust the `FROM` path to your vault root (queries below assume the
|
||||
vault opens at the repo root).
|
||||
|
||||
Two tracks now carry iterations, each numbered from 1:
|
||||
`language-runtime-database/` (the language, runtime and database) and `porch/`
|
||||
(the web framework, added 2026-08-26). Iteration ids therefore repeat across
|
||||
tracks — a porch 3 is not a language 3 — so every query below is scoped by
|
||||
`FROM` path, and porch stories carry `track: porch` so a combined query can
|
||||
still tell them apart.
|
||||
|
||||
## Everything not done, chain order first
|
||||
|
||||
```dataview
|
||||
|
|
@ -70,3 +78,24 @@ second copy of status. To keep frontmatter the single source of truth:
|
|||
the place status is edited.** A status change is one edit to one
|
||||
`status:` key; a Kanban card drag that only rewrites the Kanban file is a
|
||||
lie the next query won't see.
|
||||
|
||||
## The porch track
|
||||
|
||||
```dataview
|
||||
TABLE iteration, status
|
||||
FROM "docs/stories/porch"
|
||||
WHERE status != "done"
|
||||
SORT iteration ASC
|
||||
```
|
||||
|
||||
## Both tracks at once, grouped
|
||||
|
||||
Relies on `track:` being present on porch stories and absent on language ones,
|
||||
so the language track shows up under an empty group.
|
||||
|
||||
```dataview
|
||||
TABLE rows.file.link AS story, rows.iteration AS iteration, rows.status AS status
|
||||
FROM "docs/stories"
|
||||
WHERE iteration AND status != "done"
|
||||
GROUP BY track
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,9 +1,31 @@
|
|||
---
|
||||
iteration: "39"
|
||||
status: refine
|
||||
status: hold
|
||||
---
|
||||
|
||||
# Iteration 39 — web framework parity: randomness, cookies, and the store-backed middleware chain
|
||||
# Iteration 39 — web framework parity *(superseded by the porch track)*
|
||||
|
||||
> **⏸ SUPERSEDED 2026-08-26, the same day it was written.** Framework work now
|
||||
> lives in its own track: [`docs/stories/porch/`](../porch/00-story.md), numbered
|
||||
> from 1. This iteration's content was split across **porch 1–5** and is not
|
||||
> planned from here — the sequencing below survives, but as that track's
|
||||
> dependency order.
|
||||
>
|
||||
> | This iteration's goal | Now |
|
||||
> | --- | --- |
|
||||
> | limiter + idempotency (the cheap first slice) | [porch 1](../porch/01-store-backed-middleware.md) |
|
||||
> | random-bytes builtin, cookies, `Resp` repeated headers | [porch 2](../porch/02-randomness-and-cookies.md) |
|
||||
> | sessions | [porch 3](../porch/03-sessions.md) |
|
||||
> | CSRF | [porch 4](../porch/04-csrf.md) |
|
||||
> | method helpers, named routes, body limit, request id, response helpers | [porch 5](../porch/05-routing-response-ergonomics.md) |
|
||||
> | *(deferred here, now scheduled)* streaming, SSE, compression, byte ranges | [porch 6](../porch/06-streaming-core.md)–[8](../porch/08-static-and-lifecycle.md) |
|
||||
>
|
||||
> Kept rather than deleted because the [Fiber study](../../plan/exploration/fiber/00-fiber-parity.md)
|
||||
> cites it and because the reasoning below — especially why the randomness
|
||||
> blocker comes first — is what the porch track is built on. Original text
|
||||
> follows.
|
||||
|
||||
## Original scope
|
||||
|
||||
> Format: `product/story-iteration-template`. Part of
|
||||
> [Story — one language, one runtime, one database, one binary](00-story.md).
|
||||
|
|
|
|||
88
docs/stories/porch/00-story.md
Normal file
88
docs/stories/porch/00-story.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Story — `porch`, the writeonce web framework
|
||||
|
||||
The second track. Where
|
||||
[`language-runtime-database/`](../language-runtime-database/00-story.md) grows
|
||||
the *language*, this track grows the one library written **in** it:
|
||||
[`porch`](../../examples/porch/README.md), consumed by every serving sample
|
||||
through `wo.toml [deps]`.
|
||||
|
||||
Numbering restarts at 1 and is local to this track. Frontmatter carries
|
||||
`track: porch` so a query over `docs/stories/` can tell a porch iteration 3 from
|
||||
a language iteration 3. Status rules are the repo's, unchanged: `status:` in
|
||||
frontmatter is the only place state lives, no directory encodes it.
|
||||
|
||||
## Why a separate track
|
||||
|
||||
Three reasons, all practical:
|
||||
|
||||
1. **Different substrate, different gates.** porch is `.wo` source. Its
|
||||
iterations are proven by `just web-app` and `just site`, never by the
|
||||
conformance corpus or `oop-accept`. Mixing them into the language track's
|
||||
sequence made both harder to read.
|
||||
2. **Different cadence.** A porch slice is days; a language slice that touches
|
||||
`wob.h` and the VM is longer and riskier. Interleaving them in one numbering
|
||||
forced false ordering decisions.
|
||||
3. **The framework is now the product surface.** `writeonce.de` is served by
|
||||
porch. Its gaps are what a visitor hits first, so they deserve a roadmap that
|
||||
is not buried behind runtime work.
|
||||
|
||||
The language track stays upstream: when a porch iteration needs a new builtin,
|
||||
that half is called out explicitly and the language track owns it.
|
||||
|
||||
## Where the sequence came from
|
||||
|
||||
The [Fiber v3.5.0 parity study](../../plan/exploration/fiber/00-fiber-parity.md)
|
||||
— gofiber/fiber read end to end against porch's actual `.wo` source: its routing
|
||||
surface, `Req`/`Res` API, binder, lifecycle hooks and the `Config` of all 32 of
|
||||
its `middleware/` packages. Nine of those 32 already have a working porch
|
||||
counterpart, so this is a breadth roadmap, not a rescue.
|
||||
|
||||
That study replaced language-track
|
||||
[iteration 39](../language-runtime-database/39-web-framework-parity.md), which is
|
||||
now a pointer here.
|
||||
|
||||
## The sequence
|
||||
|
||||
Ordered by dependency, not by importance — and the first slice is deliberately
|
||||
the *cheapest*, so the store pattern and the gate shape are proven before the
|
||||
risky work starts.
|
||||
|
||||
| # | Iteration | Delivers | Needs |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Store-backed middleware](01-store-backed-middleware.md) | rate limiting + idempotency over a `@table` store | nothing new — starts today |
|
||||
| 2 | [Randomness and cookies](02-randomness-and-cookies.md) | a `random_bytes` runtime builtin, repeated response headers, `Cookie:` parsing, signed cookies | a language-track builtin (phase A) |
|
||||
| 3 | [Sessions](03-sessions.md) | server-side sessions, idle + absolute timeout, revocation | 2 |
|
||||
| 4 | [CSRF](04-csrf.md) | token mint/verify, trusted origins, single-use tokens | 2, 3 |
|
||||
| 5 | [Routing and response ergonomics](05-routing-response-ergonomics.md) | the remaining method helpers, named routes, per-route body limit, request ids, the missing response helpers | nothing — parallel to 2–4 |
|
||||
| 6 | [Streaming core](06-streaming-core.md) | incremental response writes and chunked framing — the seam three iterations wait on | nothing new, but it changes `Resp` |
|
||||
| 7 | [SSE and compression](07-sse-and-compression.md) | server-sent events, gzip/deflate | 6 |
|
||||
| 8 | [Static files and lifecycle](08-static-and-lifecycle.md) | byte ranges, cache headers, directory listing, lifecycle hooks, the small middleware everyone ships | 6 |
|
||||
|
||||
```
|
||||
1 ─ independent, start here
|
||||
5 ─ independent, any time
|
||||
2 ──▶ 3 ──▶ 4
|
||||
6 ──▶ 7
|
||||
└──▶ 8
|
||||
```
|
||||
|
||||
## What this track does NOT own
|
||||
|
||||
| Not porch's | Owner |
|
||||
| --- | --- |
|
||||
| typed binding of query/params/form into a class | language: [`@derive`](../language-runtime-database/29-compile-time-metaprogramming.md) — reflection is forbidden by principle 13 |
|
||||
| TTL cache, `transaction { }`, durable job queue | language: [iteration 18](../language-runtime-database/18-memory-db-features.md) |
|
||||
| a `proxy` middleware | language: [iteration 38](../language-runtime-database/38-content-platform-capabilities.md) — needs `net.connect`, which does not exist |
|
||||
| metrics, profiling, per-change CI, fuzzing | language iteration 30 (no story file yet) |
|
||||
| TLS, HTTP/2 | nobody — proxy-terminated by doctrine |
|
||||
| a runtime template engine | nobody — rejected; markup is a compile-time literal (`writeonce-view`) |
|
||||
| a radix-tree router | nobody yet — waiting on a *measurement*, not a decision |
|
||||
|
||||
## Review protocol
|
||||
|
||||
Same as the language track: the developer reads one iteration, approves or
|
||||
amends; the next starts only after approval. Each iteration is an unsplittable
|
||||
value slice with phases, per-phase tasks, Given/When/Then acceptance criteria,
|
||||
and an out-of-scope list. Every phase ends with both serving gates green —
|
||||
`just web-app` and `just site` — because porch has two consumers and a change
|
||||
that only satisfies one is not done.
|
||||
144
docs/stories/porch/01-store-backed-middleware.md
Normal file
144
docs/stories/porch/01-store-backed-middleware.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "1"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 1 — store-backed middleware: rate limiting and idempotency
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2.
|
||||
>
|
||||
> **First deliberately because it is the cheapest.** Both features need only a
|
||||
> `@table` and `time.ticks`, both of which already exist — no new builtin, no
|
||||
> cookie, no change to `Resp`. It exists to prove the store pattern and the gate
|
||||
> shape on low-risk work before iterations 2–4 touch the runtime and the public
|
||||
> response type.
|
||||
|
||||
## Goals
|
||||
|
||||
- **A rate limiter that survives a restart.** Fixed-window counting keyed by
|
||||
client, answering 429 with the conventional headers when the window is spent.
|
||||
Fiber's `limiter` keeps counters in memory by default and expects Redis for
|
||||
anything real; porch's live in a `@table`, so they are WAL-durable and
|
||||
crash-recoverable for free. That is the difference worth demonstrating, and it
|
||||
is why the acceptance criteria include a restart.
|
||||
- **Idempotent replay of unsafe requests.** A client resending a POST with the
|
||||
same idempotency key gets the stored response and the handler does not run
|
||||
twice. This is the correctness feature the storefront sample has silently
|
||||
needed since it grew a checkout.
|
||||
- **Establish the store convention for iterations 2–4.** Sessions and CSRF will
|
||||
want the same shape. Decide it once, here, on the cheap slice.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — the store convention
|
||||
|
||||
- Decide the store shape (see Info fork 1) and write it down before any
|
||||
middleware exists, because three later iterations inherit it.
|
||||
- Add the `@table` classes for a counter row and a stored-response row, with
|
||||
the secondary indexes their lookups need — the read path is an equality
|
||||
probe, which is the only index shape the engine has.
|
||||
- Decide and document the expiry discipline: rows are pruned lazily on access,
|
||||
not by a background sweeper, because porch has no timer and iteration 30 owns
|
||||
scheduled work.
|
||||
- Verify: `woc docs/examples/porch/` typechecks entry-less as a library; the
|
||||
new tables appear in the WAL and replay across a restart.
|
||||
|
||||
### Phase B — the rate limiter
|
||||
|
||||
- A `Limiter` middleware class with a `before` that counts and either passes or
|
||||
short-circuits with 429 — the `?Resp` short-circuit the chain already has.
|
||||
- Key selection: reuse `client_ip(req)` and the existing trusted-proxy
|
||||
judgement rather than inventing a second one. A keyed-by-principal variant
|
||||
falls out for free once `req.principal` is set by an auth middleware.
|
||||
- The response headers on both paths, and a `Retry-After` on the refusal.
|
||||
- Window arithmetic on `time.ticks` (µs monotonic), not `time.now` — a
|
||||
wall-clock jump must not hand out a free window.
|
||||
- Verify: a burst crosses the threshold at exactly N, the window rolls, the
|
||||
counters survive `SIGTERM` + restart.
|
||||
|
||||
### Phase C — idempotency
|
||||
|
||||
- An `Idempotent` middleware pair: `before` looks the key up and replays a hit;
|
||||
`after` stores the response for a miss. This is the first real user of the
|
||||
`after` chain for something other than headers, which is worth noting.
|
||||
- Decide what is part of the identity: the key header alone, or key plus a
|
||||
digest of method+path+body (`sha256` exists). Replaying a stored response for
|
||||
a *different* body under a reused key is the failure mode that matters.
|
||||
- In-flight collision handling: a second request arriving while the first is
|
||||
still running. Fiber takes a lock; porch's shard model means the honest
|
||||
answer is probably to refuse with 409 rather than to block.
|
||||
- Verify: replay returns the stored response, the handler's side effect happens
|
||||
exactly once, a reused key with a different body is refused, concurrent
|
||||
duplicates do not both execute.
|
||||
|
||||
### Phase D — the gate and the ledger
|
||||
|
||||
- Extend `scripts/web-app-accept.sh` with the checks above, including the
|
||||
restart leg — a durability claim that no gate exercises is not a claim.
|
||||
- Update porch's README ledger rows for both features, and record in the
|
||||
status board what landed versus what was planned.
|
||||
- Verify: `just web-app` and `just site` both green; `just linkcheck` clean.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** a limiter of N requests per window, **when** a client sends N+1,
|
||||
**then** the first N succeed and the last is 429 with `Retry-After` set.
|
||||
- **Given** counters at their limit, **when** the process is SIGTERMed and
|
||||
restarted, **then** the client is still limited — the counters replayed from
|
||||
the WAL rather than resetting to zero.
|
||||
- **Given** a window that has fully elapsed, **when** the same client returns,
|
||||
**then** it is served, and the expired row is pruned on that access.
|
||||
- **Given** the system clock jumping backwards, **when** the window is
|
||||
evaluated, **then** no extra allowance is granted (`time.ticks` is monotonic).
|
||||
- **Given** a POST with an idempotency key that has been seen, **when** it is
|
||||
replayed, **then** the stored response is returned byte-identically and the
|
||||
handler's side effect count is unchanged — proven by a row count, not by a
|
||||
log line.
|
||||
- **Given** a reused idempotency key with a different request body, **when** it
|
||||
arrives, **then** it is refused rather than answered with the other request's
|
||||
response.
|
||||
- **Given** two identical keyed requests in flight at once, **when** both are
|
||||
dispatched, **then** exactly one executes and the other gets the decided
|
||||
answer (replay or 409), never a partial write.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **A pluggable `Storage` interface.** Fiber abstracts it so one middleware runs
|
||||
on memory or Redis. porch has one store, and interfaces here are structural —
|
||||
an abstraction with exactly one implementor is decoration, which iteration 37
|
||||
learned the hard way about `Component`. Concrete `@table` until a second
|
||||
backend actually exists.
|
||||
- **Sliding-window or token-bucket algorithms.** Fixed window is what the
|
||||
sample needs; a better algorithm is a later slice with a measurement behind
|
||||
it.
|
||||
- **Distributed limiting across processes.** One program owns its database;
|
||||
cross-program state is language
|
||||
[iteration 20](../language-runtime-database/20-cross-program-tables.md).
|
||||
- **A background expiry sweeper.** No timer exists (`time.after` is still a
|
||||
reserved builtin id in `wob.h`). Lazy pruning on access, deliberately.
|
||||
- **The TTL cache middleware** — language
|
||||
[iteration 18](../language-runtime-database/18-memory-db-features.md) owns it,
|
||||
spec already approved. Do not build a second cache here.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **One store or two?** A single generic key/value/expiry table serving both
|
||||
features, or a purpose-shaped table each. Leaning two: the columns genuinely
|
||||
differ (a counter is an Int, a stored response is status + headers + body),
|
||||
and a generic table would force everything through `Text`, which is how the
|
||||
framework's cache ended up storing JSON strings.
|
||||
2. **What is the limiter's key when there is no auth?** `client_ip(req)` reads
|
||||
`X-Forwarded-For`, which a direct client can forge. Behind the mandated TLS
|
||||
proxy that is fine; on an open port it is not. `net.peer(fd)` gives the real
|
||||
peer — decide which is authoritative and reuse whatever the trusted-proxy
|
||||
slice concludes rather than deciding twice.
|
||||
3. **Does idempotency store headers?** Fiber has `KeepResponseHeaders` because
|
||||
replaying `Set-Cookie` or a fresh `Date` is usually wrong. porch has no
|
||||
cookies yet (iteration 2), so this is cheap to decide now and expensive to
|
||||
retrofit later.
|
||||
|
||||
Nothing here needs a new runtime primitive, which is the point of going first.
|
||||
170
docs/stories/porch/02-randomness-and-cookies.md
Normal file
170
docs/stories/porch/02-randomness-and-cookies.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "2"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 2 — randomness and cookies: the foundation three iterations stand on
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §0–§1.
|
||||
>
|
||||
> **The study's sharpest finding lives here.** porch's own README claimed signed
|
||||
> cookies, CSRF and session integrity were "UNBLOCKED — the primitives exist
|
||||
> since iteration 34". For CSRF and sessions that is **wrong**: SHA-256 and HMAC
|
||||
> let a program *authenticate* a token, they cannot *mint* one, and writeonce has
|
||||
> no source of randomness anywhere. An HMAC over a guessable session id is a
|
||||
> signed guess. Phase A closes that before anything depends on it.
|
||||
|
||||
## Goals
|
||||
|
||||
- **A CSPRNG builtin in the runtime.** No `getrandom`, no `/dev/urandom` read,
|
||||
no CSPRNG builtin exists today — grep the runtime and nothing comes back. This
|
||||
is the one phase of this track that is **language-track work** (C, a new
|
||||
builtin id, a corpus fixture); it is here because porch is what needs it and
|
||||
splitting it across two tracks would hide the dependency.
|
||||
- **Repeated response headers, which `Resp` structurally cannot express.**
|
||||
`Resp.headers` is a `map<Text, Text>`. A login response setting a session
|
||||
cookie *and* a flash cookie needs two `Set-Cookie` lines and the map can hold
|
||||
one. This is a change to porch's most public type, and deciding its shape is
|
||||
the real work of this iteration — the cookie formatting is the easy half.
|
||||
- **Cookies in both directions.** Parse a `Cookie:` request header into
|
||||
something typed; build a `Set-Cookie` with the attributes that matter for
|
||||
security — `HttpOnly`, `Secure`, `SameSite`, `Max-Age`, `Path`, `Domain`.
|
||||
- **Signed cookies.** HMAC-SHA256 over the value with constant-time comparison
|
||||
on the way back (`hmac_sha256` and `ct_eq` both already exist). This is the
|
||||
half that genuinely *was* unblocked by iteration 34, and it is what makes a
|
||||
cookie tamper-evident without a server-side lookup.
|
||||
- **Correct the ledger row that started this.** The README's crypto line, and
|
||||
anything else claiming the sessions/CSRF path was already open.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — the runtime primitive (language-track work)
|
||||
|
||||
- Add a random-bytes builtin at the next free id (89 and 90 are reserved holes
|
||||
for language iteration 31's `monitor` and `time.after`, so this starts at 96).
|
||||
- Source it from the kernel. Decide the failure mode explicitly: if the source
|
||||
is unavailable the builtin **refuses**, loudly. A CSPRNG that quietly degrades
|
||||
to something weaker is worse than none, because every layer above it will
|
||||
assume it worked.
|
||||
- Corpus fixtures: the builtin's arity and type contract, and the refusal path.
|
||||
Statistical quality is not a corpus concern — the kernel's guarantee is the
|
||||
guarantee.
|
||||
- Document it in the builtin-surface contract and the error catalog if it adds a
|
||||
diagnostic, in the same change. Both of those went stale once before by not
|
||||
doing this.
|
||||
- Verify: `oop-e2e` green; the value differs across separate processes and is
|
||||
not derivable from the clock.
|
||||
|
||||
### Phase B — repeated response headers
|
||||
|
||||
- Settle fork 1 and change `Resp`. Every response builder
|
||||
(`ok_text`/`ok_json`/`ok_html`/`created_json`/`not_found`/…) and
|
||||
`serialize()` in `internal/serve.wo` move with it.
|
||||
- Keep the single-value path ergonomic: the overwhelmingly common case is one
|
||||
value per header, and it must not get worse to write.
|
||||
- Verify: `serialize()` emits two distinct `Set-Cookie` lines for one response;
|
||||
every existing header behaviour is byte-identical, proven by both serving
|
||||
gates passing unchanged.
|
||||
|
||||
### Phase C — reading request cookies
|
||||
|
||||
- Parse the `Cookie:` header: multiple pairs, quoted values, stray whitespace,
|
||||
and duplicate names.
|
||||
- Decide where parsed cookies live — a lazily-parsed field on `Req`, or a
|
||||
helper the handler calls. `Req` already carries a `ctx` bag and a `params`
|
||||
map, so the precedent exists either way.
|
||||
- A malformed header is a 400, not a silent partial parse. The parser's
|
||||
existing discipline around duplicate `Content-Length` is the model.
|
||||
- Verify: values recovered exactly across the awkward cases above; malformed
|
||||
input refused.
|
||||
|
||||
### Phase D — writing and signing cookies
|
||||
|
||||
- A `Set-Cookie` builder covering the attribute set, with `HttpOnly` and
|
||||
`SameSite` defaulted to the safe choice rather than the permissive one — a
|
||||
cookie API whose defaults are insecure is a footgun that ships.
|
||||
- Sign with `hmac_sha256`, verify with `ct_eq`. Decide the encoding (`base64` is
|
||||
already available) and the payload framing so a value containing the delimiter
|
||||
cannot forge a signature.
|
||||
- Clear-cookie support, which is its own case: the attributes must match or the
|
||||
browser keeps the old one.
|
||||
- Verify: a one-bit change to value or signature is rejected; a valid cookie
|
||||
round-trips; a cleared cookie is actually gone.
|
||||
|
||||
### Phase E — prove it and correct the record
|
||||
|
||||
- A login/logout flow in a sample exercising two cookies on one response, using
|
||||
the signed-cookie path only — no sessions yet, that is iteration 3.
|
||||
- Correct porch's README crypto row and re-point the ledger rows this iteration
|
||||
touched.
|
||||
- Verify: `just web-app`, `just site`, `oop-accept`, `just linkcheck` all green.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** the random builtin, **when** many values are drawn across separate
|
||||
processes, **then** none repeats and none is derivable from the clock; **and**
|
||||
when the kernel source is unavailable, the builtin refuses loudly rather than
|
||||
returning weak bytes.
|
||||
- **Given** a handler setting a session cookie and a flash cookie on one
|
||||
response, **when** it is serialized, **then** **two** distinct `Set-Cookie`
|
||||
headers reach the wire. This is the criterion today's `map<Text, Text>`
|
||||
provably cannot satisfy.
|
||||
- **Given** every pre-existing response shape, **when** both serving gates run,
|
||||
**then** output is byte-identical to before phase B — the `Resp` change is
|
||||
additive or it is wrong.
|
||||
- **Given** a `Cookie:` header with several pairs, quoted values and stray
|
||||
whitespace, **when** it is parsed, **then** each value is recovered exactly;
|
||||
**and** a malformed header yields 400, never a partial parse.
|
||||
- **Given** a signed cookie altered by one bit in either the value or the
|
||||
signature, **when** it is verified, **then** it is rejected in constant time.
|
||||
- **Given** a signed value that itself contains the payload delimiter, **when**
|
||||
it round-trips, **then** it cannot be re-framed to forge a valid signature.
|
||||
- **Given** cookie defaults, **when** a cookie is created without explicit
|
||||
attributes, **then** `HttpOnly` is on and `SameSite` is not `None`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **Encrypted cookies.** Fiber's `encryptcookie` needs a symmetric cipher, and
|
||||
the runtime has digests only. Signed-and-readable is honest and sufficient for
|
||||
a session id; encrypting a payload is a separate ask with a separate primitive
|
||||
behind it.
|
||||
- **Server-side session state** — iteration [3](03-sessions.md). This iteration
|
||||
stops at a signed cookie carrying a value the app chose.
|
||||
- **CSRF** — iteration [4](04-csrf.md), which needs both this and 3.
|
||||
- **JWT.** Verification is already possible with `hmac_sha256`; issuing needs
|
||||
phase A. Either way it is a library slice with a hard stop at HS256 — no
|
||||
RS256, no JOSE — and not this iteration.
|
||||
- **Cookie-based *cache* keys.** Fiber's cache middleware has `KeyCookies`; the
|
||||
cache belongs to language
|
||||
[iteration 18](../language-runtime-database/18-memory-db-features.md).
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle, in order of how much they move:
|
||||
|
||||
1. **What replaces `Resp.headers: map<Text, Text>`?** Three candidate shapes: a
|
||||
`multi Text` of raw extra header lines beside the existing map; a dedicated
|
||||
typed `cookies` field on `Resp` that `serialize()` renders; or a general
|
||||
repeated-header list replacing the map. The first is the smallest change, the
|
||||
second the most typed, the third the most honest about HTTP — and the third
|
||||
touches every builder and both consumers. This fork decides the size of the
|
||||
whole iteration, so settle it first.
|
||||
2. **What shape is the builtin?** A bytes-returning primitive composes with
|
||||
everything iterations 19 and 34 added (`Bytes`, `base64_encode`,
|
||||
`hmac_sha256`), which argues for exactly one function and no convenience
|
||||
wrappers. Decide whether a hex/id helper rides along or whether
|
||||
`base64_encode` is enough.
|
||||
3. **Where do parsed cookies live?** A field on `Req` parsed eagerly costs every
|
||||
request that has no cookies; a helper parsed on demand costs nothing but is
|
||||
easy to call twice. `Req` is a `typedef` record, so adding a field is cheap —
|
||||
the cost is the eager parse, not the shape.
|
||||
4. **Signed-cookie framing.** Value-then-signature with a delimiter is the
|
||||
obvious encoding and the obvious place to get it wrong. Decide the framing so
|
||||
that a value containing the delimiter cannot shift the boundary.
|
||||
|
||||
Phase A is the only part of this track that is language-track work. It is
|
||||
sequenced here rather than filed as a language iteration because nothing else
|
||||
wants it yet, and a primitive with no consumer is how the `Component` interface
|
||||
became decoration.
|
||||
129
docs/stories/porch/03-sessions.md
Normal file
129
docs/stories/porch/03-sessions.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "3"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 3 — sessions: server-side state, revocable, durable
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2.
|
||||
> Needs [iteration 2](02-randomness-and-cookies.md) (a random session id and a
|
||||
> signed cookie to carry it) and inherits the store convention from
|
||||
> [iteration 1](01-store-backed-middleware.md).
|
||||
|
||||
## Goals
|
||||
|
||||
- **A session is a `@table` row keyed by a random id; the cookie carries only
|
||||
the id.** The alternative — a signed cookie carrying the whole payload — needs
|
||||
no store but cannot be revoked, and revocation is not optional for a real
|
||||
login. This is the fork iteration 1's store convention exists to answer.
|
||||
- **Both timeouts, because they answer different questions.** Idle timeout
|
||||
bounds "how long since you did anything"; absolute timeout bounds "how long
|
||||
since you authenticated". Fiber ships both (`IdleTimeout`, `AbsoluteTimeout`)
|
||||
and a session with only the first never expires for an active attacker.
|
||||
- **Durability is the differentiator.** Fiber's default store is in-memory: a
|
||||
restart logs everyone out. porch's sessions ride the WAL, so they survive —
|
||||
and the gate proves it, because an unexercised durability claim is not a
|
||||
claim.
|
||||
- **Revocation that works.** Logout, and "log out everywhere for this
|
||||
principal" — the second is what a password change needs, and it is nearly free
|
||||
once sessions are rows with an owner column.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — the session table and its lifecycle
|
||||
|
||||
- The `@table` class: random id, principal, created-at, last-seen, and whatever
|
||||
payload shape fork 1 settles. Secondary index on principal, because
|
||||
"revoke all for this user" is an equality probe and that is the only index
|
||||
shape the engine has.
|
||||
- Create, touch, expire and delete, with expiry evaluated on access (the lazy
|
||||
discipline iteration 1 established — there is still no timer).
|
||||
- Verify: rows replay across a restart; an expired row is pruned when touched.
|
||||
|
||||
### Phase B — the middleware
|
||||
|
||||
- A `Session` middleware whose `before` reads the signed cookie, loads the row,
|
||||
checks both timeouts, and attaches identity. Decide whether it sets
|
||||
`req.principal` or writes into `req.ctx` — `principal` is the field the
|
||||
framework already means for identity, so it should be that unless the session
|
||||
carries more than identity.
|
||||
- Rotate the id on privilege change (login especially). Session fixation is the
|
||||
attack that a login which reuses the pre-login id walks straight into.
|
||||
- A missing, expired, unknown or tampered cookie all end at the same place —
|
||||
unauthenticated — but for distinguishable reasons, because debugging a login
|
||||
loop without that distinction is miserable.
|
||||
- Verify: each of those four cases behaves; the id changes across login.
|
||||
|
||||
### Phase C — login, logout, revoke-all
|
||||
|
||||
- The three flows end to end in a sample, using porch's existing auth pieces
|
||||
(`bearer_token`, `basic_credentials`, `ct_eq`) for the credential check —
|
||||
sessions are about *keeping* identity, not establishing it.
|
||||
- Logout deletes the row and clears the cookie with matching attributes.
|
||||
- Revoke-all deletes by principal and is proven with two concurrent sessions.
|
||||
- Verify: after logout the old cookie is inert even though it is still
|
||||
well-signed — the point of server-side state.
|
||||
|
||||
### Phase D — the gate and the ledger
|
||||
|
||||
- Extend `scripts/site-accept.sh` rather than only the storefront: the site has
|
||||
an admin route that currently checks a bearer token per request, which is
|
||||
exactly the thing sessions replace.
|
||||
- Ledger rows, status board entry, and the standup questions answered.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** a valid session cookie, **when** a request arrives, **then**
|
||||
identity is attached and the row's last-seen advances.
|
||||
- **Given** a session whose signature is valid but whose row was deleted,
|
||||
**when** it is presented, **then** the request is unauthenticated — a
|
||||
well-formed cookie is not authority.
|
||||
- **Given** an idle timeout of T, **when** a session is unused for longer than
|
||||
T, **then** it is rejected and its row pruned on that access.
|
||||
- **Given** an absolute timeout of A, **when** a session is *continuously
|
||||
active* past A, **then** it is still rejected — the criterion an idle-only
|
||||
implementation fails.
|
||||
- **Given** an anonymous session id, **when** the user logs in, **then** the id
|
||||
is rotated and the pre-login id is inert.
|
||||
- **Given** active sessions and a SIGTERM plus restart, **when** the same
|
||||
cookies return, **then** the users are still logged in, replayed from the WAL.
|
||||
- **Given** two sessions for one principal, **when** revoke-all runs, **then**
|
||||
both are inert and other principals are untouched.
|
||||
- **Given** logout, **when** the cleared cookie is compared to the one that set
|
||||
it, **then** the attributes match, so the browser actually drops it.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **Flash messages and a general session bag.** A typed session with a known
|
||||
shape first; an untyped `map<Text,Text>` payload is a decision to defer, not
|
||||
a default to adopt.
|
||||
- **OAuth, OIDC, SSO, "log in with X".** Every one needs an outbound socket,
|
||||
which does not exist — language
|
||||
[iteration 38](../language-runtime-database/38-content-platform-capabilities.md).
|
||||
- **Remember-me tokens** — a second, longer-lived credential class with its own
|
||||
rotation story. Its own slice.
|
||||
- **CSRF** — iteration [4](04-csrf.md). Sessions make CSRF *possible* to do
|
||||
properly; they do not provide it.
|
||||
- **Sliding-window renewal of the absolute timeout.** That is not what absolute
|
||||
means.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **What does the session row carry beyond identity?** Just principal, or a
|
||||
payload? A payload wants a shape, and without generics the shape is either a
|
||||
declared class per app or `Text`. The framework's cache already stores `Text`
|
||||
and that is the compromise this repo dislikes most — so leaning: identity
|
||||
plus declared columns, and apps that want more keep their own table keyed by
|
||||
session id.
|
||||
2. **Idle-timeout writes on every request.** Touching last-seen means a WAL
|
||||
write per request, which turns every read into a durable write. Options:
|
||||
write at a coarser granularity, or accept the cost and say so with a number
|
||||
from `just db-bench`. This is a real performance fork, not a detail.
|
||||
3. **Does `Session` set `req.principal` or `req.ctx`?** `principal` is the
|
||||
framework's declared home for identity and auth middleware already writes it,
|
||||
so two writers of one field need a documented precedence.
|
||||
130
docs/stories/porch/04-csrf.md
Normal file
130
docs/stories/porch/04-csrf.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "4"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 4 — CSRF: tokens that are unguessable, bound, and spendable once
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2.
|
||||
> Needs [2](02-randomness-and-cookies.md) for randomness and cookies, and
|
||||
> [3](03-sessions.md) for something to bind a token to.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Tokens that cannot be guessed or forged.** Minted from the iteration-2
|
||||
builtin, verified with `ct_eq`. This is the iteration that could not have been
|
||||
written honestly before phase A of 2 existed, which is the whole reason the
|
||||
track is ordered this way.
|
||||
- **Bound to a session, not floating.** An unbound token is a token an attacker
|
||||
can fetch for themselves and replay against a victim. Binding is what makes
|
||||
the defence real, and it is why this follows sessions rather than preceding
|
||||
them.
|
||||
- **Origin checking as the cheap second layer.** Fiber ships `TrustedOrigins`
|
||||
alongside the token. Same-site cookies plus an origin check stop most of what
|
||||
tokens stop, for almost no cost — and the two layers fail differently, which
|
||||
is the argument for having both.
|
||||
- **Single-use where it matters.** Fiber's `SingleUseToken` exists because a
|
||||
long-lived token in a browser history or a referrer header is a credential
|
||||
left lying around. Decide which routes get it rather than making everything
|
||||
pay.
|
||||
- **Refusals that are distinguishable.** Missing, stale, foreign-origin and
|
||||
already-spent must be told apart in the logs, or nobody can debug a form that
|
||||
stopped working.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — mint and verify
|
||||
|
||||
- Token generation, storage keyed by session, and constant-time verification.
|
||||
- Decide the transport: a dedicated cookie plus a form field (double-submit), or
|
||||
session-stored plus a form field. The second needs no second cookie and is the
|
||||
stronger of the two once sessions exist.
|
||||
- Extraction from where forms and fetch clients actually put it: a form field, a
|
||||
header, and decide whether a query parameter is ever allowed (it should not be
|
||||
— it leaks into logs and referrers).
|
||||
- Verify: a valid token passes; altered, absent and foreign tokens each fail
|
||||
distinctly.
|
||||
|
||||
### Phase B — the middleware and safe-method policy
|
||||
|
||||
- A `Csrf` middleware gating unsafe methods only. `GET`/`HEAD`/`OPTIONS` must
|
||||
pass untouched or every link on the site breaks.
|
||||
- Origin and `Referer` checking against a configured trusted set, including the
|
||||
awkward cases: absent origin, `null` origin, and a same-origin request that
|
||||
arrives without the header.
|
||||
- Failure is a distinct status with a body that does not leak whether the token
|
||||
was wrong or merely stale.
|
||||
- Verify: the site's admin edit flow works through the middleware; unsafe
|
||||
methods without a token are refused; safe methods are unaffected.
|
||||
|
||||
### Phase C — single use and rotation
|
||||
|
||||
- Mark-spent-on-use for the routes that opt in, and decide what happens to a
|
||||
double-submitted form (the user double-clicking is not an attack, and treating
|
||||
it as one is a support ticket).
|
||||
- Rotate on privilege change, matching the session-id rotation from iteration 3.
|
||||
- Verify: a spent token is refused; a double-click produces a comprehensible
|
||||
outcome rather than a raw 403.
|
||||
|
||||
### Phase D — the gate and the ledger
|
||||
|
||||
- Both serving gates: the site's admin edit is the natural CSRF subject, the
|
||||
storefront's checkout the natural single-use subject.
|
||||
- Ledger and status board.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** an unsafe request with no token, **when** it is dispatched, **then**
|
||||
it is refused and the handler never runs.
|
||||
- **Given** a token minted for session A, **when** it is presented with session
|
||||
B's cookie, **then** it is refused — binding is enforced, not decorative.
|
||||
- **Given** a token altered by one bit, **when** it is verified, **then** it is
|
||||
refused in constant time.
|
||||
- **Given** a request from an untrusted origin carrying an otherwise valid
|
||||
token, **when** it arrives, **then** it is refused — the layers are
|
||||
independent.
|
||||
- **Given** a safe method (`GET`, `HEAD`, `OPTIONS`), **when** it arrives with
|
||||
no token, **then** it passes untouched.
|
||||
- **Given** a single-use token, **when** it is submitted twice, **then** the
|
||||
second attempt is refused and the outcome is distinguishable from a forged
|
||||
token in the logs.
|
||||
- **Given** login, **when** the session id rotates, **then** the outstanding
|
||||
token for the old session is no longer valid.
|
||||
- **Given** each refusal class, **when** the logs are read, **then** missing,
|
||||
stale, foreign-origin and spent are told apart — while the response body
|
||||
tells the client none of it.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **CORS.** Already shipped (`Cors` before/after middleware). It is a different
|
||||
problem — CORS decides who may *read* a response, CSRF stops a forged write.
|
||||
Conflating them is the most common way both get misconfigured.
|
||||
- **Same-site cookie attributes as the whole answer.** Iteration 2 sets a safe
|
||||
`SameSite` default and it does real work, but it is a browser behaviour, not a
|
||||
server guarantee, and older clients ignore it.
|
||||
- **Captcha, rate-limited forms, bot defence.** Rate limiting shipped in
|
||||
iteration [1](01-store-backed-middleware.md); the rest is not porch's.
|
||||
- **Encrypted or stateless tokens.** No symmetric cipher exists, and a
|
||||
stateless token cannot be revoked or spent once.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **Double-submit cookie, or session-stored token?** Double-submit needs no
|
||||
store and works without sessions; session-stored needs no second cookie and
|
||||
is strictly stronger. Since iteration 3 lands first, leaning session-stored —
|
||||
and if so, say plainly that porch has no CSRF story for sessionless apps
|
||||
rather than shipping the weaker one silently.
|
||||
2. **Which routes default to single-use?** All of them is safest and the most
|
||||
annoying; opt-in is pleasant and easy to forget on the one route that
|
||||
mattered. Leaning opt-in with the checkout as the worked example, because a
|
||||
default nobody can live with gets disabled wholesale.
|
||||
3. **What happens when a form is submitted twice by a human?** This is the fork
|
||||
that decides whether the feature is usable. Iteration
|
||||
[1](01-store-backed-middleware.md)'s idempotency machinery may be the honest
|
||||
answer — the same key, replayed, gets the same response — which would make
|
||||
these two features compose rather than collide.
|
||||
142
docs/stories/porch/05-routing-response-ergonomics.md
Normal file
142
docs/stories/porch/05-routing-response-ergonomics.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "5"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 5 — routing and response ergonomics: the parity that is merely missing
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §5.
|
||||
>
|
||||
> Independent of iterations 2–4 and of the streaming seam — startable at any
|
||||
> time, and a reasonable slice to interleave when the risky work needs a break.
|
||||
> Nothing here is hard; all of it is felt.
|
||||
|
||||
## Goals
|
||||
|
||||
- **The rest of the method helpers.** `App` has `get`/`post`/`put`/`delete_`.
|
||||
A `Route { method: "PATCH" }` literal already works, so this is registration
|
||||
ergonomics rather than capability — but writing the literal by hand for
|
||||
`PATCH` while `get` exists is the kind of asymmetry that makes a framework
|
||||
feel unfinished. Add `patch`, `options`, `head`, and an `all`.
|
||||
- **Named routes and reverse routing.** Fiber has `Name()` and `GetRouteURL()`.
|
||||
porch has neither, so every link in the site is a hand-written string that no
|
||||
compiler checks — and the site is exactly the app where a renamed path breaks
|
||||
a page silently.
|
||||
- **A per-route body limit.** `BODY_MAX = 1048576` is one compile-time constant
|
||||
for the whole server. An upload route and a JSON route want different numbers,
|
||||
and the JSON route wants a much smaller one than the upload route can live
|
||||
with.
|
||||
- **Request ids.** `req.ctx` already exists to carry one; there is no generator
|
||||
and no middleware. With iteration 2's builtin available this is a few lines,
|
||||
and it is the difference between logs you can correlate and logs you cannot.
|
||||
- **The response helpers written by hand today.** `Location`, `Vary`,
|
||||
`Attachment`/`Download`, and a content-negotiated `format` dispatch on top of
|
||||
the existing `accepts()`. Also q-value *ranking*, which the ledger has carried
|
||||
as a known 🔶 since the negotiation slice landed.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — method helpers and route introspection
|
||||
|
||||
- The missing registration helpers, including `all`, and decide whether `head`
|
||||
auto-registers alongside `get` (Fiber has `DisableHeadAutoRegister`, which
|
||||
tells you the default is auto and that people want it off).
|
||||
- Route introspection — list the table — because it is nearly free once routes
|
||||
are already a `multi Route`, and it is what makes a startup banner or a
|
||||
route-dump flag possible.
|
||||
- Verify: each method dispatches; `405` still carries a correct `Allow` built
|
||||
from the real table; existing routes unchanged.
|
||||
|
||||
### Phase B — named routes and URL building
|
||||
|
||||
- A name on `Route`, a lookup, and a builder that fills `:param` captures.
|
||||
- Decide the failure mode for a missing or extra parameter. A silently wrong URL
|
||||
is worse than a trap, and this is a compile-time-checkable shape only once
|
||||
language iteration 29's `@derive` exists — so for now it is a runtime check
|
||||
and should say so.
|
||||
- Migrate the site's internal links onto it, which is the proof it is usable.
|
||||
- Verify: every site link resolves through the builder; a wrong parameter set is
|
||||
refused loudly.
|
||||
|
||||
### Phase C — per-route body limits and request ids
|
||||
|
||||
- Move the limit from a module constant to route-level configuration with the
|
||||
current value as the default, so no existing app changes behaviour.
|
||||
- A request-id middleware writing into `req.ctx`, and settle whether an inbound
|
||||
header is trusted (fork 2).
|
||||
- Thread the id into the logging middleware's output, since a request id nothing
|
||||
logs is decoration.
|
||||
- Verify: an oversized body is refused per-route; the id appears in logs and is
|
||||
stable across a request's lifetime.
|
||||
|
||||
### Phase D — response helpers and negotiation ranking
|
||||
|
||||
- `Location`, `Vary`, `Attachment`/`Download`, and a `format`-style dispatch
|
||||
choosing a builder from `accepts()`.
|
||||
- Rank q-values properly instead of stripping them, retiring the ledger's 🔶.
|
||||
- Verify: `Vary` accumulates rather than overwrites (which the iteration-2
|
||||
repeated-header work makes possible); negotiation picks the highest-q match,
|
||||
not the first.
|
||||
|
||||
### Phase E — the gate and the ledger
|
||||
|
||||
- Both serving gates, the ledger rows, the board entry.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** a route registered with each new helper, **when** the matching
|
||||
method arrives, **then** it dispatches; **and** an unmatched method still
|
||||
yields `405` with an `Allow` listing exactly the registered methods.
|
||||
- **Given** `head` auto-registration, **when** a `HEAD` request hits a `GET`
|
||||
route, **then** the response is headers-only with the `Content-Length` a `GET`
|
||||
would have sent — the behaviour `serialize()` already implements, now
|
||||
reachable by registration.
|
||||
- **Given** a named route with `:param` captures, **when** a URL is built with
|
||||
the right parameters, **then** it matches that route's pattern exactly;
|
||||
**and** a wrong or missing parameter is refused rather than producing a
|
||||
plausible-looking wrong URL.
|
||||
- **Given** two routes with different body limits, **when** a body exceeding the
|
||||
smaller arrives at each, **then** it is refused at the small route and
|
||||
accepted at the large one.
|
||||
- **Given** no per-route limit, **when** a request arrives, **then** the
|
||||
previous global limit applies unchanged.
|
||||
- **Given** a request-id middleware, **when** a request is handled, **then** the
|
||||
same id appears in every log line for that request and in the response header.
|
||||
- **Given** an `Accept` header with q-values out of order, **when** negotiation
|
||||
runs, **then** the highest-q acceptable type wins — not the first listed.
|
||||
- **Given** two `Vary` contributions from different middleware, **when** the
|
||||
response leaves, **then** both appear.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **A radix-tree router.** Path matching is a linear scan and the ledger marks
|
||||
it 🔶 pending a *measurement*. Language iteration 22 built the benchmark
|
||||
harness but pointed it at the database. Until someone benches the router, this
|
||||
is an optimisation without evidence.
|
||||
- **Case-insensitive or strict-slash routing.** Fiber exposes both as config.
|
||||
porch is case-sensitive and lenient; changing that is a behaviour change for
|
||||
existing apps and wants its own decision.
|
||||
- **Compile-time-checked URL building.** The typed version needs language
|
||||
iteration [29](../language-runtime-database/29-compile-time-metaprogramming.md).
|
||||
Runtime-checked now, upgraded later.
|
||||
- **Streaming responses, `SendFile`, byte ranges** — iterations
|
||||
[6](06-streaming-core.md) and [8](08-static-and-lifecycle.md).
|
||||
- **Typed binding of params into a class** — language iteration 29 again.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **Does `head` auto-register?** Fiber's default is yes with an opt-out. Auto is
|
||||
friendlier; explicit is more predictable and never surprises someone
|
||||
debugging why a route they did not register is answering.
|
||||
2. **Is an inbound request-id header trusted?** Behind the mandated proxy,
|
||||
trusting it is what makes tracing work across hops. On an open port it lets a
|
||||
client forge correlation ids and poison logs. `client_ip` and `net.peer`
|
||||
already exist for exactly this trust decision — reuse that conclusion.
|
||||
3. **Where does a route's body limit live?** A field on `Route` is the obvious
|
||||
home but widens a record that the conformance corpus pins the ownership shape
|
||||
of. Check that fixture before choosing.
|
||||
155
docs/stories/porch/06-streaming-core.md
Normal file
155
docs/stories/porch/06-streaming-core.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "6"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 6 — streaming core: the seam three iterations wait on
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §3.
|
||||
>
|
||||
> The largest and riskiest slice in this track, and the one with the most
|
||||
> downstream value: iterations [7](07-sse-and-compression.md) and
|
||||
> [8](08-static-and-lifecycle.md) are both blocked on it, and porch's README has
|
||||
> carried "lazy body streaming · streaming responses · explicit commit point"
|
||||
> as parked since framework v1.
|
||||
|
||||
## Goals
|
||||
|
||||
- **A response that can be written incrementally.** Today `internal/serve.wo`
|
||||
builds the whole response as one `Text` and hands it to a single `net.write`,
|
||||
and `serialize()` always emits `Content-Length`. Nothing can produce output it
|
||||
cannot first hold entirely in memory — which rules out large downloads,
|
||||
server-sent events, and any response whose length is unknown when the first
|
||||
byte is ready.
|
||||
- **Chunked transfer-encoding on the way out**, correctly framed and correctly
|
||||
terminated, because a truncated chunked response is indistinguishable from a
|
||||
network failure to the client and corrupts keep-alive for the connection.
|
||||
- **Chunked request bodies on the way in — carefully.**
|
||||
`internal/parse.wo` **deliberately refuses** them today, with a correct note
|
||||
that silently treating a chunked request as body-less is request smuggling.
|
||||
That refusal is good engineering. It may only be lifted by an implementation
|
||||
that handles the smuggling cases explicitly, and the refusal must remain the
|
||||
behaviour for anything the parser is not certain about.
|
||||
- **An explicit commit point.** Once the first byte is written, the status and
|
||||
headers are gone and no `after` middleware can change them. That is a real
|
||||
semantic change to the middleware contract and it has to be stated, not
|
||||
discovered — the `after` chain currently runs on *every* response and
|
||||
security headers depend on it.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — the writer seam
|
||||
|
||||
- Decide the shape (fork 1) and introduce a way for a handler to emit body
|
||||
bytes progressively instead of returning a complete `Resp`. Handlers are
|
||||
classes, so this is a second interface beside `Handler`, not a callback.
|
||||
- Keep the existing whole-response path as the default and unchanged: the
|
||||
overwhelming majority of responses are small and should not pay for this.
|
||||
- Verify: the two paths coexist; every existing response is byte-identical.
|
||||
|
||||
### Phase B — chunked responses
|
||||
|
||||
- Chunk framing, the terminating zero-length chunk, and the interaction with
|
||||
keep-alive — a connection whose chunked response was truncated must be closed,
|
||||
not reused.
|
||||
- `Content-Length` and chunked are mutually exclusive; `serialize()` must pick
|
||||
one and never emit both.
|
||||
- `HEAD` on a streaming route: headers only, and decide what `Content-Length`
|
||||
claims when the length is unknown.
|
||||
- Verify: a chunked response reassembles byte-exactly; a mid-stream trap closes
|
||||
the connection rather than leaving a half-frame; `HEAD` is coherent.
|
||||
|
||||
### Phase C — the commit point and the middleware contract
|
||||
|
||||
- Define and enforce when headers are locked. An `after` middleware that tries
|
||||
to mutate a committed response must fail loudly in development rather than
|
||||
silently doing nothing.
|
||||
- Decide what happens to `SecurityHeaders` and `Cors` — both are `after`
|
||||
middleware and both must still apply to streamed responses, which means they
|
||||
have to run *before* the commit for those routes.
|
||||
- Verify: security headers and CORS are present on a streamed response; a
|
||||
post-commit mutation attempt is reported.
|
||||
|
||||
### Phase D — chunked request bodies
|
||||
|
||||
- Only if phase C is clean. Parse chunked request bodies with the smuggling
|
||||
cases enumerated and tested: both `Content-Length` and `Transfer-Encoding`
|
||||
present, duplicated `Transfer-Encoding`, unknown transfer codings, and
|
||||
oversized or malformed chunk sizes.
|
||||
- Every ambiguous case stays a 400-and-close, matching the existing duplicate
|
||||
`Content-Length` discipline.
|
||||
- Verify: a well-formed chunked upload arrives intact; every enumerated
|
||||
smuggling shape is refused.
|
||||
|
||||
### Phase E — the gate and the ledger
|
||||
|
||||
- A streaming route in a sample, gated on both consumers, plus a large-body leg
|
||||
proving memory does not scale with response size.
|
||||
- Retire the README's parked streaming rows; record the commit-point semantics
|
||||
where a handler author will find them.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green; ASan clean.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** a streaming handler emitting N chunks, **when** a client reads the
|
||||
response, **then** the reassembled body is byte-exact and the framing is
|
||||
well-formed.
|
||||
- **Given** a response far larger than the arena, **when** it is streamed,
|
||||
**then** it completes and peak memory does not grow with the body — the
|
||||
criterion that distinguishes streaming from buffering.
|
||||
- **Given** a handler that traps mid-stream, **when** the failure occurs,
|
||||
**then** the connection is closed rather than reused, no half-frame is left
|
||||
behind, and the server survives.
|
||||
- **Given** a streamed response, **when** it leaves, **then** the security and
|
||||
CORS headers the `after` chain contributes are still present.
|
||||
- **Given** an `after` middleware attempting to change a committed response,
|
||||
**when** it runs, **then** the attempt is reported rather than silently
|
||||
dropped.
|
||||
- **Given** a `HEAD` request to a streaming route, **when** it is answered,
|
||||
**then** the headers are coherent and no body is sent.
|
||||
- **Given** a request with both `Content-Length` and `Transfer-Encoding`,
|
||||
**when** it is parsed, **then** it is refused with 400 and the connection is
|
||||
closed — smuggling is refused, never guessed at.
|
||||
- **Given** every pre-existing non-streaming response, **when** both serving
|
||||
gates run, **then** output is byte-identical to before this iteration.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **SSE** — iteration [7](07-sse-and-compression.md), the first consumer.
|
||||
- **Compression** — also [7](07-sse-and-compression.md); it composes with
|
||||
chunking and should not be entangled with building it.
|
||||
- **Byte ranges and `SendFile`** — iteration
|
||||
[8](08-static-and-lifecycle.md).
|
||||
- **Request-body backpressure as a general mechanism.** Reading a body slowly to
|
||||
push back on a producer wants cancellation, which porch does not have and
|
||||
which language iteration 31's actor lifecycle owns. This iteration streams
|
||||
*out* and parses chunked *in*; it does not add flow control.
|
||||
- **WebSockets.** Already shipped (`ws_accept`, `wsframe`) and deliberately a
|
||||
hijack that bypasses `serialize()` — that path must keep working untouched,
|
||||
which is worth an explicit regression check.
|
||||
- **HTTP/2.** Proxy-terminated by doctrine, and parked behind language
|
||||
iteration 23 regardless.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **What is the writer?** Candidates: a second interface whose method is
|
||||
called repeatedly until it signals done; a `Resp` variant carrying a producer
|
||||
object instead of a `Text` body; or a handler that receives the connection and
|
||||
writes directly (which is what `ws_accept` already does via the 101 hijack
|
||||
sentinel). The third is the least new machinery and the most footgun. The
|
||||
first fits the no-closures doctrine best, since a producer is just another
|
||||
class with fields.
|
||||
2. **Does the `after` chain still run for streamed responses?** It must, or
|
||||
security headers regress. But it cannot run *after* the body. So either
|
||||
`after` runs at commit time for streaming routes, or streaming routes declare
|
||||
they opt out and the framework refuses to combine them with header-mutating
|
||||
middleware. Silent partial application is the one unacceptable answer.
|
||||
3. **Is chunked request parsing in this iteration at all?** It is separable and
|
||||
it is the riskiest security surface in the framework. Splitting phase D into
|
||||
its own iteration is a legitimate outcome of the brainstorm — the study is
|
||||
explicit that the current refusal is *correct*, so there is no pressure to
|
||||
rush it.
|
||||
144
docs/stories/porch/07-sse-and-compression.md
Normal file
144
docs/stories/porch/07-sse-and-compression.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "7"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 7 — server-sent events and compression
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §3.
|
||||
> Both halves need [iteration 6](06-streaming-core.md); neither is possible
|
||||
> before it.
|
||||
|
||||
## Goals
|
||||
|
||||
- **SSE, which fits this runtime unusually well.** A room actor already has the
|
||||
fan-out shape a live feed needs, and a parked fiber per subscriber costs
|
||||
almost nothing on the shard model — so the awkward part of SSE in most
|
||||
frameworks (holding thousands of idle connections) is the part writeonce
|
||||
already solved with iteration 35's idle deadlines and fiber-per-connection.
|
||||
Fiber ships `Retry`, `HeartbeatInterval` and `OnClose`; all three matter,
|
||||
because a proxy will silently drop an idle event stream.
|
||||
- **gzip/deflate, decided honestly.** Iteration 36 landed the bitwise operators,
|
||||
so a pure-`.wo` DEFLATE is now *expressible* — the question is whether it
|
||||
should be. A C builtin is faster and smaller to write; a `.wo` implementation
|
||||
keeps the runtime doctrine intact and proves the language can do real
|
||||
bit-level work. Fork 2 decides, and the answer should turn on whether anything
|
||||
else will ever want zlib.
|
||||
- **Negotiate, never assume.** Compress only when the client said it accepts the
|
||||
coding, only above a size threshold, and never for content that is already
|
||||
compressed — a gzipped JPEG is bigger than the JPEG.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — SSE framing and lifecycle
|
||||
|
||||
- The event framing (`data:`, `event:`, `id:`, `retry:`), the double-newline
|
||||
terminator, and the `text/event-stream` content type with caching disabled.
|
||||
- Heartbeats, because an idle stream through a proxy dies quietly. Interacts
|
||||
directly with iteration 35's `idle_ms` — a heartbeat interval longer than the
|
||||
idle deadline evicts the client the heartbeat exists to keep.
|
||||
- Disconnect detection and cleanup: a write to a gone client must free the
|
||||
fiber, the actor subscription and the fd, on every path.
|
||||
- Verify: a client receives ordered events; a heartbeat keeps an otherwise idle
|
||||
stream alive past `idle_ms`; a disconnect releases everything (fd count flat).
|
||||
|
||||
### Phase B — an SSE workload worth gating
|
||||
|
||||
- A live feed in a sample fed by an actor, so the fan-out path is real rather
|
||||
than a loop in one handler.
|
||||
- `Last-Event-ID` resumption, or an explicit statement that it is not supported
|
||||
— silently ignoring it means clients think they resumed when they did not.
|
||||
- Verify: N concurrent subscribers all receive an event published once; fds and
|
||||
memory flat across a churn of connect/disconnect.
|
||||
|
||||
### Phase C — the compression codec
|
||||
|
||||
- Implement or bind the codec per fork 2, with the framing gzip requires
|
||||
(header, deflate stream, CRC32 and length trailer). Note CRC32 does not exist
|
||||
yet — the crypto row lists it as waiting for a consumer, and this is that
|
||||
consumer.
|
||||
- Correctness against a reference decompressor is the acceptance bar, on
|
||||
awkward input: empty, highly repetitive, incompressible, and larger than any
|
||||
internal buffer.
|
||||
- Verify: `gzip -d` reproduces the input byte-exactly for every case.
|
||||
|
||||
### Phase D — the compression middleware
|
||||
|
||||
- `Accept-Encoding` negotiation reusing the q-value ranking iteration
|
||||
[5](05-routing-response-ergonomics.md) added, a minimum-size threshold, and a
|
||||
content-type skip list.
|
||||
- `Vary: Accept-Encoding` on anything compressed, or every cache in front will
|
||||
serve gzipped bytes to a client that cannot read them. This needs iteration
|
||||
2's repeated-header work to accumulate correctly with other `Vary`
|
||||
contributions.
|
||||
- Composition with chunked streaming: compress then chunk, and the ETag question
|
||||
— an ETag computed over compressed bytes is a different entity than the same
|
||||
resource uncompressed.
|
||||
- Verify: a compressed response decompresses to the original; `Vary` is present;
|
||||
no double-compression; an already-compressed content type is skipped.
|
||||
|
||||
### Phase E — the gate and the ledger
|
||||
|
||||
- Both serving gates plus an ASan leg for the codec, which is the part most
|
||||
likely to leak or over-read.
|
||||
- Retire the ledger's SSE and compression rows.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green; ASan clean.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** an SSE endpoint and a subscribed client, **when** events are
|
||||
published, **then** the client receives them in order with correct framing.
|
||||
- **Given** an idle SSE stream and a heartbeat interval shorter than `idle_ms`,
|
||||
**when** it idles past the deadline, **then** it stays open.
|
||||
- **Given** a heartbeat interval *longer* than `idle_ms`, **when** the stream
|
||||
idles, **then** the misconfiguration is evident rather than mysterious — the
|
||||
interaction is documented and, ideally, refused at construction.
|
||||
- **Given** a client disconnecting mid-stream, **when** the next publish
|
||||
occurs, **then** the write failure frees the fiber, the subscription and the
|
||||
fd; fd count returns to baseline.
|
||||
- **Given** N concurrent subscribers, **when** one event is published, **then**
|
||||
all N receive it and memory does not grow per event.
|
||||
- **Given** any input including empty, repetitive and incompressible, **when**
|
||||
it is compressed, **then** a reference `gzip -d` reproduces it byte-exactly.
|
||||
- **Given** a client that did not send `Accept-Encoding`, **when** it requests a
|
||||
compressible resource, **then** the response is uncompressed.
|
||||
- **Given** a compressed response, **when** it leaves, **then** `Vary:
|
||||
Accept-Encoding` is set and coexists with any other `Vary` contribution.
|
||||
- **Given** an already-compressed content type, **when** it is served, **then**
|
||||
it is not compressed again.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **Brotli and zstd.** One codec, proven, before a second. gzip is what every
|
||||
client accepts.
|
||||
- **Request-body decompression.** A compressed *upload* is a separate surface
|
||||
with its own decompression-bomb risk, and no workload asks yet.
|
||||
- **WebSockets as an SSE alternative.** Already shipped and a different tool;
|
||||
SSE is the one-way, proxy-friendly, reconnect-by-default option.
|
||||
- **Compressing static files at rest.** Fiber's static has `Compress`;
|
||||
precompressed-file serving belongs with iteration
|
||||
[8](08-static-and-lifecycle.md).
|
||||
- **A general-purpose zlib library surface.** Whatever lands is what the
|
||||
middleware needs. If a second consumer appears, it can argue for a library.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **Heartbeat versus idle deadline.** These two mechanisms can silently fight,
|
||||
and the failure looks like a flaky network. Decide whether the framework
|
||||
refuses an incoherent pair at construction — leaning yes, because a
|
||||
configuration that cannot work should not be constructible.
|
||||
2. **`.wo` DEFLATE or a C builtin?** The honest tiebreaker is whether anything
|
||||
else ever wants zlib. If compression is the only consumer forever, a `.wo`
|
||||
implementation keeps the runtime small and is a genuine demonstration that
|
||||
iteration 36's bit operators earned their place. If a second consumer is
|
||||
plausible (precompressed assets, a WAL codec, an archive format), the builtin
|
||||
wins. CRC32 comes along either way.
|
||||
3. **ETag over compressed or uncompressed bytes?** `etag_for` exists and is used
|
||||
with `with_etag` for 304s. Compressing after the ETag is computed keeps the
|
||||
entity identity stable across encodings, which is almost certainly right —
|
||||
but it must be decided, because getting it wrong serves the wrong body for a
|
||||
conditional request.
|
||||
153
docs/stories/porch/08-static-and-lifecycle.md
Normal file
153
docs/stories/porch/08-static-and-lifecycle.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
track: porch
|
||||
iteration: "8"
|
||||
status: refine
|
||||
---
|
||||
|
||||
# porch 8 — static files, lifecycle hooks, and the small middleware everyone ships
|
||||
|
||||
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
||||
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §3, §5.
|
||||
> The static half needs [iteration 6](06-streaming-core.md); the rest does not.
|
||||
>
|
||||
> The clean-up iteration. Individually every item is small; together they are
|
||||
> most of what makes a framework feel finished rather than adequate.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Static files that can serve something large.** `StaticFiles` exists and is
|
||||
already careful — traversal is refused rather than normalised, `max_bytes` is a
|
||||
hard ceiling, and the site's `/dl` downloads run through it. What it cannot do
|
||||
is serve a file it cannot hold in memory, resume a partial download, or let a
|
||||
browser cache correctly. Fiber's static ships `ByteRange`, `MaxAge`,
|
||||
`CacheDuration`, `IndexNames`, `Browse` and `Download`; the range support is
|
||||
what makes video and large downloads work at all.
|
||||
- **Cache headers that let a client skip the request.** `etag_for`/`with_etag`
|
||||
give conditional GETs; `Cache-Control`, `Last-Modified` and `If-Modified-Since`
|
||||
are the other half, and `fs.stat` already returns the mtime they need.
|
||||
- **Lifecycle hooks.** The ledger records "no user teardown hooks yet" as a known
|
||||
gap. Fiber has eleven hook families; porch needs a small handful — on-listen,
|
||||
on-shutdown, and on-route-registered — and the shutdown one is the one that
|
||||
matters, because an app with its own resources currently has nowhere to close
|
||||
them.
|
||||
- **The small middleware every framework ships**: healthcheck, favicon,
|
||||
redirect, rewrite, and a `skip` combinator. Each is a handful of lines and
|
||||
their absence is felt immediately by anyone starting a new app.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — ranges and cache headers
|
||||
|
||||
- `Range` request parsing (single range first; multi-range is a multipart
|
||||
response and can wait), `206 Partial Content`, `Content-Range`, and
|
||||
`Accept-Ranges`. An unsatisfiable range is `416`, not a truncated `200`.
|
||||
- `Last-Modified` from `fs.stat`'s mtime, `If-Modified-Since` handling, and
|
||||
`Cache-Control` with a configurable max-age.
|
||||
- Serve the body through iteration 6's writer so file size stops bounding what
|
||||
can be served.
|
||||
- Verify: a ranged request returns exactly the requested bytes; a file much
|
||||
larger than the arena serves; a conditional request returns 304 with no body.
|
||||
|
||||
### Phase B — directory behaviour
|
||||
|
||||
- Index-file resolution (`index.html` and friends) and an optional directory
|
||||
listing, defaulting **off** — a listing that is on by default is an
|
||||
information leak the first time someone points it at the wrong directory.
|
||||
- `Download`/`Attachment` disposition, reusing the helper iteration
|
||||
[5](05-routing-response-ergonomics.md) added.
|
||||
- Re-verify the traversal refusal against every new path (index resolution and
|
||||
listing both construct paths, which is exactly where traversal creeps back
|
||||
in).
|
||||
- Verify: index resolution works; listing is off unless asked for; traversal is
|
||||
still refused on all new paths.
|
||||
|
||||
### Phase C — lifecycle hooks
|
||||
|
||||
- Decide the minimal set (fork 1) and the interface — hooks are classes, like
|
||||
everything else here.
|
||||
- Wire the shutdown hook into the existing `env.stopping()` path so an app can
|
||||
flush and close before the process exits, and guarantee it runs exactly once
|
||||
even on a trapping path.
|
||||
- Verify: the shutdown hook fires on SIGTERM before the listener closes, once;
|
||||
a trapping hook does not prevent shutdown.
|
||||
|
||||
### Phase D — the small middleware set
|
||||
|
||||
- Healthcheck (liveness and readiness are different questions — say which),
|
||||
favicon, redirect (permanent and temporary), rewrite (internal, no round
|
||||
trip), and `skip` wrapping another middleware with a predicate.
|
||||
- Verify: each behaves; `skip` composes with the existing chain in registration
|
||||
order.
|
||||
|
||||
### Phase E — the gate and the ledger
|
||||
|
||||
- Both serving gates. The site is the natural subject: it already serves
|
||||
`/favicon.svg`, `/health`, and `/dl` downloads through `StaticFiles`, so these
|
||||
features have a real consumer rather than a synthetic one.
|
||||
- Close out the porch track's ledger rows and record what the whole track
|
||||
actually landed versus what the Fiber study predicted.
|
||||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **Given** a `Range: bytes=a-b` request, **when** it is served, **then** the
|
||||
response is `206` with exactly those bytes and a correct `Content-Range`.
|
||||
- **Given** an unsatisfiable range, **when** it is served, **then** the response
|
||||
is `416`, never a truncated `200`.
|
||||
- **Given** a file larger than the heap, **when** it is requested, **then** it
|
||||
serves completely and peak memory does not track file size.
|
||||
- **Given** `If-Modified-Since` matching the file's mtime, **when** the request
|
||||
arrives, **then** the response is `304` with no body.
|
||||
- **Given** a directory with an index file, **when** the directory is requested,
|
||||
**then** the index is served; **and** with no index and listing disabled, the
|
||||
response is `404`, not a listing.
|
||||
- **Given** a traversal attempt through the index-resolution and listing paths,
|
||||
**when** it is served, **then** it is refused — the existing guarantee, re-proven
|
||||
against the new code paths.
|
||||
- **Given** a registered shutdown hook, **when** the process receives SIGTERM,
|
||||
**then** the hook runs exactly once before the listener closes, and in-flight
|
||||
requests still complete.
|
||||
- **Given** a hook that traps, **when** shutdown runs, **then** shutdown still
|
||||
completes.
|
||||
- **Given** `skip` wrapping a middleware with a predicate, **when** the
|
||||
predicate matches, **then** the wrapped middleware does not run and the chain
|
||||
continues in order.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- **Multi-range requests.** A multipart byte-range response is a separate
|
||||
format; single ranges cover downloads and media seeking, which is what the
|
||||
workload needs.
|
||||
- **Precompressed asset serving** (`file.gz` beside `file`). Composes with
|
||||
iteration [7](07-sse-and-compression.md); worth doing once, later, when both
|
||||
exist.
|
||||
- **A file-watching or hot-reload story.** Assets are read from disk per
|
||||
request; a cache with invalidation is a different feature and language
|
||||
iteration [18](../language-runtime-database/18-memory-db-features.md) owns
|
||||
caching.
|
||||
- **`pprof`, `expvar`, metrics endpoints.** Language iteration 30 (no story file
|
||||
yet). A healthcheck is not observability — it is one bit.
|
||||
- **Fiber's fork, mount and prefork hooks.** The shard runtime owns placement;
|
||||
there is no worker-pool to hook.
|
||||
- **`SendFile` with kernel `sendfile(2)`.** No such builtin exists and no
|
||||
iteration owns adding one; the streaming writer is the portable answer here.
|
||||
|
||||
## Info
|
||||
|
||||
Forks the spec must settle:
|
||||
|
||||
1. **Which hooks, exactly?** Fiber has eleven families and porch needs the
|
||||
fewest that are load-bearing. On-shutdown is clearly one — an app with open
|
||||
resources has nowhere to close them today. On-listen is convenient for a
|
||||
startup banner. On-route-registered is only useful for introspection, which
|
||||
iteration [5](05-routing-response-ergonomics.md) may already cover. Fewer is
|
||||
better; each hook is a contract forever.
|
||||
2. **Liveness or readiness for healthcheck?** They answer different questions
|
||||
and conflating them is why deployments flap: liveness says "do not kill me",
|
||||
readiness says "do not send me traffic yet". A framework shipping one
|
||||
endpoint called `/health` should say which it is, and probably ships both.
|
||||
3. **Is directory listing available at all?** Off-by-default is not the same as
|
||||
present-but-off. Shipping it at all means it will eventually be switched on
|
||||
somewhere it should not be. Leaning: ship it, off, with the doc saying
|
||||
plainly what it exposes — the alternative is every app hand-rolling a worse
|
||||
one.
|
||||
Loading…
Reference in a new issue