Every doc statement that explained a Cloudflare challenge as a "score"
was wrong. Researched against Cloudflare's own docs on 2026-08-12
(docs/research/cloudflare-bot-scoring-and-poll-cadence.md, 22 primary
pages plus RFC 9309): the 1-99 bot score is Enterprise Bot Management
only, free-plan zones get Bot Fight Mode signature matching and no score
at all, and no per-IP request rate is documented as an input to
challenge issuance. cf_clearance also expires in 30 minutes, so every
cadence at or above 1h re-solves the challenge regardless.
Docs only - no behaviour change. The 6h browser cooldown stays; its
justification is now cost (a serialized single-tab solve costs seconds,
a plain read costs one request), not a risk reduction nothing documents.
- AGENTS.md: the block is per-zone configuration plus request
fingerprint, not IP reputation; comix.to turning its gate on
2026-08-12 is the example. Residential egress avoids the
cloud-hosting-IP signature rather than earning a better score. The UTC
measurement stands but its mechanism is marked undocumented.
- backend/AGENTS.md: states why the browser cooldown is longer.
- ADR-0003, ADR-0006: dated corrections rather than rewrites. Both
decisions stand on their other arguments (sweep depth, VPS memory).
- DEPLOY.md: a red smoke run means the Site's settings or this Chrome's
fingerprint moved, not that "Cloudflare's scoring" did.
Closes#46 once deployed.
The headless browser leaves the API stack and becomes its own compose unit
(`chrome/docker-compose.yml`) intended for the home machine, reached over the
tailnet. No fallback sidecar is left on the VPS.
The backend needs no code change — `BROWSER_WS_URL` was already the only
coupling. Its default is now empty rather than a pinned Docker IP, so an
unconfigured or unreachable browser degrades exactly as it always has: plain-TLS
libraries unaffected, kagane/novelfull logged and skipped, stored covers still
served.
### What shipped
- `chrome/docker-compose.yml` + `chrome/.env.example` — the browser unit, with
the CDP port bound to `${BROWSER_BIND_ADDR}` (no default) and the resource
limits from the epic: 512 MiB / 1 GiB memory+swap, `oom_score_adj 800`,
halved CPU weight, shm 1 GiB -> 128 MiB.
- API stack drops the service, its `depends_on` and the `browser` network.
- `bookmark-api` gains the `default` network. Dropping `browser` had left it on
`db` alone, which is `internal: true` — no published port and, worse, no
egress for the poller at all. Caught by actually bringing the stack up.
- ADR-0006 for the topology; `DEPLOY.md` §7 for first-time setup of the browser
machine; `REDEPLOY.md` §8 for its independent update cadence; architecture
diagrams, config tables and troubleshooting rows across README/AGENTS/env.
### Verified locally
- Browser unit builds and runs: Chrome 151, UA carries no `HeadlessChrome`,
all limits applied as declared.
- **Live smoke passes through the new unit**: `TestSmokeKaganeImage` fetched
56710 bytes of `image/webp`, `TestSmokeKaganeGet` got a 200 with a real
chapter list. The challenge cleared under the reduced 128 MiB shm.
- Bind isolation proven: refused on the host's non-loopback address, accepted
on the configured one.
- 321 MiB peak of the 512 MiB cap after a full solve; 0 restarts, no OOM kill.
- API stack comes up clean, `/healthz` 200; egress confirmed present on
`default` and absent on `db`.
- `go test ./...`, `go vet`, `gofmt` clean.
### Left to the operator
Provisioning the home machine, the Tailscale ACL, setting `BROWSER_WS_URL` in
production, and observing acceptance criteria 5-7 (covers with the machine off,
several days of zero OOM/restarts, VPS memory improvement). `DEPLOY.md` §7 now
carries the before/after `free -m` reading those need.
Reviewed-on: #52
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>