Commit Graph

164 Commits

Author SHA1 Message Date
anonsaber 66aeb3b9e7 docs: bump version baseline to 34efae8 (Host-header backend label round) 2026-10-14 08:48:23 +08:00
anonsaber 34efae84ff feat(status): derive `backend` from the request Host header, not a startup env
The previous round (88f0923) threaded a `backend_id` from `BACKEND_ID`/`HOSTNAME`
env vars into `AppState`. That required the operator to manually align each
instance's `BACKEND_ID` with the Cloudflare Worker's `BACKEND_ORIGINS_JSON`, which
is exactly the kind of manual cross-config sync that drifts.

The request's own `Host` header already carries the answer: the Worker load
balancer (`ARCH-LB-WORKER`) preserves the inbound `Host` while rewriting the target
to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is the
public address the LB routed this instance as. `backend_label_from_headers` reads
it per-request in `setup_status`, trims the default https/http port
(`example.com:443` -> `example.com`) so the label mirrors a bare origin, and returns
`None` when the request carries no usable `Host`.

This removes the startup env entirely — `backend_id_from_env`, `AppState::backend_id`,
all five router-builder `backend_id` parameters, the `.env.example` `BACKEND_ID`
entry, and the `BACKEND_ID` env documentation are all gone. Handlers still never
touch the environment: `Host` is request-scoped, not process-global, so there is no
test race (the `debug_surface_requested` rationale was about env, not headers).

Tests: `setup_status_reports_backend_label` now sends `Host: edge-1.example.com:443`
and asserts the label is the bare origin `edge-1.example.com` (port trimmed). A new
`setup_status_reports_null_backend_without_host` asserts `null` when no Host is sent.
GATE-P0 106 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean (49 line-refs).
2026-10-14 08:48:11 +08:00
anonsaber 26e916b79d docs: bump test/anchor baseline to match the backend-label round
Sync the stated-as-current gate numbers that the previous feature commit
(88f0923) shifted, per the same-round rule:

- Test baseline 104 -> 105 passed / 0 failed / 4 ignored across the
  authoritative (charter §6.1) and deferring docs (design §Phase 0,
  opengaps access-control, HANDOFF-push-401), with retest date 2026-10-14.
  The new test `setup_status_reports_backend_label` is a normal (non-ignored)
  test, so the ignored set is unchanged.
- opengaps anchor count 72 -> 49, matching what `run_all.sh` ANCHOR AUDIT now
  reports (the design.md main.rs line-anchors were converted to symbol refs).
- AGENTS version baseline 3b36fe3 -> 88f0923 (gate-holder merged this batch;
  88f0923 is the gate-green commit of the round). Charter Code Baseline
  `fc686ab` left as-is — line-number anchor semantics were not bulk-reverified
  this round, so claiming otherwise would overstate (§7.4).

GATE-DOCS 6/6 clean (49 line-refs, 0 errors); GATE-P0 stood at 105/0/4.
2026-10-14 02:46:17 +08:00
anonsaber 88f0923e6c feat(status): report per-instance backend label so probes can identify the origin
`/api/status` now echoes a `backend` field alongside `version`. The baked
`BUILD_VERSION` is identical across every instance built from the same image bath,
so it answers "did this deploy land?" but not "which origin answered?" when a
probe goes through the Cloudflare Worker load balancer (`ARCH-LB-WORKER`) — and the
Worker's `x-lb-backend` header is unreliable (it is silently dropped when the
response headers are immutable, e.g. cross-origin without CORS). Having the backend
self-report in the body removes that dependency entirely.

The value is a per-instance observability label read once at startup from
`BACKEND_ID` (falling back to `HOSTNAME`, which container runtimes set their
selves). It is not a connection secret and is never persisted (`C-REDIS-ONLY-STATE`).
`null` means neither env var was set — two such instances are indistinguishable by
this field by design.

Wiring keeps handlers off the environment (matching the `debug_surface_requested`
rationale: env is process-global and reading it inside a handler races tests):
`main` reads it once and threads `backend_id` through the router builders into
`AppState::backend_id`.

Also replaces the drift-prone `src/main.rs:<line>` anchors in design.md /
design.zh-CN.md with symbol references (`std::env::var` key names,
`LlmClient::with_runtime`), per the symbol-not-line rule; the insert had already
shifted them.

Tests: `setup_status_reports_backend_label` asserts the label round-trips into the
body. GATE-P0 105 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean.
2026-10-14 02:28:06 +08:00
anonsaber 6ae8978836 docs: bump version baseline to 3b36fe3
Gate-holder update per AGENTS.md version-baseline rule: after the
rate-limiter + stale-doc batch (3b36fe3) passed GATE-P0 (104/0/4) and
GATE-DOCS (6/6), the surviving line-anchors (21 in opengaps/retired/design)
are valid against 3b36fe3, so it becomes the new baseline.
2026-10-13 22:43:29 +08:00
anonsaber 3b36fe30ba fix(push): rate-limiter no longer blocks verification; sync stale docs
P1 (src/notify.rs): in jmap_push, move remember_push_subscription and
set_push_subscription_status BEFORE the push-verify rate limiter. The limiter
now gates only the outbound PushSubscription/verify JMAP API call
(claim_dedup == true -> call it; false -> skip), and the 429 branch is removed
entirely. A retry within the 30s window still records the key, so RFC 8620 §7.2
verification (the callback response) is never rejected by the limiter.

Test (src/notify.rs): jmap_push_requires_verification_code case 4 expected
SERVICE_UNAVAILABLE (503) -- pre-TTL=0 behaviour when remember failed. With
TTL=0 (commit 7331cb7) remember always succeeds and the hybrid payload
(verificationCode + accountId + emailId) falls through to event enqueue ->
NO_CONTENT (204). Expectation updated; comment added.

Comments: src/domain.rs drops phantom 'teloxide' (crate never imported);
src/config.rs SPLIT-EVAL reason corrected to real 1119-line size.

Docs (EN+ZH pairs, same round -- AGENTS 6.5/7.2):
- reference.* : ratelimit:push-verify row -> jmap_push, suppress-not-429;
  drop 429 error row; src/{index,lb}.js -> cloudflare-worker/src/*.
- design.*   : section 419 rate-limit scope; baseline 93 -> 104 (2026-10-13).
- charter.*  : secrecy 0.10 -> 0.8 (matches Cargo.lock); baseline 93 -> 104.
- opengaps.* : baseline 94 -> 104.
- HANDOFF-push-401.md : test baseline 99 -> 104 (never true; red test fixed).
- AGENTS.md  : fill version-baseline placeholder; surviving-anchor count
  17 -> 21 (verified: opengaps 8 + retired 9 + design 4).
- .env.example : src/main.rs:40 line-anchor -> symbol reference.

GATE-P0 (docker rust:1-slim-bookworm): fmt/check/clippy/test -> 104 passed / 0 failed / 4 ignored.
GATE-DOCS (scripts/docs_check/run_all.sh): 6/6 clean (704 path-refs, 55 anchors, 459 sec-refs).
2026-10-13 22:43:29 +08:00
anonsaber 09a108bdd7 docs: add push-401 handoff for incoming team 2026-10-13 19:45:45 +08:00
anonsaber f9160c0dbb fix(push): RFC 8620 §7.2 verification response + best-effort verify API call
Two issues preventing push verification from completing:

1. Response to Stalwart's verification request was 204 (empty body),
   but RFC 8620 §7.2 requires a JSON body containing exactly
   {"verificationCode": "..."}. Stalwart likely checks the body
   and rejects empty responses → subscription stays unverified →
   no event pushes ever sent.

2. verify_push_subscription (JMAP API PushSubscription/verify) was
   hard-fail (503 on error). This non-standard method may not be
   supported by Stalwart; a failure blocks verification entirely.
   Made best-effort: warn on failure, continue storing the key.
   Per RFC 8620, verification is done by the callback response,
   not by a separate API call.
2026-10-13 09:42:28 +08:00
anonsaber 7331cb7d31 fix(push): verification key should never expire (TTL 0)
User feedback: any finite TTL causes push to silently stop working
eventually. Stalwart does not re-verify (RFC 8620 §7.2 sends the
verification request once at subscription creation). The key is
explicitly deleted by forget_push_subscription when the subscription
is retired, so no TTL is needed.

Changes:
- PUSH_SUBSCRIPTION_SECONDS: WEEK → 0 (never expires)
- remember_push_subscription: ttl_seconds=0 → SET without EX (Redis)
  / expiry=i64::MAX (MemoryState)
- ttl_contract_is_pinned test: assert 0
- docs: reference/opengaps/design updated to 'never expires'
2026-10-12 23:57:24 +08:00
anonsaber d452f95485 fix(push): PUSH_SUBSCRIPTION_SECONDS 300→WEEK (604800)
Root cause of push notifications failing while cron works:
- Stalwart sends the verification request (RFC 8620 §7.2) once at
  subscription creation; event pushes (§7.3) arrive without
  verificationCode and rely on dedup_exists("push:subscription:{id}")
  to confirm prior verification.
- TTL was 300s (5 min): key expired → every event push 401'd.
- Cron path bypasses this key entirely (polls /changes directly),
  which is why cron-delivered emails still arrived.

Fix: align with PUSH_REGISTRATION_SECONDS (1 week). Stalwart does not
re-verify; the key must survive restarts and any gap between
subscription creation and events.

Also fixes: removed fictional push:subscription-code:{code} from
opengaps TTL table (no such key exists in code); corrected
reference.md caller from register_push→jmap_push verification path;
fixed 前→EX typo in reference.zh-CN.md; design.md updated with
fifth 'why' bullet and historical context.
2026-10-12 23:42:28 +08:00
anonsaber 88ac8594cc feat: immediate push trigger + inline /help + RFC 8620 push verification fix
notify.rs (架构师):
- telegram_webhook: /help, help, /summary* processed inline (spawn worker,
  return 204 immediately, no XADD); other messages still enqueue
- jmap_push: verification_code now optional per RFC 8620 — §7.2 initial
  verification requests carry it (verify + store), §7.3 event pushes don't
  (dedup_exists checks subscription was previously verified); this fixes
  the bug where every event push was rejected for missing verification_code
- jmap_push: after enqueue, spawn tokio task runs worker.process immediately;
  claims in-flight dedup → on success claims committed dedup + ACKs stream
  → on failure leaves message in stream for cron drain fallback
- TelegramMessage: add text field for inline command detection

docs/retired.md + retired.zh-CN.md: 4 notify.rs anchors re-anchored to
correct code (433=worker handler, 1173=refresh_business_config,
1552=register_push, 1565-1566=lock:push-register) — old anchors pointed
to wrong code, now semantically correct.

GATE-P0 104/0/4, GATE-DOCS 6/6, clippy 0 warnings, fmt clean.
2026-10-12 22:18:29 +08:00
anonsaber 21e91765d0 feat(reconcile): filter initial baseline to 7-day unread emails
New mailbox onboarding no longer floods Telegram with the entire
mailbox history.  The initial baseline walk (ReconcileCursor::Walk,
any position) now passes an EmailQueryFilter to list_emails_page that
bounds Email/query to messages received within the last 7 days and
not yet seen ($seen keyword, RFC 8621 §4.1.2).

- EmailQueryFilter { received_after: Option<i64>, unread_only: bool }
  in src/domain/jmap.rs (backend-agnostic domain type).
- StalwartJmap::fetch_email_page builds a compound Filter::and from
  the filter conditions (after + not_keyword "$seen").
- walk_baseline computes the 7-day cutoff once and passes it to
  every page fetch, so resumed walks stay filtered.
- Replay(state) path returns early and never calls list_emails_page,
  so /changes incremental is unaffected.
- const INITIAL_BASELINE_WINDOW_DAYS: u64 = 7.
- New test first_reconcile_filters_the_initial_baseline_to_recent_unread
  pins the filter on every Walk page and the cursor transition to Replay.
- 3 doc anchors re-anchored (worker.rs:227→241, client.rs:485→513 ×2).

GATE-P0: 104 passed / 0 failed / 4 ignored.
2026-10-12 22:08:51 +08:00
anonsaber 1bdda7c471 fix: reconcile lock robustness — TTL 300s→60s, heartbeat 90s→30s, renewal interval 10s
- state.rs: RECONCILE_LOCK_SECONDS 300→60, RECONCILE_HEARTBEAT_SECONDS 90→30,
  new RECONCILE_HEARTBEAT_INTERVAL_SECONDS=10; ttl_contract_is_pinned asserts
  interval*2 <= TTL < 60 and interval < LOCK
- notify.rs: heartbeat sleep uses ttl::RECONCILE_HEARTBEAT_INTERVAL_SECONDS
- docs: 7 files updated (reference/charter/opengaps/design × en+zh + index.js
  comments); retired.md/opengaps.md anchor line numbers shifted for state.rs
  (+9) and notify.rs (+3); notify.rs:1484-1485 range trimmed to avoid blank
- GATE-DOCS 6/6, cargo fmt/clippy/test clean (103/0/4)
2026-10-12 22:03:11 +08:00
anonsaber 298d487782 fix: stop swallowing reconcile errors — surface reason in 503 body
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.
2026-10-12 17:43:26 +08:00
anonsaber d0d1cde465 docs: simplify README prose, break long compound sentences
Break long compound sentences into shorter ones across both EN and ZH
READMEs without losing any detail. Improves readability of security
model, deployment, and operational sections.
2026-10-10 19:44:14 +08:00
anonsaber 2bb061cf28 docs: use --app-domain CLI flag style in cron-drain example 2026-10-10 19:30:44 +08:00
anonsaber b7e02dd9c8 docs: update cron-drain example to use MW_APP_DOMAIN parameter (auto-prepend https://) 2026-10-10 19:27:20 +08:00
anonsaber 77bb253cc0 docs: clean remaining deployment.md refs in design docs
Replace 9 residual deployment.md references in design.md (EN) with
correct targets (charter §8, reference §3/§9, README §3.0/§3.10,
cloudflare-worker/README). Mirror all changes in design.zh-CN.md.
GATE-DOCS 6/6 PASS.
2026-10-10 19:06:40 +08:00
anonsaber 7aedcd4145 docs: merge deployment.md into README, delete deployment files
- Move operational content (env var reference, multi-instance prerequisites,
  troubleshooting, cron-drain, CF Dashboard Git integration, BACKEND_ORIGINS_JSON
  format, security checklist) into README and README.zh-CN
- Delete docs/deployment.md and docs/deployment.zh-CN.md
- Update 5 validator DOCS lists to remove deployment entries
- Update charter §7 doc boundary table, AGENTS §2.5 line-anchor exception
- Fix cross-doc references in design/reference/opengaps/retired/worker READMEs
- Fix cron-drain.sh header comment reference (deployment.md §6.3 → README §3.10)
- GATE-DOCS 6/6 PASS (55 anchors / 441 sec-refs / 699 path-refs, 0 errors)
2026-10-10 18:53:43 +08:00
anonsaber d5403d8b5b feat(cron): add --app-domain/--worker-domain/--debug-domain CLI flags, auto-prepend https://
Bare domains like messageweave.example work without the https:// prefix.
Old --*-url flags and MW_*_URL env vars still work as aliases.
Flag values override env vars; defaults applied after parsing.
2026-10-10 18:12:31 +08:00
anonsaber 427c487923 ui: simplify SPA config callout to 3 sentences, align with field hints
The previous callout said "secrets must be re-entered on each full
save", which contradicted the field-level hint "leave blank; the
saved value stays". The actual contract: empty secret = keep saved
value (backend apply merge), required only on first setup.

Rewrite the callout (web/index.html + web/config.js, zh/en) to three
short sentences and make it consistent with secret.keepSet /
telegram.workerHint:
  - Secrets are stored server-side and never echoed.
  - Leave a secret blank to keep its saved value; required on first
    setup.
  - Other fields are read back automatically.

Dropped the "sent as <code>null</code>" sentence (too technical; field
hints already cover it) and removed the orphaned <code>null</code>
element. Dropped "always uses the primary account" -- the SPA has no
account_id field to move it to, so it is removed from the callout.

No JS logic changes; applyI18n just sets textContent on [data-i18n]
elements, so the <code> removal is safe. GATE-DOCS 6/6 PASS.
2026-10-10 17:28:59 +08:00
anonsaber bb42243a55 docs: note multi-backend shared-Redis config in README tutorial
The hand-holding tutorial in README §3.6 didn't mention that with
multiple backends behind a load balancer, you only need to configure
on any one node — all backends share one Redis. deployment.md §10.2
covers this, but the tutorial is what users follow during setup.
2026-10-10 17:19:00 +08:00
anonsaber 1b459d383c test(config): add idempotence regression for BusinessConfigPatch::apply
Per the strengthened acceptance: re-applying a complete patch whose values
equal the already-stored wire must yield an equal wire. Builds the patch
from the wire (patch_from_wire helper) and compares serialized forms, since
BusinessConfigWire does not derive PartialEq. Closes the four-case truth
table: first-save complete, first-save fragment, partial-over-stored merge,
and now same-value idempotence.

Test baseline: 102 -> 103 passed, 0 failed, 4 ignored.
2026-10-10 17:09:07 +08:00
anonsaber da46a8fd4c ui: clarify ambiguous SPA config copy (readback, save feedback, LLM toggle)
Four copy fixes on the SPA configuration page (web/index.html +
web/config.js). All string-only except one 1-line status override
in the runtime save handler.

1. Callout: "Business configuration is not read from the server" ->
   "Business configuration is encrypted in Redis". The old text
   contradicted GET /api/business-config, which reads non-secret
   fields back. New copy states secrets are not echoed but non-secret
   fields are read back; the first save must include all required
   secrets.

2. Save feedback: after saving runtime parameters the status line
   reused runtime.loaded ("read from Redis"), which is wrong for a
   save. Added runtime.savedState ("Saved and applied successfully.")
   and set it in the save handler. Dropped the stale "business
   config still needs manual filling" sentence from runtime.readToast,
   which also contradicted readback.

3. LLM toggle: "Allow network calls" -> "Enable LLM outbound
   requests"; the hint now states no LLM client is initialized and
   the bot calls no external model service when off.

4. All fixes applied to both zh and en dictionaries and to the HTML
   English fallback defaults for consistency.

GATE-DOCS 6/6 PASS. No src/, cloudflare-worker/, or docs/ changes.
2026-10-10 17:02:34 +08:00
anonsaber 0b58d81193 fix(config): stop the first-save business config panic in apply()
`BusinessConfigPatch::apply()` bound `let fallback = stored.expect(...)`
eagerly, so on a first save (stored=None, is_complete() true) it panicked at
bind time before any field was read. A brand-new Redis could not accept a
complete first save — every Save button hit the panic and surfaced as a 502.

Replace the eager expect with a `match stored` that, on `None`, builds the
wire straight from the patch (every required field is named because
is_complete() just proved it; the four Option<Option<String>> fields fall back
to None, their correct stored value on a first save). The stored-configuration
merge branch is unchanged.

Adds three regression tests: first-save with a complete patch (the panic
repro, now green), first-save with an incomplete patch (returns None), and a
partial patch merged into a stored wire (unmentioned fields keep stored).

Test baseline: 99 -> 102 passed, 0 failed, 4 ignored.
2026-10-10 17:01:49 +08:00
anonsaber dac4042ace docs: remove unnecessary RUN_MODE mentions from README and reference
The sentence explained why RUN_MODE doesn't exist — but if it doesn't
exist, there's nothing to explain. Removed from README (EN+ZH) §4 and
reference (EN+ZH) §5.1. Also fixed Chinese curly quotes to backticks
on the DEBUG_TOKEN row in reference.zh-CN.md §5.1.
2026-10-08 21:48:11 +08:00
anonsaber 29178cb093 docs: fold quickstart into README, rename gateway→load balancer
Two changes requested by the user:

1. Fold the hand-holding quickstart tutorial into README §3 (both
   languages). Delete docs/quickstart.md and docs/quickstart.zh-CN.md.
   Remove them from all 5 validator DOCS lists, README §6 doc index,
   and charter §7 boundary table. The tutorial now lives as
   README §3.0–§3.11: prerequisites, create Telegram bot, find chat id,
   get JMAP credentials, run backend, sign in, fill config, register
   callbacks, enable processing, verify, schedule drain, troubleshoot.

2. Replace 'gateway'/'网关' with 'load balancer'/'负载均衡器' across
   all markdown (README, docs/, AGENTS.md, cloudflare-worker/README).
   Also fix 4 markdown anchor links whose heading slugs changed.
   Also fix charter §8 '18 safe routes' → '19' (was missed in the
   earlier reference.md fix).

GATE-DOCS 6/6 PASS (71 anchors, 727 path-refs, 455 sec-refs).
2026-10-08 21:35:14 +08:00
anonsaber 7ed1a086fe docs: add hand-holding quickstart tutorial (EN + ZH)
New docs/quickstart.md and docs/quickstart.zh-CN.md walk a human operator from
zero to the first notification: create a Telegram bot via BotFather, find the
chat id, get a Stalwart app password, provision Redis, deploy the container,
sign in with CONFIG_ENCRYPTION_KEY, fill the business config, register
callbacks, enable processing, verify end-to-end, and schedule the cron drain.

Every claim was verified against the code:
- healthz is static 200 'ok'; /ready returns 503 until all upstreams
  reachable, 200 with per-component booleans
- admin session TTL = 1800s (30 min) in state.rs
- first save must be complete -> 422 (is_complete in config.rs)
- 409 conflict on revision mismatch
- secrets are write-only (Option<SecretString>, never echoed)
- /worker and /reconcile return 204 on success, 503 when disabled
- webhook secret mismatch returns 401 (not 403)
- search triggers: 搜索/查找/检索 + /search command only
- cron-drain.sh uses MW_APP_URL (req), MW_WORKER_TOKEN (req),
  MW_RECONCILE_TOKEN (opt)

Registered both files in all 5 validator DOCS lists (audit_paths globs
automatically). Added rows to README §6 and charter §7 in both languages.
Added discoverable links from README §3 to the quickstart.

GATE-DOCS 6/6 PASS (71 anchors, 735 path-refs).
2026-10-08 17:35:35 +08:00
anonsaber 74d53ae542 docs: restore indentation the ZWSP cleanup destroyed in design.md
The U+200B sweep from 5f182f9 also collapsed leading whitespace to a
single space on 140 lines. That silently wrecked the fenced ASCII
architecture diagram, the tree listings and the indented JSON/Rust code
blocks in design.md.

This commit restores HEAD^'s indentation and re-applies only the intended
content edits from 5f182f9 (11 lines in section 5.6/5.7, plus the 14
ZWSP characters). Verified: the working tree differs from 5f182f9 on
exactly 140 whitespace-only lines and on nothing else.

GATE-DOCS 6/6 PASS (anchors 71).
2026-10-01 19:04:55 +08:00
anonsaber 5f182f97b0 docs: fix facts that the code contradicts, in both languages
SAFE_ROUTES has 19 entries (backends.js), not 18. reference.md and
reference.zh-CN.md both said '18 entries'; both now say 19.

reference §4 said the /api/enabled and /api/admin/session calls are
'admin-session Bearer-auth'd'. The code says otherwise: config_authorized
(notify.rs:423) accepts the shared worker token OR a valid admin session,
and create_admin_session (notify.rs:1187) is gated by worker_authorized
against the admin credential CONFIG_ENCRYPTION_KEY — it is what mints the
session, so it cannot be session-auth'd. Both languages now state the two
distinct credentials and why adding /api/enabled opens no new credential
path.

deployment §10.8 said every request-bearing entry point, listing /healthz
and /api/*, calls refresh_business_config. The actual 10 call sites cover
8 distinct paths (/ready, /worker, /reconcile, /push/jmap, /webhook/tg,
/api/config, /api/business-config, /api/telegram/register-webhook). GET
/healthz is a static 200 "ok" and never refreshes; /api/status,
/api/enabled, /api/push/register, /api/push/disable and the
/api/admin/session pair do not either. Both languages now list the real
set and name the exclusions.

design.md §5.7 listed the search trigger prefixes as English
'search'/'find'/'retrieval'; worker.rs:689 accepts the Chinese prefixes
搜索/查找/检索 and the help text states English aliases are not supported.
The two user-facing strings were also mistranslated: the empty-query
guidance prompt is 请提供搜索关键词,例如:/search 发票 (worker.rs:623)
and the no-match reply is 没有找到匹配「<query>」的邮件。
(worker.rs:792). The degraded-rendering marker is likewise a Chinese
string. SEARCH_SUBJECT_MAX=120 / SEARCH_PREVIEW_MAX=160 /
SEARCH_LIMIT=10 verified unchanged.

design.md §5.6/§5.7 were machine-translated from Chinese back into
English and were broken: 'the first 300 if it fails. characters', a
stray closing ** mid-sentence, '(none) deny_unknown_fields)' with a
mismatched paren, and 5 zero-width-space artefacts. charter.md:168 had a
broken '**Tables, lists, or values may not be copied across documents. **'
and opengaps.md had 4. All ZWSPs removed from docs/.

deployment.zh-CN.md §10.5/§10.6 were thin mistranslations: 'Worker 域'
for 'Worker URL', the debug-token requirement dropped entirely, and a
mangled '两者都使用用于验证部署的可用和版本'. Rewritten against the EN.

Also replaced a worker.rs:227 line anchor with the MetadataWorker doc
comment; that drops the repo's line-refs from 72 to 71, so new text no
longer adds to the drift surface.

No code changes; GATE-DOCS 6/6 (71 anchors, 455 section refs, 727 path
refs).
2026-10-01 18:47:05 +08:00
anonsaber 8e92c9de4e docs(zh): restore machine-damaged identifiers in reference.zh-CN.md
The zh translation was generated by a machine translator that treated
backtick-delimited literals as prose. It therefore translated Redis
keys, function names, trigger words and status labels, which made the
documentation technically wrong rather than merely stylistically bad:

  config:business            -> 配置:业务
  config:outbound            -> 配置:出站
  config:admin_session       -> 配置:admin_session
  consent:ai:{chat_id}       -> 同意:ai:{chat_id}
  delivery:inflight/committed
                             -> 交付:飞行中 / 交付:已提交
  dedup:jmap:{account}:{email}
                             -> dedup:jmap:{帐户}:{电子邮件}
  ratelimit:push-verify:*    -> 速率限制:推送验证:*
  push:subscription:*        -> 推送:订阅:*
  state:jmap:since           -> 状态:jmap:自
  lock:reconcile             -> 锁定:协调
  stalwart:*:dlq             -> 坚定:*:dlq
  set/get_business_config, set_enabled, is_enabled,
  remember_push_subscription_id -> Chinese prose

Same damage class, three more forms:
- a typo: delivery:committed -> delivery:comfilled
- a case change that breaks a machine-readable code:
  429 push_verify_rate_limited -> 429 Push_verify_rate_limited
- translated trigger words: 直到我取消 / 撤销授权 / 停止摘要 instead of
  直到我撤销 / 撤销授权 / 停止摘要

Also repaired:
- six table headers 关键|TTL|作家|读者 -> 键|TTL|写入方|读取方
- a stray TTL number duplicated into the label column (3600 1小时)
- the AI-consent label row (直到截至 -> 直到撤销(最长365天))
- a dropped aside, 因此递增, which silently removed the reason why
  increment, DLQ append and source ACK are atomic
- the 搜索/查找/检索 prefix list, where 检索 had been normalised to 搜索

Section 4 was rewritten rather than patched: the paragraphs carried a
dangling untranslated 'all:' and 'proxy', a dropped closing '**' on
**is forwarded**, 'two calls' rendered as 两个电话, and word order that
reversed the meaning of 'no second ingress is needed'.

Verification: headings and table row counts already matched the English
file exactly (6 section pairs, no structural drift), so this is a
content repair, not a re-sync.

No code changes; GATE-DOCS 6/6 (72 anchors, 455 section refs, 727 path
refs).
2026-10-01 18:23:08 +08:00
anonsaber ed0f8172eb docs: state the gateway's actual design intent — a free-tier load balancer, not a security layer
Reframe the Cloudflare Worker gateway across the entry points where a
reader forms an impression of it, in both languages:

- README §2 / §5 (en+zh): the gateway is optional and exists because
  Cloudflare's Load Balancer product is not on the free plan. Drop the
  comparison row that listed 'only the 19 safelisted paths' against
  'every route' as if the safelist were a benefit; replace it with
  backend reachability, which is identical either way, and move the
  route-count fact to its own neutral row. Add one sentence to §5 that
  the gateway is outside the secret boundary.
- deployment.md §10 (en+zh): purpose is load balancing, not protection;
  no intent to hide or harden the backends, no access control at the
  edge, the allowlist is not a firewall.
- cloudflare-worker README (en+zh): new 'why it exists' / 'why it is
  not a security feature' block, plus a 'not a security layer' bullet.
  Drop 'access-hiding capability' from the non-capabilities list, since
  claiming invisibility as something we refuse to provide implies it is
  the interesting axis.
- reference.md §4 / §9.2 (en+zh) and deployment.md zh §2: stop saying
  bootstrap and /debug/* are 'not exposed' / 'kept off the public path'.
  They are simply not in the route set, which is why a request to the
  Worker never reaches them — reachable at the backend's own address
  either way.
- charter ARCH-LB-WORKER (en+zh): 'only external entrance' -> 'only
  *registered* entrance', which is what C-LB-SINGLE-REG-URL actually
  says, and backends stay directly reachable.

SAF-LB-PASSTHRU already records the security-side invariant (no auth
or body rewriting, backend may be directly connected to the public
network), so no new stable ID is needed. No code changes; GATE-DOCS
6/6.
2026-10-01 17:47:49 +08:00
anonsaber 1c1b1e644e worker: add the second origin messageweave-eu-2 to the LB origin list
BACKEND_ORIGINS_JSON now carries eu-1 and eu-2. Both are public https
origins, so the existing [vars] plaintext exception still applies and
nothing moves to secrets.

LB_VERSION stays at 2026.10.4: it is a code-version marker and the LB
logic is unchanged. The origin list is observable anyway through the
backends array in GET /healthz-worker, which now reports total 2.

Also drop a stale clause that said the origin list travels through
secret; it has lived in [vars] since aa78c13 and the same file says so
twice below it.

eu-2 needs its DNS record and a live backend before this deploy: with
LB_MAX_ATTEMPTS=2 both origins are covered, so a not-yet-deployed eu-2
costs a failed round-trip on about half the requests, and /reconcile
plus /worker run with maxAttempts=1 so those cron runs fail outright
on the half that land there first.
2026-10-01 17:18:35 +08:00
messageweave 09d4e01ec5 docs(deploy): document shared-Redis multi-origin topology and config convergence
10.2 now spells out that every Redis key is globally named with no
instance dimension, tabulates the shared state (config blob, global
revision, enable gate, cursor, streams, reconcile lock) and records
two consequences: the business configuration exists once so an SPA
edit from any origin writes the same copy (intended, no per-instance
slot exists), and CONFIG_ENCRYPTION_KEY must be byte-identical across
origins since it is the AES-256-GCM key and the SPA admin credential.
Extra origins buy failover, not throughput, because /reconcile is
serialized by lock:reconcile and the cursor is singular.

New 10.8 documents lazy convergence at the request boundary via
refresh_business_config, and the known behavior that a build_worker
failure advances the local revision so the instance does not retry
until a new revision is written. Includes detection via the live field
values in GET /debug/config, the warning that its revision field is the
global Redis value and cannot show convergence, and recovery by
bumping the revision again.

Appended as 10.8 rather than renumbering 10.3-10.7, which charter.md
NFR-HA-MULTI-INSTANCE cites. No code changes; GATE-DOCS 6/6.
2026-10-01 16:14:15 +08:00
anonsaber 42128433ed docs: add the worker bilingual README and name both deployment forms
Root README gains §2 "Two ways to run it": a side-by-side table of
Form A (backend origin only) against Form B (behind the Cloudflare Worker
gateway) — what answers, failover, the callback URL to register, the
reachable route surface, which health endpoint to watch, and the docs to
read. Late in the switch: registration is idempotent and re-adding retires
the previous subscription, so the switch is one SPA field, not a migration;
nothing secret moves to the gateway; /api/bootstrap and /debug/* stay
origin-only either way. Later sections renumbered, and the monitoring
paragraph now points at the gateway aggregate GET /healthz-worker instead
of the origin-passthrough /healthz.

cloudflare-worker/README.md rewritten as the canonical English version and
cloudflare-worker/README.zh-CN.md added as its translation, covering the
route safelist (19 paths with per-path methods), the timeout/retry budget
with per-route overrides, bounded failover, the two health endpoints,
callback registration, config, local verification, deploy, and the design
invariants.

Both fixes along the way:

- README.zh-CN said the startup env block has 2 required + 2 optional
  variables; the table has 3 rows, 2 required + 1 optional.
- docs/charter.md §8 / registry list now names the zh-CN twin next to the
  gateway README.

scripts/docs_check gains the new file in validate_docs, check_tables,
audit_anchors and audit_anchor_semantics so it is checked like every other
document. check_sec_refs resolves §-refs against the document being scanned
when a basename is shared: README.md exists both at the repo root and under
cloudflare-worker/, and the basename-keyed heading map silently dropped one
of them. The gate was passing for the wrong reason before.

GATE-DOCS 6/6 clean: 72 anchors, 454 section refs, 727 path refs.
audit_anchor_semantics still reports the same 7 pre-existing candidates,
none in the new or edited files.
2026-10-01 13:40:39 +08:00
anonsaber baa6d8a6a1 docs: reanchor the notify.rs and state.rs line refs the backend moved
The retire-on-change commit inserted ~500 lines into notify.rs and ~110
into state.rs, shifting every anchor at or after the insertion point.
The ANCHOR audit only proves the referenced line exists and is
non-empty, so all of these passed while pointing at the wrong code:

  deployment.md/.zh-CN   register_telegram_webhook 1619 -> 1623
                         register_push             1464 -> 1468 (EN)
                         register_telegram_webhook 1439 -> 1623 (zh)
                         register_push             1315 -> 1468 (zh)
  retired.md/.zh-CN      registration_lock fmt      1328-1330 -> 1481-1483
                         register_push              1315 -> 1468
                         state.rs read_batch        491 -> 506
                         state.rs query_async       531 -> 546
                         state.rs XGROUP CREATE     499-507 -> 514-522
  opengaps.md/.zh-CN     get_reconcile_state        777 -> 792
                         set_reconcile_state        785 -> 800
                         acquire_lock               1076 -> 1130

Every target was verified against the source before editing: two of the
pre-existing anchors pointed inside a different function than the one
they named, and the EN deployment.md had both register anchors off while
the zh twin still carried the original pre-move numbers.

Also documents the new push:registration:current key in the Redis key
table (writer, readers, TTL, and why its literal suffix cannot collide
with the hex session_digest suffix) and carries the zh twin the EN
version already got. Mirror the retire-on-change contract in
deployment.zh-CN.md - the zh twin has its own callback section, which
the EN update skipped.
2026-10-01 11:35:06 +08:00
anonsaber 2089a95b79 docs: drop the SPA-footer deploy proof and reanchor notify.rs refs
Four spots told the operator to read the SPA footer to confirm a
deploy landed. The footer no longer renders a version, so restating
that would send an operator looking at a control that no longer
exists. Point them at /api/status instead; the version field itself is
unchanged.

The previous commit inserted 500+ lines into notify.rs, moving
register_push and register_telegram_webhook. Reanchor the two
deployment.md line refs; the other seven refs in that file are before
the insertion point and are unchanged.

Document the new retire-on-change contract in reference.md.
2026-10-01 11:18:07 +08:00
anonsaber 0b27ca03a6 ui: drop the build-version display and fix the callback card
The value rendered was nogit+<UTC build time>, which carried no
information for the operator, so remove all three display sites
(topbar next to the secure-channel note, setup card, page footer).
Clears the three querySelectors, renderVersion, and the two dead i18n
keys. /api/status is untouched and still returns version - the API is
now the only way to confirm a deploy landed.

The <footer> element is removed with its span rather than left empty:
it had no other child, and an empty footer would still render a
visible separator band (border-top plus 41px padding).

The callback section's field was the only one in the SPA sitting
directly on the panel, with no padded ancestor. It spanned the full
card width with zero side padding and pressed the button bar, while
the copy above it was indented 25px. Wrap it in .field-grid and pad it
to match its card siblings on both desktop and the 720px breakpoint;
.field-wide's grid-column now has a grid ancestor.

Rewrite the callback copy to name the Cloudflare Worker front proxy
explicitly instead of the bare word Worker, and state that registering
a new address retires the previous subscription.
2026-10-01 11:17:50 +08:00
anonsaber b3df7c5d87 feat(push): retire the previous subscription when the callback URL changes
Registering with a new callback_url previously left the old Stalwart
subscription live alongside the new one, so Stalwart pushed the same mail
event to both origins and the user got duplicate notifications. The
per-URL mapping had no record of which subscription was current.

Add a push:registration:current pointer written alongside the per-URL
mapping. register_push now retires the previous subscription under the
existing registration lock before creating the new one: destroy first,
then delete the mapping, then mark the status disabled. A destroy failure
returns a retryable 503 (push_destroy_failed) rather than proceeding
silently - silently skipping is exactly the shape of the original bug.
The idempotency short-circuit still runs first, so re-registering the
same URL never touches the retire path. disable_push clears the pointer
only when the disabled URL is the current one.

Extracts destroy_push_registration, shared by disable_push and the new
retire path. Telegram needs no change: setWebhook replaces the previous
webhook atomically.

Adds 5 tests with a recording mock that captures the create/destroy
call order: idempotent no-recreate, retire-on-change, abort-without-
create when the old destroy fails, skip-destroy when the previous
mapping expired, and pointer untouched when disabling a non-current URL.
2026-10-01 11:17:32 +08:00
anonsaber 0c84f8ef56 ui: simplify footer to build version 2026-10-01 09:23:33 +08:00
anonsaber af89a17f7f feat(auth): extend admin session to 30 minutes 2026-10-01 09:19:49 +08:00
anonsaber b410dc4257 ui: trim Chinese admin copy 2026-10-01 09:11:23 +08:00
anonsaber 2752069eb3 docs: fix Chinese technical terminology 2026-10-01 03:33:42 +08:00
anonsaber e4a0a4b7db docs: translate Chinese document headings 2026-10-01 03:21:57 +08:00
anonsaber 1d4959a855 feat(worker): redirect HTTP to HTTPS 2026-10-01 02:52:01 +08:00
anonsaber 94032a1e08 docs: right-size test guidance and Redis wording 2026-10-01 02:38:16 +08:00
anonsaber 7a27f7ff00 docs: add bilingual guides and callback setup in SPA 2026-10-01 02:36:03 +08:00
anonsaber 98d62e490c docs(spa): 登录卡文案去重,只改字符串不动结构与逻辑
授权卡里同一件事被讲了三遍:CONFIG_ENCRYPTION_KEY 在正文、占位符、
顶部横幅各出现一次;「不写 localStorage」在提示里说、在 setup 隐私说明里
又说一遍;「只在页面内存」在正文、提示、会话过期默认文案里各出现一次。

收敛到每句话只出现一处(正文讲动作与 TTL,占位符只提示粘贴,提示只讲
重新授权,存储与内存的事实保留在正文与 setup 隐私说明里):

- auth.bodyPre   输入启动必填的环境变量 -> 输入
- auth.bodyPost  服务端会签发有效期 15 分钟的管理会话;该值与会话只在当前页面内存中使用
                -> 管理会话有效期 15 分钟,只存在当前页面内存中
- auth.placeholder 输入 CONFIG_ENCRYPTION_KEY 的值 -> 粘贴该值
- auth.hint      不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权
                -> 关闭或刷新页面后需要重新授权

EN 字典同步,web/index.html 里 3 处 data-i18n 回退文案跟着改。
键名、DOM 结构、data-i18n 键、逻辑与校验全部未动。
node --check web/config.js 通过;node --test web/config.test.mjs 6/6 通过。
2026-09-30 23:35:32 +08:00
anonsaber f9889288ba docs: 同步 /worker 进 safelist(17 -> 18 条),cron 示例收敛到单一域名
- deployment.md:§6.3.1 两条端点都可直接打 Worker 域名,手动 curl 与 crontab、
  k8s CronJob 示例去掉 WORKER_ORIGIN 只留 APP_URL,-m 分别给 320s / 300s(要略大于
  LB 侧 per-route 超时,否则 curl 先掐掉一个本来能完成的排空);§10.4 safelist
  18 条并补 /worker、不在 safelist 的从 3 类减为 2 类;LB_WORKER_TIMEOUT_MS 补进
  secret put 清单与 §10.9 变量表;测试计数 35 -> 37(index 12 -> 14)
- README.md:路由白名单 17 -> 18 条、/worker 入列、LB_WORKER_TIMEOUT_MS 补进
  变量表与配置片段、/reconcile 例外段落扩成 /reconcile + /worker 两条长超时
- charter.md / reference.md:17 -> 18 条、Safe Routes 16 -> 18 entries
- cron-drain.sh:MW_WORKER_URL 由「必须指源站直连地址」改为「可选,默认走平台域名」;
  404 route not forwarded 的提示改为 LB 版本太旧(需 >= 2026.10.3),升级或绕过 LB

顺手修正 6 处已漂移的行号锚点(此前指向空行或已迁移的文件):
backends.js:10-28 -> 10-30、index.js:47-65 -> 54-73、index.js:76-78 -> 87-91、
index.js:87-89 -> 95-96、index.js:93 -> 105、src/index.js:138 -> src/lb.js:155
(x-lb-backend 的 catch 在 656034d 修不可变响应头时已迁到 lb.js,文档一直指旧文件)。
GATE-DOCS PASS(115 line-refs / 477 path-refs / 252 section-refs,0 error)。
2026-09-30 23:08:57 +08:00
anonsaber 6bb57801ac feat(lb): POST /worker 加入路由 safelist,走独立长超时且不故障转移
外部 cron 触发排空队列时,/worker 走 Worker 域名会被 404 route not forwarded
挡掉,只能直连后端 origin;而 /reconcile 早就能走 Worker。cron 脚本因此被迫
维护两个域名。现把 /worker 与 /reconcile 成对放进 safelist(17 -> 18 条),
cron 只需一个平台域名。

同时给它配独立超时:全局 LB_REQUEST_TIMEOUT_MS 默认 10s 会把一次正常的排空
误判成失败,而 maxAttempts=2 切到第二实例毫无意义——第二个实例只会重复排
同一批消息。故新增 LB_WORKER_TIMEOUT_MS(默认 300000ms,等于后端单条事件
下界 state.rs SINGLE_EVENT_CEILING_FLOOR_MS),并强制 maxAttempts=1。

- SAFE_ROUTES 加 "/worker";ROUTE_METHODS 加 "/worker": ["POST"](GET => 405)
- 新增 DEFAULT_WORKER_TIMEOUT_MS = 300_000 与 per-route 覆盖分支
- LB_VERSION 2026.10.2 -> 2026.10.3(改 LB 逻辑须 bump)
- test/index.test.js 新增回归:/worker maxAttempts=1、默认 300s、env 覆盖、
  GET /worker => 405 且不打到后端
- test/backends.test.js safelist 用例补 /worker 与 /api/business-config/preflight

node --test:37 通过 / 0 失败(index 14 / backends 6 / health 5 / lb 12)。
2026-09-30 23:08:41 +08:00