Commit graph

10 commits

Author SHA1 Message Date
c97de237ef feat(porch-store): idempotency rebuilt so the actor runs the handler
- Delete before/after flow: it stored on a miss, so two duplicates both
  missed and both ran; its 10s "in flight" check fired on fast legit
  replays and never on a real collision
- Idempotent now wraps the route's Handler and hands request + handler to
  the pool; a duplicate waits in the actor's mailbox, not a held reply
- keypool.wo kind-2 arm: digest match replays, mismatch refuses (422), a
  miss runs the handler inside receive and stores status/body/
  content-type, all via the same pool_pack(count, remaining_ms) scalar
  kind-1 uses (WO-E226 forces one return type)
- Outcome codes start at 1, never 0: idempotent.wo's try/catch cannot
  tell a literal 0 reply apart from a trapped call
- fresh_req() copies a borrowed Req's map fields into a new Req before it
  crosses the actor boundary (WO-E222: aliased graphs can't cross heaps)
- Reading a stored row back forces fresh Text via `.. ""` on every field
  copied out of json.decode's result -- decoded Text does not survive
  being handed onward once the decoded record goes out of scope
- insert is unguarded (kind 1's own convention): a swallowed failure
  would answer "stored" for a response never written
- web-app-accept.sh: leg 18a/b/c -- byte-identical replay off an ExecMark
  row count, digest mismatch is 422, genuinely parallel duplicates run
  the handler exactly once

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eae1b06cdc38e4766d4e27a66b02264f47164e99)
2026-09-15 01:15:30 +02:00
37a63192c6 fix(porch-store): trust_proxy falls back to net.peer on an absent XFF
- limiter_key: an empty client_ip(req) under trust_proxy no longer keys
  on the literal "ip:" -- falls through to net.peer(req.conn) instead,
  same as the untrusted-default path
- the bug: every client omitting X-Forwarded-For shared ONE bucket,
  so one could exhaust it and deny/hide the rest
- curl availability check added alongside the existing woc/wovm check
  (the limiter gate legs drive the server with it)
- new gate leg: LIMIT+1 sequential no-XFF requests must all be 200
  (own key per connection, via a fresh ephemeral port each time) --
  confirmed it fails against the pre-fix code (6th comes back 429)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 831e9d8e6b1ef39cd938783dbc47c1abe6d53211)
2026-09-15 01:15:30 +02:00
491c2f42b4 feat(porch-store): limiter delegates all counting to the key pool
- delete all RateLimitCounter access from limiter.wo: query, increment,
  delete-then-insert, and the swallowing catch (e) nil -- the pool is now
  the only writer, so its serialization guarantee actually holds
- Limiter gains pool/limit/trust_proxy fields; before() calls pool_count
  and acts on the Verdict; make_limiter takes a pool
- key selection: req.principal first, else trust_proxy ? client_ip(req)
  : net.peer(req.conn); delete the dead req.ctx["verified_proxy"] branch
- 429 on a spent window (Retry-After, X-RateLimit-*); 503 + Retry-After
  on a caught actor trap (saturated pool), request never let through
- add Limiter.after(), registered alongside before() as both Mw and Aw
  (Cors's own shape) so the allowed path's X-RateLimit-* headers reach
  the response, not just req.ctx
- scripts/web-app-accept.sh: three new gate legs -- threshold (N pass,
  N+1th 429), SIGTERM+restart (still limited from the WAL), and N
  genuinely-parallel curl clients on one key with an exact-count assertion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a653dd0aa64711c43461126d32b4632cc8f64c7a)
2026-09-15 01:15:30 +02:00
9af42c8e69 fix(porch-store): correct reset_at unit on the two fresh-window paths
- keypool.wo:78,89 passed msg.window (µs) straight into pool_pack's
  remaining_ms (ms) parameter on the first-hit and post-prune-reset
  paths; the third call site already divided by 1000 and was correct
- fix: pool_pack(1, msg.window / 1000) at both sites — a 60s window
  no longer reports reset_at ~16.7h away
- count/allowed were unaffected (computed independently); this only
  hit the client-visible reset instant, on the two most common cases
  (new key, window rollover)
- extended gate leg 16 to assert reset_at falls within a 5s band of
  time.now() + window_ms, not just on count — verified the assertion
  itself by reverting the fix, confirming leg 16 failed with the
  exact defect shape, then restoring it and confirming green
- woc docs/examples/porch/ exits 0; web-app-accept.sh: 47 checks,
  0 failures

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 153fd295d37905dd083823536c6781843699e455)
2026-09-15 01:15:30 +02:00
4be826f9ab feat(porch-store): key pool actor for serialized per-key counting
- add docs/examples/porch/middleware/keypool.wo: one PoolMsg (kind 1 =
  count, kind 2 = begin placeholder for task 4), a Verdict class, a
  fixed-size actor pool with a byte-sum-mod-N selector
- KeyActor.receive implements kind 1: reads the row, writes the new
  count via field assignment (writes through, never delete+insert),
  prunes a fully-elapsed window's row instead of resetting it
- window arithmetic on time.ticks(); reset instant sent back is built
  from time.now() only
- call's reply must be a copyable scalar (WO-E226), so the count and
  remaining window time are packed into one Int by the actor and
  unpacked into Verdict by pool_count — the packing stays inside this
  file, callers only ever see Verdict
- gate leg in scripts/web-app-accept.sh: a flat copy of porch (manifest
  stripped) with a driver dropped beside keypool.wo asserts two
  sequential counts return 1 then 2; verified failing (E403, make_pool
  undeclared) before this file existed, passing after
- woc docs/examples/porch/ exits 0; full web-app-accept.sh: 47 checks,
  0 failures

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 676e651d808ef2c3619f88c3808adc372cd0be6c)
2026-09-15 01:15:30 +02:00
377808b936 feat(porch-store): add digest column to IdempotencyKey table
- Add digest field to store sha256(method|path|body) separately from key
- Enables detection of "same key, different body" in future tasks
- Update idempotent.wo insert to compute and store digest value
- Typechecker passes: exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3a9bddcd1e3c11f5b371ce54cafc373685ca08b6)
2026-09-15 01:15:30 +02:00
aee5adddc4 fix(porch-store): add the missing use json import
- docs/examples/porch/ did not typecheck: WO-E403 "cannot resolve the
  receiver's type for the call to `encode`" on json.encode
- every other example that calls json.encode/decode imports it; this
  file did not, so the whole porch library was uncompilable on dev
- woc docs/examples/porch/ now exits 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5c3544d524fa98dbb7a363600cd2eeb6dd1badac)
2026-09-15 01:15:30 +02:00
bf343045ab feat(porch-store): Idempotent middleware (Phase C, in progress)
- replays a stored response for a repeated Idempotency-Key: before()
  checks the key, after() stores status/body/content-type on a 2xx/3xx
- key is "idem:<header>:<value>", optionally plus a sha256 digest of
  method|path|body when include_body is set
- 409 while a key is in flight (stored within the last 10s), lazy TTL
  expiry on access, default 24h
- replay allowlists content-type only — never Set-Cookie or Date
- backed by IdempotencyKey from Phase A (519d411)

Written by a parallel session and committed here as-is because its
branch was consolidated away. NOT verified: it calls json.decode and
json.encode without a `use json` import, which every other example that
uses json has. Left unedited rather than fixed blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit aee79264095eea3b2c38c92789c44b31f1c9ef8a)
2026-09-15 01:15:30 +02:00
e159b5a754 feat(porch-store): Limiter middleware (Phase B)
(cherry picked from commit 5b1e82ab4bf3bad39bb6762a52b2dfaafd18a110)
2026-09-15 01:15:30 +02:00
192d451f5e feat(porch-store): store tables for rate limit + idempotency (Phase A)
(cherry picked from commit 519d4117fd0d6d0bd7d51f80bcb20de4d6294503)
2026-09-15 01:15:30 +02:00