Repository navigation
fix: the v0.2.0 release-review findings - #132
Merged
Merged
Conversation
Two defects in the cache-purge surfaces, both found reviewing 0.2.0.
The console's "Purge everything" button sat inside a <form> that wrapped
<ConfirmButton>, which renders its own <form>. A <form> start tag inside a
form element pointer is a parse error and the token is dropped, so the inner
form never entered the DOM — and the dialog's submit button, which points at
it with form="corx-confirm-N-form", fell back to its nearest ancestor form.
The button therefore only worked because the inner form's hidden `csrf` and
`all=1` inputs were adopted by the outer one. Invalid HTML, and dependent on
parser error-recovery for correctness. `blocked.tsx` and `_topbar.tsx` already
use ConfirmButton standalone; this brings the cache page in line.
Separately, POST /api/cache/purge accepts {keyId} and has since the purge
index landed, but the 400 answered "Provide exactly one of: url, host, all" —
telling a caller the scope does not exist — and the c.req.json() generic
omitted the field it reads. README's curl comment repeated the omission.
The test counts <form> nesting depth in the rendered page (balanced siblings
reach 1, nesting reaches 2) and checks every form="…" target exists, plus that
the 400 names keyId. Reintroducing the wrapper makes it fail.
Also documents why revoke's purge is awaited rather than handed to waitUntil:
the count is the console's confirmation and the walk is already bounded by the
page budget, so the synchronous read is the deliberate trade.
Refs #111
The deep health check exercises D1 and R2 and, on failure, returns the raw
driver error. Unauthenticated, that is two things at once: an amplification
path (a D1 query plus an R2 LIST per call, for free, forever) and a map of the
deployment's internals — the message names the database and the bucket:
{"checks":{"d1":{"ok":false,"error":"D1_ERROR: db corx-db is inaccessible"},
"r2":{"ok":false,"error":"R2_ERROR: bucket corx-cache not found"}}}
Neither is something a liveness probe needs, and the probe uses the shallow
form. So ?deep=1 now carries the same guard as /api/* — getAdminUser, 401
without a credential — and /health stays cheap and public.
The error text itself is unchanged: with the route admin-gated, naming the
failing binding is exactly what an operator needs from their own deployment.
Tests pin both halves — anonymous deep is 401 while the shallow form still
answers 200, and the two deep cases carry the credential they need.
Docs: FEATURES' endpoint table and its monitoring entry, and README's curl
block, all say the deep form is admin-gated and point the uptime probe at the
shallow one.
Refs #117
…st read Both per-isolate memos are keyed by a hostname the caller chooses, and an isolate never shrinks on its own — so in subdomain mode, which is an open proxy by design, a caller cycling distinct hostnames grows the Map until the isolate hits its memory cap. Neither had a size bound: - guard.ts `blocklistMemo` (#110), new in 0.2.0; - dns-check.ts `resolveCache`, which has always had it. Both now cap at 4096 entries and clear wholesale when full. Wholesale is the deliberate choice: partial eviction would need an LRU structure and a clock this code deliberately does not keep, and the cost of being wrong is bounded — one extra D1 read, or one extra DoH lookup, per host until the next entry repopulates it. It cannot soften a block: blocking fails closed inside the memo, so a clear can only ever cost a re-read. The request-body streaming path also shadowed `pending` — the response header map declared at the top of the handler — with its own pending read, inside a block that builds a response. The scopes happen not to overlap today, so nothing is broken, but `pending.set(...)` added in that block would silently stop setting a header. Renamed to `pendingRead`. Refs #110, #107
ratelimit.ts, _blocked.tsx, metrics.ts, blocked.tsx, cache.tsx and cache/purge.ts were all added in 0.2.0 without one. No functional effect; it makes every future diff on those files report the last line as changed. The two files that also carry a functional fix got theirs in that commit.
SECURITY.md's threat-model table predates most of this release. Added rows for what 0.2.0 actually introduced: - per-key scope (migration 0014), with the fail-closed detail that matters — segment-boundary path prefixes matched against the post-injection URL, an unparseable CIDR or expiry refusing rather than widening, and the checks running before any cache lookup or upstream call; - stale-if-error, which serves a body past its TTL on upstream failure and so is a policy surface rather than a caching detail; - the unauthenticated surface, naming the deep-health gate and the blocklist page's hostname-and-date-only projection. Accepted limitations: #3 now describes the edge rate limiter's three costs (the binding owns the limit, counters are per isolate so the ceiling is N x limit, no X-RateLimit-* headers), and a new #7 records the blocklist memo's 30 s staleness window — why it only ever concerns a hand-edited row, why blocking fails closed inside it, and the new size cap. FEATURES.md gains the deep-health endpoint row and the memo cap beside the existing staleness note; README documents the gate and where to point an uptime probe. Refs #110, #117, #131
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release review of 0.2.0 before tagging. Six findings, five commits; nothing here
changes what a key or a caller may do, except where noted.
Findings
1. Invalid nested
<form>— console cache page.cache.tsxwrapped<ConfirmButton>in a<form>, but that component renders its own. Aformstart tag inside a form element pointer is a parse error and gets dropped, so
the inner form never entered the DOM and the dialog's submit button — which
points at it with
form="…"— fell back to its ancestor form. "Purgeeverything" only worked because the hidden
csrf/all=1inputs were adoptedby the outer form.
blocked.tsxand_topbar.tsxalready use it standalone.2.
/health?deep=1was unauthenticated. It costs a D1 query + an R2LISTper call and returns the raw driver error, which names the database and the
bucket:
{"checks":{"d1":{"ok":false,"error":"D1_ERROR: db corx-db is inaccessible"}, "r2":{"ok":false,"error":"R2_ERROR: bucket corx-cache not found"}}}A free amplification path and a free map of the deployment, neither of which a
liveness probe needs — it uses the shallow form.
?deep=1now carries the sameguard as
/api/*(401 without a credential); the error text is unchanged,because naming the failing binding is exactly what an operator needs.
3. Unbounded per-isolate memos.
guard.ts'sblocklistMemo(new in 0.2.0)and
dns-check.ts'sresolveCacheare keyed by a hostname the caller chooses,and an isolate never shrinks on its own — so in subdomain mode, an open proxy by
design, cycling distinct hostnames grows the Map to the isolate memory cap. Both
now cap at 4096 entries and clear wholesale. Wholesale is deliberate: partial
eviction needs an LRU and a clock this code does not keep, and a clear can only
cost one extra D1 read — it cannot soften a block, because blocking fails closed
inside the memo.
4. Stale
keyIdin the purge API.POST /api/cache/purgeaccepts{keyId}, but the 400 said "one of: url, host, all" and thec.req.json()generic omitted the field it reads. README repeated the omission.
5.
pendingshadowing. The request-body streaming path shadowedpending— the response header map — with its own pending read, inside a block that
builds a response. No overlap today, so nothing was broken, but a
pending.set()added there would silently stop setting a header. Renamed
pendingRead.6. Six new files lost their trailing newline.
Also
stale-if-error, and the unauthenticated surface; limitation feat(ui): reimplement all pages with daisyUI (Tailwind v4) #3 now describes
the edge rate limiter's three costs; a new chore(brand): display the brand name as CORX #7 records the blocklist memo's 30 s
staleness window and the new size cap.
the memo cap.
the console's confirmation and the walk is already bounded by the page budget.
Tests
cache-purge.test.tscounts<form>nesting depth in the rendered page(balanced siblings reach 1, nesting reaches 2), checks every
form="…"targetresolves to a form that exists, and that the 400 names
keyId. Verified theregression test fails when the wrapper is reintroduced.
metrics.test.tspins anonymous?deep=1→ 401 with the shallow form still200, and gives the two deep cases the credential they now need.
Every commit is self-consistent (tsc + vitest run per commit, not just at the
head).
Verify
check·test(47 files / 767) ·check:contrast(14/14) ·build·wrangler deploy --dry-run— all pass locally, matching theverifyworkflow.No migrations touched; no env vars or bindings added or changed.
🤖 Generated with Claude Code