Skip to content

fix: the v0.2.0 release-review findings - #132

Merged
dev-bobsong merged 5 commits into
mainfrom
fix/v0.2.0-review-findings
Oct 8, 2026
Merged

dev-bobsong merged 5 commits into
mainfrom
fix/v0.2.0-review-findings

Conversation

@dev-bobsong

Copy link
Copy Markdown
Contributor

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.tsx wrapped
<ConfirmButton> in a <form>, but that component renders its own. A form
start 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. "Purge
everything" only worked because the hidden csrf/all=1 inputs were adopted
by the outer form. blocked.tsx and _topbar.tsx already use it standalone.

2. /health?deep=1 was unauthenticated. It costs a D1 query + an R2 LIST
per 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=1 now carries the same
guard 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's blocklistMemo (new in 0.2.0)
and dns-check.ts's resolveCache are 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 keyId in the purge API. POST /api/cache/purge accepts
{keyId}, but the 400 said "one of: url, host, all" and the c.req.json()
generic omitted the field it reads. README repeated the omission.

5. pending shadowing. The request-body streaming path shadowed pending
— 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

  • SECURITY.md gained threat-model rows for per-key scope (0014),
    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.
  • README / FEATURES.md document the deep-health gate, the endpoint row, and
    the memo cap.
  • The purge on revoke stays awaited, now with the trade documented: the count is
    the console's confirmation and the walk is already bounded by the page budget.

Tests

  • cache-purge.test.ts counts <form> nesting depth in the rendered page
    (balanced siblings reach 1, nesting reaches 2), checks every form="…" target
    resolves to a form that exists, and that the 400 names keyId. Verified the
    regression test fails when the wrapper is reintroduced.
  • metrics.test.ts pins anonymous ?deep=1 → 401 with the shallow form still
    200, 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 the verify workflow.
No migrations touched; no env vars or bindings added or changed.

🤖 Generated with Claude Code

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
@dev-bobsong
dev-bobsong merged commit 361adab into main Oct 8, 2026
1 check passed
@dev-bobsong
dev-bobsong deleted the fix/v0.2.0-review-findings branch October 8, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant