All non-static routes now return Cache-Control: no-store to prevent
Cloudflare edge caching of dynamic API responses. Static assets
(/, /assets/config.js, /assets/styles.css) are exempted to allow
edge caching for performance.
Cloudflare may cache responses based on origin headers or its own
heuristics. Stale cached API/webhook responses would mask real backend
state changes (e.g., stale health probes, cached config reads, cached
webhook receipts). The no-store directive prevents this entirely.
Implementation:
- withNoStore(response) helper appends Cache-Control: no-store header
- Applied to all dynamic routes: /api/*, /webhook/*, /push/*,
/reconcile, /worker, /healthz*, /ready
- Static asset routes (/, /assets/*) exempt
- Error responses (404/405/503) also get no-store
Tests:
- New tests verify no-store presence on dynamic routes
- New tests verify static routes remain exempt
- All 43 tests pass
Docs:
- Both READMEs: updated pass-through semantics, test counts 38->43,
per-file breakdown (index 15->19, lb 12->13)
- retired.md: fixed index.js anchor 77-92->104-117 (drift from this
commit's line additionsBump LB_VERSION to 2026.10.14
GATE-DOCS: 6/6 clean
Two layers were masking the real cause of reconcile failures:
1. Backend: worker.rs reconciled all JMAP/Redis failures into Err(()),
so handle_reconcile returned 503 with {"error":"reconcile_retry"} but
no indication of WHICH step broke. Now reconcile() returns
Result<String, &'static str> with diagnostic codes:
- jmap_session_failed — session_state() failed (JMAP unreachable/auth)
- jmap_list_failed — list_emails_page() failed
- reconcile_budget_exhausted — budget/pages exhausted before any progress
- reconcile_no_progress — walk made no progress
- reconcile_lease_lost — background heartbeat lost the lock
- reconcile_state_write_failed — set_reconcile_state() Redis failure
- enqueue_failed — claim_dedup_and_enqueue() Redis failure
The 503 body now includes a "reason" field alongside the documented
"reconcile_retry" error code, so operators can see the cause without
server-side logs. The error code is unchanged (stable contract for
cron-drain.sh and other callers).
2. Cloudflare Worker LB: forwardWithFailover treated all 5xx as backend
failure and replaced the real response body with synthesized
"all backends failed: ..." text. Now the last 5xx HTTP response is
kept and passed through, so the backend's real error body (including
the new reason field) reaches the client. Pure network errors (no HTTP
response at all) still get the synthesized 503 fallback.
Adds the missing test coverage for the GET /api/business-config read-back
and the partial-patch PUT, then syncs the docs to the behaviour the code
actually has.
src/notify.rs
- PUT response body now carries "revision" alongside the
x-business-config-revision header, which the SPA surfaces in its status line.
- 4 test helpers (a router that returns (Router, MemoryState) so a config can be
seeded first, a full valid wire value, a direct state writer that skips
validation, and a wire value whose JMAP URL fails fast) plus 9 tests:
- GET 401 leaks no business field at all (body is exactly error + request_id)
- GET echoes the 10 non-secret values and returns secrets only as presence
booleans; the serialised body is asserted to contain none of the 6 secret
values (SAF-NO-SECRET-ECHO)
- GET with nothing stored returns 200 configured:false revision:0, not 404
- a first save with an incomplete patch is rejected and writes nothing
- a partial submit replaces only the fields it carries, verified by reading
the merged result back over the same path the SPA uses
- validation runs on the merged result, so changing one field cannot bypass
the rest, and a rejected submit neither persists nor bumps the revision
- telegram_chat_id is accepted as a number or a numeric string and a garbage
string is rejected without overwriting the stored value
- a typo in a field name is rejected rather than silently dropped
src/config.rs
- Pure deserialisation test for de_i64_from_number_or_string, with no router and
no store. This is the only place that separates "the custom deserializer
refuses the string" from "a later validation step refused it" - a real
distinction, since the SPA submits telegram_chat_id as a string on a partial
submit while the read-back model returns a number.
cloudflare-worker/test/index.test.js
- The forwarded-route test asserted a fixed upstream origin, which is not stable:
the LB rotates its start index between tests. Assert the origin is one of the
configured backends and check the path and method exactly instead.
docs
- 17 spots bring the docs in line with the shipped contract: GET is now
documented, PUT as a partial patch, the worker safelist at 16 routes, and the
preflight path listed as forwarded.
- Corrections the docs had backwards: the response field is "configured"
(revision is a u64, 0 when nothing is stored); Invalid returns 422, only
Unreachable returns 503; there is no chat_allowlist_empty error code.
- "Blank keeps the stored value" is now stated as a client-side contract. The
backend does not distinguish an omitted field from an empty string - apply()
uses unwrap_or_else, so an explicitly submitted "" really does clear the
secret (validation then rejects it). Saying it was server-side was wrong.
- POST /worker was listed as a forwarded route though it is not in SAFE_ROUTES,
and the per-category counts did not match the lists they introduced.
- POST /api/bootstrap does not share the patch-merge contract; it deserialises a
full BusinessConfigWire, which is the only path that can create a config from
nothing.
- Re-anchored 9 src/notify.rs line refs that had drifted blank and raised the
baseline to 86 passed / 0 failed / 2 ignored.
Gates: fmt, clippy -D warnings, test 86/0/2 ignored, worker 32/32, SPA 17/17,
GATE-DOCS 6/6 clean.
GET /api/business-config returns the stored values with presence-only secrets, and PUT now merges a patch against the stored config instead of replacing it, so the SPA can show what is already set and send back only the fields that changed.
The SPA marks secrets as set-but-unreadable, edits chat_allowlist as an unordered set, and omits fields it did not touch; blank required fields keep the stored value, blank optional fields clear it. The read-back is Serialize-only, so a client cannot inject secrets_present.
Forward both paths at the gateway: /api/business-config accepted PUT only, so the read-back returned 405, and the preflight path was missing from the safelist entirely, so it returned 404. The SPA is served from the Worker domain, so the whole edit experience was unreachable behind the production gateway. Route methods are now asserted per path, which the safelist test never checked.
Also: /debug/config reports timezone again, the timezone select is styled like the other fields, and the docs follow the new contract.
Every member's audit is now read and each real finding fixed, not just reported.
AGENTS.md §2.1
- The 500-line soft cap now has a machine-checkable exception: an over-limit
file must carry a one-line `SPLIT-EVAL:` marker stating the split decision.
- Six files were over the cap with no justification: notify.rs 2036, state.rs
1586, worker.rs 1045, config.js 1079, client.rs 601, config.rs 521. All now
carry the marker. client.rs was missed by my own earlier line count, which
did not descend into src/domain/jmap/.
- New validator scripts/docs_check/check_file_size.py; GATE-DOCS 4/4 -> 5/5.
It proves the marker exists, not that the written reason is sound.
- Re-based 144 line anchors across six docs for the +1 line each file gained;
every shifted anchor verified against the code, not just for non-blankness.
audit-arch.md
- design.md §6.1 module matrix omitted the `debug` module entirely; added.
- Seven numeric duplications in design.md now defer to reference.md §6.1 as
the single authority instead of restating the values.
- reference.md §5.1 was missing DEBUG_TOKEN; §5.3 listed six recognised names
and guessed `JMAP_*`. It now lists all 20 exactly as config.rs reads them.
- Corrected two wrong claims in the audit itself: debug.rs is compiled
unconditionally, not under #[cfg(debug_assertions)] (there are no cargo
features at all), and /debug/* exposes 7 routes, not the invented session
list / resend / rebuild surface.
audit-fileplanner.md
- reference.md cited a nonexistent `jmap_service.rs`; replaced with the real
adapter chain worker.rs -> domain/jmap.rs -> domain/jmap/client.rs.
- cloudflare-worker/README.md was outside every validator's scope; all four
doc validators now include it (208 section refs, 250 line refs).
- README.zh-CN.md stated only half of what README.md says about RUN_MODE; the
forward-placeholder sentence is now bilingual.
- charter.md §7 boundary table was missing rows for itself and for web/.
audit-rust.md / audit-frontend.md
- Error envelope corrected: `error` is a bare machine code, no `message`.
- Status table, AI-consent labels and ~15 stale or phantom facts fixed against
notify.rs; `TelegramClient::SendMessage` and `web_config` phantoms removed.
- GET|PUT /api/enabled forwarded by the worker so the SPA kill switch works.
- Ten UI strings that called the admin credential the Redis ACL password now
say CONFIG_ENCRYPTION_KEY.
- POST /healthz returns 405; dead HTTP_STATUS removed.
- audit_anchors.py now flags identifiers that do not exist in the file they
are anchored to.
charter.md
- `SAF-ENABLE-FLAG` registered (85 IDs); `SAF-ADMIN-SESSION` row corrected;
- `REQ-PUSH-TYPES` registered; the stale `admin_session_digest` removed.
Gates: GATE-DOCS PASS 5/5 (250 line-refs, 208 section refs, 0 errors);
GATE-P0 62 passed / 0 failed / 1 ignored, baseline unchanged; worker and SPA
36 tests, 0 failures.
Real Stalwart / Telegram / managed Redis verification stays blocked on missing
credentials and remains labelled as unverified.