Commit Graph

48 Commits

Author SHA1 Message Date
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 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 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 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 af89a17f7f feat(auth): extend admin session to 30 minutes 2026-10-01 09:19:49 +08:00
anonsaber 7a27f7ff00 docs: add bilingual guides and callback setup in SPA 2026-10-01 02:36:03 +08:00
anonsaber 5c77fab597 test(state): assert the dedup TTL floor as an upper bound, not a window
A remote TLS readback arrives after most of the 1s floor has been consumed,
so the +/-500 ms window could never be met (PTTL -2 / 269 / 118 ms across
three runs). PTTL -1 is what actually signals the regression this assertion
guards against -- EX silently dropped, key lives forever -- and a live value
can only be counting down, so bound it from above.

SET ... NX EX 1 itself is unchanged; the expired (-2) outcome is what proved
it took effect.
2026-09-30 14:51:58 +08:00
anonsaber ac27e593dd refactor(worker): make the reconcile cursor an explicit mode instead of a sentinel
The persisted cursor was a bare (String, usize) tuple in which usize::MAX meant
"already in replay mode". A real walk position of usize::MAX would have decoded
as a bare state token and its walk would have been skipped silently.
ReconcileCursor now carries the two modes, and the pass is split along the same
boundary: reconcile -> walk_baseline (WalkOutcome: ReplayFrom / Persist /
NoProgress) -> replay_changes.

Behaviour is unchanged; the four walk/replay budget constants move out of the
function body to module level, and the /changes parameter is renamed
max_changes -> initial_changes because it only sizes the first call.

domain/jmap: current_state -> session_state (RFC 8620 Section 2.1, which is not
the Email-collection token that Email/changes wants as sinceState) and
has_more -> has_more_changes (hasMoreChanges). EmailChanges and both trait
methods now carry RFC-referenced docs that spell out why oldState and destroyed
are dropped at the boundary.
2026-09-30 14:11:31 +08:00
anonsaber e85863e6da refactor: centralize every TTL value in state::ttl and pin it
Bare TTL literals at call sites were invisible to the test suite:
MemoryState ignores the TTL argument entirely, so shrinking a 24h dedup
window to 60_480 compiled and stayed green. All 17 values now live in
pub(crate) mod ttl, the call sites reference them, and
ttl_contract_is_pinned asserts each value plus two ordering invariants
(heartbeat shorter than the lock it renews; consent tiers strictly
increasing).

The one value the earlier sweep could not reach is now reached: the EXPIRE
inside retry_or_dlq's Lua script is built with an interpolated
ttl::RETRY_COUNTER_SECONDS, so the script carries no second literal.

Gate: fmt/check/clippy clean; cargo test --locked 89 passed / 0 failed /
4 ignored (the ignored ones need REDIS_TEST_URL or JMAP_TEST_URL).

Not claimed: the two real-Redis PTTL assertions are wired but cannot run
today. Both Upstash tokens I have are rejected with -WRONGPASS, so
connect() fails before any PTTL is read.
2026-09-30 13:20:00 +08:00
anonsaber d8e1226c76 test: make the real-Redis TTL tests runnable against a rediss:// URL
The two REDIS_TEST_URL-gated tests could never have passed as written: a
unit-test binary never runs main(), so no rustls crypto provider was
installed and the TLS handshake to a rediss:// URL panicked before any
command was sent. Install the provider in a helper inside the test module
and reuse the same call main.rs already makes.

Also fix a key-construction typo in the retry/DLQ test: the retry counter
key embeds the message id, not the run id.

Verified against the live Upstash instance:
  REDIS_TEST_URL=rediss://... cargo test --locked -- --ignored ttl
  ttl_claim_dedup_sets_the_exactly_requested_expiry ... ok
  ttl_consent_and_retry_landing_on_real_redis ... ok
2026-09-30 12:45:20 +08:00
anonsaber 7d6ea013b4 fix: make the real-Redis TTL test safe to point at a shared database
Two latent bugs surfaced while preparing to run these against Upstash:

1. The retry key was mis-constructed.  builds its counter
   as , so the test had to read
   . It read
   , a key the call never writes, so the
   assertion would have failed on the first real run (PTTL of a missing
   key is -2, and the test's own  guard rejects it).
   Derive the key from the same stream name and message id the call
   receives instead of re-spelling it by hand.

2. The consent probe used the process id as a Telegram chat_id. A
   private Telegram chat_id is a plain positive integer, so a small pid
   can legitimately be a real user. If the test is pointed at the live
   database it would have overwritten that user's AI consent flag with a
   1h expiry. Anchor at -4e18 + pid instead: real Telegram ids stay
   under ~1e11 in magnitude, so no live flag can be shadowed.

All test keys remain prefixed and are deleted at the end of each test.
2026-09-30 12:39:21 +08:00
anonsaber 0890eb18f0 test: assert the three Redis TTL write points on a real Redis (opt-in)
real_redis_ttl_tests in src/state.rs closes the "no test asserts any TTL
value" gap for the three atomic write points that actually reach Redis:

- set_nx_ex  SET NX EX, with .max(1) floor (Redis rejects a zero EX)
- retry_or_dlq EXPIRE KEYS[1] 86400, embedded in the Lua script
- set_ai_consent SET EX

Each is opt-in via REDIS_TEST_URL (#[ignore], mirrors the JMAP gating in
src/domain/jmap/client.rs:522); missing the env var prints 'skipped' and
returns, so CI stays green without credentials and the URL is never logged.
retry_or_dlq uses max_attempts=99 so the script returns before XADD/XACK and
no stream or group needs to be created.

opengaps.md item 2 is now blocked only on a reachable Redis URL. Two
defects in that section were fixed while touching it: the consent table
rendered 7 * 86_400 as 604_800 and 365 * 86_400 as 31_536_000 (arithmetic
right, literal wrong) and listed 300s/7200s values that do not exist in
the code - the sixth real consent TTL is 0 (revoke). 16 line anchors had
also drifted onto comments and neighbours; all are re-pointed. The line-
range audit passes 138 refs but only checks that a line is non-blank, not
that it holds the claimed content, which is how these slipped through.

GATE-P0 baseline moves to 88 passed / 0 failed / 4 ignored.
2026-09-30 12:30:57 +08:00
anonsaber e130cb1dfb refactor: drop the never-read EmailChanges.destroyed field
It was populated at the client boundary but read nowhere in production -
the doc comment claimed "retained for cursor accounting", yet the cursor
is just new_state. Deletions are dropped at the boundary instead of being
plumbed through and then ignored one layer in.

The upstream crate still returns destroyed; we simply stop carrying it.
Re-add it only when something acts on deletions (e.g. pruning a dedup
key) - keeping an unread Vec costs a copy on every changes page for
nothing.
2026-09-30 12:20:03 +08:00
anonsaber bc68c6b241 docs: unblock GATE-DOCS (debug.rs anchors drifted + file over 500 lines)
debug.rs grew past 500 lines with no SPLIT-EVAL marker, and the seven
/src/debug/* anchors in deployment.md pointed at the blank line just
above each function - so GATE-DOCS was red on two unrelated pre-existing
counts (SIZE CHECK, ANCHOR AUDIT) while the other four checks were green.

Re-anchor all seven to the function bodies they describe, add the marker
with the reason we are keeping the module whole, and document the
redis_failure_detail classification that deployment.md still called
"redis_probe_failed". Bump the GATE-P0 baseline to 88.
2026-09-30 12:17:04 +08:00
anonsaber 269c8f6304 fix: re-baseline reconcile when Email/changes no longer replays
A stale sinceState made every later run retry the same dead state and
reconcile:state froze forever, so reconcile answered 503 with no forward
progress. Re-baseline to a freshly fetched state at position 0 instead of
failing: the position walk never depends on server-side changelog
retention, and the 24h dedup key keeps the re-read at-most-duplicate.

Closes opengaps.md item 1 without a downtime window.
2026-09-30 11:51:44 +08:00
anonsaber 4cff3a7cee fix: bound Redis connect retries so an unreachable host fails fast
The previous commit switched to ConnectionManager but took its config
defaults. Two of them are unusable.

Default number_of_retries is 6, which is not a bounded retry in redis
0.27.6. Measured with a throwaway binary against a refusing host
(127.0.0.1:1, ConnectionManager::new_with_config):

    retries = 0  -> returns in ~0.5 ms with IoError
    retries = 1  -> returns in ~1.5 s with IoError
    retries = 6  -> never returns (still hung at 300 s)

So the crate default is an infinite loop, not "six tries". With it in
place a Redis host that is down at boot makes startup hang forever: no
error, no log, and a container that reports Up while serving nothing.
The code path this replaced (get_multiplexed_async_connection) returned
in about half a millisecond, so the default quietly turned a fail-fast
boot into a silent hang.

Default connection_timeout is None, which leaves each attempt at the OS
connect timeout: roughly two minutes for a host that drops packets
instead of refusing them.

Setting retries to 1 restores fail-fast boot -- an unreachable host now
exits in under two seconds with `State(Redis(Connection refused))` --
while still absorbing a single transient refusal. Anything more is
redundant here: the manager re-enters the connect path on every
subsequent command once the connection drops, so recovery never depended
on one operation retrying many times.

Measured end-to-end against a local redis container, killing the Redis
and restarting it while the app stayed up:

    before (retries 6): 61.6 s to recover, and during the outage
                        /debug/redis hung past a 25 s curl timeout
    after  (retries 1):  3.0 s to recover, and /debug/redis answers
                        immediately with detail=io_error

Auto-recovery still works with zero restarts; this only makes it faster
and stops the health endpoint from wedging while Redis is down.

Both values are named constants with the measurements in their docs so
the numbers can be checked and the "why only one retry" question does
not have to be answered again.

Gates: fmt clean, clippy -D warnings clean, cargo test 87 passed,
2 ignored, unchanged from the previous baseline.
2026-09-30 10:21:16 +08:00
anonsaber 1353745f58 fix: let the Redis connection reconnect instead of dying with the process
RedisState built one MultiplexedConnection at startup and kept it for the
life of the process; every call cloned the handle, which clones the handle
and not the socket. When the underlying TCP connection dropped -- an Upstash
idle timeout, a network blip, an idle-connection cutoff -- the handle was
permanently dead. The process kept running, every Redis read failed, and
only a restart recovered it. That is what had to be restarted.

The crate says so in its own docs: redis 0.27.6 lib.rs:71 recommends
ConnectionManager "for automatic reconnections" behind the
connection-manager feature, which we had never enabled.

RedisState now holds a ConnectionManager built from the Client. Both types
derive Clone and implement ConnectionLike, so the five query_async call
sites are untouched; the three helper signatures that took a cloned
connection by value or by mutable reference change type only.
MultiplexedConnection no longer appears anywhere in src/.

Start-up semantics are unchanged: ConnectionManager::new awaits
new_connection before returning, so an unreachable Redis still fails closed
at boot rather than succeeding and erroring on first use. Reconnect is gated
on is_io_error(), which is exactly kind() == IoError. A dropped socket is an
IoError, so it reconnects with exponential backoff and jitter (base 2,
100ms factor, 6 retries); an authentication failure is AuthenticationFailed
and is deliberately not retried, because that error does not fix itself.

No Drop or cancel is needed: the live connection sits behind
Arc<ArcSwap<SharedRedisFuture>>, the Client is retained for rebuilding, and
no detached control task is spawned, so nothing leaks when RedisState is
dropped and the existing #[derive(Clone)] still holds.

/debug/redis collapsed every failure into the single string
"redis_probe_failed", which is why the real error was invisible and the
outage took a session to trace. StateError::Redis carries the underlying
RedisError, so the class is readable here: authentication_failed,
io_error, busy_loading, client_error, response_error, plus
state_lock_poisoned and encryption_failed. ErrorKind is #[non_exhaustive],
so the match keeps a fallback arm for future variants.

No NotReachable variant exists in redis 0.27.6 -- a network outage
surfaces there as IoError or ClientError -- so the classifier does not
mention one.

Four new transitive dependencies come in with the feature: backon 1.6.0,
fastrand 2.4.1, futures 0.3.32, futures-executor 0.3.32.

Not covered: there is no test for the automatic reconnect path, which would
require tearing down a real TCP connection mid-flight. /debug/redis still
probes through is_enabled() rather than issuing a bare PING.

Gates: rust fmt clean, clippy --all-targets -D warnings clean,
cargo test 87 passed 2 ignored, unchanged from the previous baseline.
2026-09-30 09:13:38 +08:00
anonsaber b764d6dc30 feat: reject stale business-config submits with a 409
revision becomes a request-body control field on BusinessConfigPatch. It is
never a setting: apply() and is_complete() ignore it, so the stored wire
never gains a revision key, and a PUT body that omits it keeps
last-write-wins for callers that never read the revision back.

The SPA records the revision it read, echoes it on every partial submit, and
on a 409 re-reads the stored values into the form and tells the operator to
recheck what they changed. Without that reload every retry would 409
forever; if the reload itself fails the SPA falls back to last-write-wins
rather than trapping the operator. The control field is excluded from the
empty-patch check so echoing it never counts as a change.

The compare-then-write is deliberately not atomic. Without a Lua script an
optimistic lock still has a residual window in which two requests racing
inside the same tick both pass and the later one wins; the window stays
small because a patch carries only the fields the operator actually changed.

notify.rs gained 18 lines before line 700, so its anchors in deployment.md,
opengaps.md and retired.md shifted by +18. Every one was checked against the
real line content, since the anchor audit only flags blank targets.

Gates: rust fmt/clippy clean, cargo test 87 passed 2 ignored, node --test
web/config.test.mjs 18 passed, docs_check/run_all.sh 6/6.
2026-09-30 02:56:15 +08:00
anonsaber e3924a53c1 test: cover business config read-back and partial submit
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.
2026-09-30 01:24:42 +08:00
anonsaber 0cee50b4c9 feat: read business config back and accept partial patches
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.
2026-09-30 00:53:58 +08:00
anonsaber d32d645322 feat(config): make the notification time zone configurable
The Received line was sent as a raw unix timestamp. It is now a business
configuration field (`timezone`, IANA name, default `Asia/Shanghai`), edited
in the admin SPA and applied through the existing 1s hot-reload.

The offline build has no IANA tz database (chrono-tz is unavailable), so the
server keeps a static table of 16 zones that carry no daylight saving
transition and rejects anything else with the existing 422 configuration
error instead of guessing an offset.

`format_received_at` renders to `%Y-%m-%d %H:%M` at notification time; a
timestamp outside the representable range falls back to `unknown`.

Chrono 0.4 was already in Cargo.lock as a jmap-client transitive dependency,
so this only promotes it to a direct dependency.
2026-09-29 21:48:04 +08:00
anonsaber 5e32fe842a fix(jmap): request the From property so notifications show the sender
read_email never asked Stalwart for `From`, so `EmailMetadata.sender` came back
empty for every message and the Telegram line rendered as `From: unknown`.

List page was unaffected: fetch_email_page passes no `properties` argument and
therefore receives the full default set, which already includes `from`.

sender is now formatted as the standard mailbox form - `Name <addr>` when the
display name exists, the bare address otherwise - so a human-readable name is
shown instead of a raw local part.
2026-09-29 21:48:04 +08:00
anonsaber fc686abc22 config: persist business config regardless of client build, add preflight probe
PUT /api/business-config and POST /api/bootstrap now treat validation as the only
write gate: a valid wire is always persisted, and only then are the clients built
and the running worker swapped. A failed build returns 200 with persisted=true,
runtime_applied=false and a warnings array of {component, step, detail} instead of
503, so an unreachable JMAP/LLM no longer turns a valid configuration into a silent
data loss. The previous worker keeps serving.

POST /api/business-config/preflight runs the same validation and client build
against the submitted wire without writing anything and without touching the
running worker. It answers 200 even when the configuration is bad, because that
is the answer the caller asked for; components is null after a validation
failure, and llm is null rather than vacuously ok when LLM is disabled.
Authentication is the admin-session gate, not DEBUG_TOKEN.

The SPA drops the account_id field (the backend still accepts it; omitted falls
back to the session primary account), adds a Test connection button, and shows
the build fingerprint in the footer, the topbar and the setup card. The
503 message no longer claims JMAP/LLM build failure, which is not what produces
it.

BusinessConfigWire.account_id gains #[serde(default)]; without it, omitting the
field in the wire body made deserialization fail outright.

build.rs bakes BUILD_VERSION (git sha or nogit, plus a UTC build timestamp) which
GET /api/status exposes as version.
2026-09-29 18:25:56 +08:00
anonsaber dda8e084ae fix(jmap): trust the configured origin host so Stalwart's session redirect is followed
jmap-client refuses to follow any redirect unless the target host is on a
trust list that defaults to empty. Stalwart 307-redirects /.well-known/jmap
to /jmap/session, so connect() aborted with "Aborting redirect request to
unknown host 'mail.motofans.club'" and PUT /api/business-config failed with
a 503 carrying no usable detail.

connect_with_runtime now passes the origin host parsed from the normalized
session URL to follow_redirects, trusting that host only -- a target host the
server invents is still refused. An unparseable base yields an empty host,
which matches no target and preserves the previous fail-closed behaviour.

Verified against a real Stalwart instance both ways: the ignored smoke test
session_list_and_read_smoke now passes (session -> folders -> email list ->
read) and fails with the redirect error when the change is reverted.

docs: record the redirect trust under SAF-JMAP-URL, mark G1/D-G1-1 verified
after the real-server run, and refresh two shifted line anchors in client.rs.
2026-09-29 16:30:55 +08:00
anonsaber 80d13c0292 fix(notify): stop exposing the business and admin routes in configuration-setup mode
The setup router delegated to the full factory, so all 12 business and
admin routes were registered even though not one could ever work:
admin_token is an empty SecretString and constant_time_eq has a
!expected.is_empty() guard, so an empty expected value rejects every
candidate forever. MemoryState could not persist a bootstrap write either.

Those routes answered 401 {"error":"unauthorized"}, which is
indistinguishable from a mistyped credential. An operator or a probe
reads that as "try a different token" when the truth is "these
endpoints do not exist in this mode".

router_configuration_setup now builds its own AppState and mounts only
/api/status, /ready and /healthz. Verified live with no env vars:
healthz 200, ready 503, /api/status 200 with the missing list, and all
12 business/admin routes 404. The SPA is unaffected: /api/status is the
only endpoint it calls at startup, and web/config.js reveals the
admin-session card only when ready === true.

Added configuration_setup_mounts_only_status_and_probes to guard the
route table, and configuration_setup_call as its helper.

Docs: reference.md section 3 and 5.1 now describe the reduced surface
and cite the empty-expected-value guard; deployment.md's probe table had
/api/status listed as 503 {"status":...} when it returns 200 with a
"mode" field; retired.md section 4.1 records the removed route
registrations. Anchors refreshed for the +19 lines in notify.rs.
2026-09-29 15:26:54 +08:00
anonsaber 4cf128fcc8 feat(debug): 开关信号改为 DEBUG_ENABLED 环境变量,启动命令不再需要改动
起因:用户反馈会临时改启动命令(比如给 start 加 --debug)。而 hoststack.yaml
声明的 start.command 优先级最高且每次部署都覆盖控制台旧值,所以每次开关调试面
都得改 YAML、提交、推送、部署——最麻烦的那条路。

改法:把双因子的「开启信号」从单一 argv 变成「argv 或 env 二者之一」:
- --debug 保留,本地开发照旧用
- 新增 DEBUG_ENABLED(1/true/TRUE/True/yes/YES,精确匹配),生产走控制台环境变量
- DEBUG_TOKEN 仍然必须非空。刻意不让 token 单独生效——token 是平台上的长期
  Secret,让它自己开门就等于把双因子退化成单因子

收益:生产开关调试面 = 控制台改一个 env + 重新部署,不改代码、不改 hoststack.yaml、
不重新构建。启动命令从此静态,顺带消解了"YAML 还是控制台"的争论——命令不再需要变,
放哪都无所谓。

实现:把判定抽成纯函数 debug_surface_requested(debug_arg, debug_enabled_var),
不直接读全局 env。env 是进程级全局态,测试里读它会和同二进制内其他测试竞争;
调用方传入已取好的值,测试就能覆盖真值集合、假值集合(含 "TRUE " 带空格、
"debug" 这类近似值)与"开了开关但没 token 仍然关闭"。

文档:deployment.md §2.1 双因子条件重写 + 环境变量清单;§5 表格;§5.1 启动命令
说明;reference.md §3 表与调试面描述;design.md 架构图注与 LLM_ALLOW_NET 锚点;
charter.md 两条 ID 描述。hoststack.yaml 的 start 注释从"临时加 --debug"改成
"用 DEBUG_ENABLED 切"。

顺带修掉加代码引发的锚点漂移(新代码把 main.rs 后面的行号整体后移 13):
design.md:366、design.md:739、reference.md:205/335/336/380 共 6 处。

验证:cargo test --bin message-weave = 65 passed / 0 failed(新增 4 个测试,
含真值集合、假值集合、无 token 仍关闭)。GATE-DOCS PASS 6/6,259 line-refs 全绿。
2026-09-29 12:54:06 +08:00
anonsaber b2dbe7ccbb refactor: delete three zero-implementor 'stage 0 placeholder' channel traits
src/channel.rs had Channel / Notifier / MessageAdapter, each marked
#[expect(dead_code, reason = "稳定ID+阶段0占位…")]. All three had no
implementor, no caller and no dyn binding: Telegram outbound actually goes
through channel::telegram::TelegramClient::send_notification -> send_text,
held directly by worker.rs's MetadataWorker and notify.rs. The traits were
never on any path.

- delete the three traits + their #[async_trait] attributes
- drop the async_trait import from channel.rs (unused afterwards); the
  dependency stays in Cargo.toml, still used by 5 other files
- drop the domain import from channel.rs (its only uses were inside the
  deleted traits; the telegram module has its own Notification import)
- delete UserCommand from src/domain.rs -- its only consumer was Channel.
  Notification stays (worker.rs:234 builds it)
- rewrite channel.rs's module doc: "only an adapter boundary in stage 0
  (no bot commands yet)" was already false -- /help, /search, /summary
  and /ai on|off are all parsed in worker.rs::parse_intent

The three dead-code attributes were the last three #[expect(dead_code)] in
src/. Two #[expect(clippy::too_many_arguments)] remain in notify.rs; those
are justified lint suppressions on router wiring, not placeholders.

External contracts unchanged: HTTP routes, Redis keys, error codes and the
error envelope are untouched.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (unchanged).
2026-09-29 02:23:38 +08:00
anonsaber 2da11b9f92 docs+fix: delete the legacy env compat layer; sync every doc that described it
Code (src/config.rs 521->394, src/main.rs 232->200):
- Config::from_env() + required_secret / required_nonblank / env_bool deleted.
  config.rs now performs no std::env read; all business fields come from Redis
  business config (BusinessConfigWire -> validate_business_wire -> validate_nonblank).
- RUN_MODE deleted: variable, Config.run_mode field and the main.rs validation
  block. The two modes never changed runtime behaviour -- webhook and reconcile
  share one router, POST /reconcile is a standalone endpoint.
- jmap_password() accessor deleted; the field name is kept in
  BusinessConfig / BusinessConfigWire because it is real data, not a stub.
- Config.port and Config.redis_url deleted: both had zero readers, and port was
  hardcoded to 8080 in both constructors while the listener binds straight from
  the PORT env var, so the field could silently disagree with the real port.
  Config is now 6 fields: telegram / jmap / account_id / llm / auth / worker_token.
- redis_only() and from_business(value) signatures simplified accordingly.
- #[expect(dead_code)] count in src/ is now 0.

Test coverage: the from_env()-driven startup_rejects_blank_auth_secret case is
gone with the path it tested. The non-leak assertion it carried was reattached
to the real validation route (BusinessConfigWire -> TryFrom -> validate_nonblank)
with a stronger third assertion that the rejected value is not echoed either.

Docs: design.md 7.1 re-scoped to the Redis business config table; reference.md
5.1/5.3 rewritten (no legacy env path); deployment.md 5 and the runtime-mode
table rewritten; retired.md gains section 4 registering every deletion;
README x2, .env.example, charter.md updated.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (was 62; the deleted case). GATE-DOCS: PASS 5/5, ANCHOR 247 line-refs /
0, SECTION-REF 216 / 0.
2026-09-29 02:02:44 +08:00
anonsaber 638c3a8cbc docs: consume all four self-checks; make the file-size rule enforceable
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.
2026-09-29 01:16:11 +08:00
anonsaber b70d188864 docs+fix: sweep stale and phantom references; log the actual bound port
Code (2 sites):
- main.rs logged bootstrap_config.port, which is always the hardcoded 8080 in
  the two-variable production config; the listener actually binds the port
  parsed at main.rs:31-34. Log the bound port.
- That exposed Config.port as genuinely unread (clippy -D warnings), so mark it
  with #[expect(dead_code, reason=...)] instead of silently deleting a
  documented env-parsing site.

Docs, phantom identifiers (names that do not exist in src/):
- RuntimeConfig x3 -> OutboundConfig via RuntimeConfigProvider (state.rs:34/41)
- LlmClient::noop() x2 -> None in Option<Arc<LlmClient>> (main.rs:125)
- main.rs::validate_env_or_exit x2 -> config.rs:338 read, main.rs:75 validation
- TelegramChannel::send_message -> TelegramClient::send_text (channel.rs:134)

Docs, stale facts:
- LLM table: ENABLED and ALLOW_NET defaulted true,true -> false,false;
  API_KEY, BASE_URL, MODE were "required" -> only required when enabled. This
  contradicted design.md:385-387, which already had the correct values.
- design.md:560 said RUN_MODE "is actually consumed"; it is only read and
  validated and never affects behaviour (main.rs:73-74).
- RUN_MODE operator docs (README, reference, deployment, .env.example) now say
  both accepted values produce identical behaviour.
- retired.md: 5 intents -> 6 (/search shipped, worker.rs:517); 4 LLM env vars
  -> 6; main.rs tracing events 6 -> 7.
- design.md:494 tracing count 6 -> 7, debug-gate warning added to the list.
- design.md:350 code-gap count 2 -> 1 (multi-instance window done in 6c99ce5).
- design.md:554 marks the 45-paused stage-0 baseline explicitly as historical.
- .env.example said the Redis ACL password was the bootstrap trust root; it is
  CONFIG_ENCRYPTION_KEY (main.rs:88-91).

Docs, re-anchored after the main.rs shrink (6 refs shifted by -2).

GATE-P0 62 passed / 0 failed / 1 ignored; GATE-DOCS 4/4 clean
(228 line anchors, 211 section refs, 0 errors).
2026-09-28 22:33:27 +08:00
anonsaber bfe0fd88aa feat: Telegram /search 邮件搜索(email/query Filter + SearchSnippet/get 高亮)
⑥-c 落地代码缺口:/search <关键词>

实现
- JmapBackend::search_emails 加 trait 默认实现(mock 后端返回 InvalidRequest,
  真机后端用 Email/query + text Filter 取 ID,再 SearchSnippet/get 取高亮)
- JmapService::search_emails 暴露搜索入口,与 list_emails_page 共享超时/错误映射
- worker:Intent::Search + /search 分发分支 + /help 说明行

降级路径(不 panic、不回栈给用户)
- SearchSnippet/get 不支持(unknownMethod/timeout)→ 仍返回匹配 ID 列表,
  渲染降级为高亮片段暂不可用
- 无匹配 → 没有找到匹配「…」的邮件。
- JMAP 侧失败 → SearchReply::Retry,交由协调器 503 + Retry-After 重试
- 搜索关键词为空 → 引导提示,不发请求

安全边界
- subject/preview 只做展示:strip_mark_tags 剥 <mark> 高亮(大小写不敏感、
  未闭合标记丢弃,不泄漏原始标记),unescape_html_entities 单趟解码,
  先剥标签再解码实体以保留邮箱正文里字面 <mark>
- 无匹配 ID / 主题 / preview 分别有截断上限(120/120/160 字符)
- 每条命中带 email_id,可直接接 /summary <email_id>
- 中文关键词仅前缀匹配,避免帮我搜一下发票被劫持成搜索、或覆盖授权意图

测试 +7(55 → 62 passed, 0 failed, 1 ignored)
2026-09-28 21:09:06 +08:00
anonsaber 6c99ce51ab state: XAUTOCLAIM 空闲阈值改由运行超时配置推导,封顶 6h(01a0e75f-f9b1)
现状:read_batch 里 const SINGLE_EVENT_CEILING_MS = 300_000 是硬编码,
而真实单事件上限 = (max_retries+1) x (jmap+telegram+llm 三个 timeout),
运维把任一超时调大后实际耗时可能远超 300s → 空闲阈值欠保护,
多实例会把还在处理的条目提前认领,造成重复投递。

改动(ROADMAP 缺口③「多实例重复窗口」):
- single_event_ceiling_ms(config) 纯函数:(max_retries+1) x (jmap+telegram+llm) x 2
  (SAFETY),并用 .max(SINGLE_EVENT_CEILING_FLOOR_MS=300_000) 兜底,
  零值配置不会把窗口缩到 legacy 常量之下。
- reclaim_idle_threshold_ms(config, count) 纯函数:count.max(1) x 单事件上限,
  再 .min(RECLAIM_IDLE_THRESHOLD_CAP_MS = 6h) 防误调把恢复拖到天级。
- read_batch 调用 self.get_outbound_config()(trait 方法,同实现内已有,未改签名),
  Ok 走推导值,Err 回退 count x 300_000(不破坏投递主流程、不传播 Redis 错误)。

既有闸门不动:notify.rs 投递前查 delivery:committed、60s delivery:inflight 在途租约、
retry_or_dlq 3 次。本次只修阈值本身,性质保持「永不丢、只可能重复;单实例不受影响」。

:356-364 那段注释改写为描述真实机制(旧文「~300s 上限」已不准确),
未写入任何未经测试证明的断言。

测试:3 个纯函数单测覆盖 —— 默认配置 (15+10+30)x4x2=440s >= 300s floor、
大超时配置阈值更大、零值配置不缩到 floor 之下、荒唐超时被封顶 6h、count=0 用 1 事件兜底。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 55 passed / 0 failed / 1 ignored。
2026-09-28 18:38:00 +08:00
anonsaber f4cae00adc telegram: 重试之间加指数退避,429 尊重服务端 retry_after(01a0e75f-f7e3)
现状:send_text 的 for _ in 0..=max_retries 重试循环在两次尝试之间零延迟,
任何 5xx/超时/429 都以最短间隔连续打 API,429 场景下反而更容易触发二次限流。

改动(ROADMAP 缺口②「Telegram 429 退避」):
- backoff_delay_ms(attempt: u8) 纯函数:起步 250ms、每轮 x2、单轮封顶 4s。
  调用方 sleep 只走这一个出口,便于单测直接断言序列而无需真实等待。
- retry_after_ms(body) 纯函数:解析 429 响应体 parameters.retry_after(秒),
  转毫秒后钳到 60s 上限;缺字段/非数字/非正/非有限值一律回退到指数退避。
- 总退避预算上限 60s:耗尽后 delay 归零不再 sleep,避免单次 send_text 长时间挂起。
- 429 单独分支,错误串改为 telegram request rate limited (429) 以便区分限流与一般失败。

语义不变:max_retries 仍 cap 5、每次请求仍受 telegram_timeout_ms 约束、
返回 Result<(), String>、错误串不泄漏 token。

测试:client() 通过 with_sleep_override 注入 no-op sleep,退避逻辑完整走通但不真实等待。
新增 3 个测试:429 端到端(mock 返回 parameters.retry_after=1,断言 2..=3 次请求)、
backoff 序列(250/500/1000/2000/4000 后封顶)、retry_after 解析与钳制。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 52 passed / 0 failed / 1 ignored。
2026-09-28 18:13:08 +08:00
anonsaber afa6cec8ef debug: 新增 /debug/* 远程联调面,双因子门禁默认绝对关闭(01a0e319)
新增 src/debug.rs:/debug/ping、/config、/redis、/jmap、/telegram、/worker 六个只读
探针 + POST /debug/notify 一条测试出站消息。

SAF-DEBUG-GATE 双因子,缺任一即完全不挂载路由(请求落到通用 404,无"未配置即放行"
降级):
- 启动命令带 --debug(main.rs 最小 std::env::args 解析,未新增依赖)
- Secret 环境变量 DEBUG_TOKEN 非空

两层执行点:main.rs 推导 debug_token 并传入 router builder(None 则 debug_router 不被
merge);debug.rs 侧 auth_token 缺失时直接返回空 Router。

SAF-DEBUG-AUTH:每个 handler 先做 Authorization: Bearer DEBUG_TOKEN 常数时间比较
(复用 worker_authorized),失败 401,鉴权通过前不产生任何副作用。

探针只读侧全部复用现成实现,不另写一套:Redis 走 is_enabled,JMAP 走
probe_jmap_session,Telegram 走 probe_telegram_get_me,出站走唯一构造点
business_client(notify.rs 新增 pub(crate))到 send_text,/debug/notify 与
refresh_business_config 共用同一条客户端构造路径。

SAF-NO-SECRET-ECHO / SAF-LOG-PURITY:/debug/config 只回"是否配置"、计数与超时等
运行时参数,bot token / JMAP password / worker / webhook / reconcile token / LLM key
一律不出现在响应体;DEBUG_TOKEN 只在鉴权内部暴露,不回显、不入日志,日志仅一条
"debug endpoints enabled" 且不含令牌值。allowlist 用 chat_allowlist_snapshot 取生效值
(运行时业务配置优先、否则启动值),/debug/notify 显式 chat_id 不在 allowlist 则 403。

ARCH-LB-WORKER 白名单不含 /debug/*,该面仅供直连 origin 联调,公网入口不可达。

AGENTS.md 登记 4 个稳定 ID:MOD-DEBUG / SAF-DEBUG-GATE / SAF-DEBUG-AUTH /
REQ-DEBUG-ENDPOINTS(§2 第 19 条硬边界、§5 测试条、§7 索引 4 行)。

调试路由测试(4 项,notify.rs + debug.rs 内):Bearer 放行 200、缺失/错误令牌 401、
未提供 DEBUG_TOKEN 因子时 /debug/* 完全不注册而落 404、/debug/notify POST 未认证 401。

门禁四段全绿(rust:1-slim-bookworm):cargo fmt --all -- --check / cargo check --locked
/ cargo clippy --locked --all-targets -- -D warnings / cargo test --locked =
49 passed / 0 failed / 1 ignored。
2026-09-28 15:00:49 +08:00
anonsaber 800d6e0d00 notify: SPA 管理凭据改用 CONFIG_ENCRYPTION_KEY,解除与 Redis ACL 密码耦合
原实现把 SPA 管理凭据派生自 REDIS_URL 的 ACL 密码,并在 handler 侧用
`.is_empty()` 直接拒绝空凭据。托管 Redis 常见配置是 TLS-only 无密码,
那种情况下 `.is_empty()` 恒为真,管理界面永久 401 —— `POST /api/bootstrap`
与 `POST /api/admin/session` 双双不可用,无法完成初始化。

改为直接取 CONFIG_ENCRYPTION_KEY:它本就是启动必填 Secret(64 位 hex /
32 字节,见 config::encryption_key_from_env),启动时已校验,因此生产环境
不可能出现空凭据,handler 侧的空串守卫随之失效,一并移除。Redis ACL 密码
回归本职:只用于数据库连接。

- main.rs:env 只读一次(encryption_key_raw),admin_token 与已校验值不可能
  分叉;删除 Url::parse(redis_url).password() 分支。
- notify.rs:AppState 字段 bootstrap_token → admin_token(4 个构造点同步),
  两处 handler 删除空串守卫,bootstrap() 文档不再称 Redis ACL bootstrap。
- 测试更名 bootstrap_rejects_missing_admin_credential:断言语义不变(测试态
  空凭据收到 Bearer 仍 401),名称改为反映新语义。

门禁全绿:cargo fmt --check / check --locked / clippy --all-targets -D warnings
/ test --locked = 45 passed, 0 failed, 1 ignored。
2026-09-28 12:43:33 +08:00
anonsaber c5bc7b8d82 fix(notify): /ready JMAP 探针对齐真实客户端(Basic 认证 + 路径归一)
架构师复核指出两点会致全站不可用的缺陷(notify.rs):

- 探针未带认证:真实 jmap_client 以 .credentials() 取 session,Stalwart
  session 端点需认证;无认证必 401 → /ready 永久 503 → ingress 停止路由。
  改为从 business_runtime 快照取 jmap_username/jmap_password,
  basic_auth 随请求发送(零新增依赖,凭据仅入请求头,绝不日志)。
- session URL 未归一:配置若是裸 origin,GET 根路径必 404 → 误报。
  改为复用 normalize_session_url(client.rs 改为 pub(crate))并请求
  标准 /.well-known/jmap,与 JmapClientBackend::connect_with_runtime 同路径。

顺带:两探针 tokio::join! 并行,/ready 最坏耗时 3s→3s(原 6s)。

门禁 4/4 绿(45 passed, 0 failed, 1 ignored)。
2026-09-28 10:37:22 +08:00
anonsaber c0aab5e966 feat(notify): /ready 接入 JMAP session 与 Telegram getMe 端到端探测
ARCH-READY-BASELINE 从「配置完整性 + Redis 可达性」升级为端到端就绪:

- PROBE_TIMEOUT=3000ms 各探针独立超时(notify.rs:69)
- cached_jmap_session_url / cached_bot_token 先克隆后 await,
  避免跨 .await 持有 RwLockReadGuard(非 Send)
- probe_jmap_session:GET JMAP session URL,error_for_status
- probe_telegram_get_me:bot token 仅用于拼 URL,绝不日志
- ready():四者全过返 200 + 就绪报告 JSON(jmap/telegram 真实结果),
  任一失败 503 + Retry-After: 30(标准错误 envelope)
- 探针只读、无状态写入;token 永不回显(SAF-NO-SECRET-ECHO)

门禁:fmt / check / clippy / test 4/4 绿(45 passed, 0 failed, 1 ignored)。
2026-09-28 10:15:52 +08:00
anonsaber ed93358e29 feat(notify): unify /ready not-ready 503 into the error envelope
- GET /ready: ready -> 200 + diagnostic report (unchanged success body);
  not ready -> 503 + {"error":"service_unavailable","request_id":id}
  + Retry-After: 30, consistent with the other public entry points
- drop the legacy {"status":"not_ready"} diagnostic block from the 503 body
  (backward compatibility not required)
- HTTP 200/503 semantics preserved, so Uptime Kuma HTTP(s) monitors
  (status-code based) keep working
- sync docs: README.md, README.zh-CN.md, docs/design.md, docs/deployment.md,
  AGENTS.md, docs/todo.md
2026-09-27 13:11:29 +08:00
anonsaber f16b747672 feat(notify): unify public-route error envelope and add push route tests
- all public write routes now return {"error":code,"request_id":id};
  retryable 503 responses add Retry-After: 30; 204 and success bodies unchanged
- GET /ready keeps its structured diagnostic JSON (Uptime Kuma runtime contract)
- add HTTP tests for the push register/disable routes
2026-09-27 12:53:22 +08:00
anonsaber 309a38c503 fix(worker,state,jmap,config): close independent-review findings
- worker/state: paginate Email/changes by widening maxChanges on the same
  sinceState (jmap-client 0.4.2 has no upToId) to avoid skipping a batch when
  advancing per page; make dedup+enqueue atomic via claim_dedup_and_enqueue
  (Lua SET NX EX then XADD); scale the XAUTOCLAIM idle threshold by batch size;
  XACK payload-less malformed stream entries out of the PEL via ack_malformed;
  fail closed on unknown streams (Err); tighten SET NX EX TTL to .max(1);
  derive the CSPRNG fallback owner-token from time+PID+counter instead of a
  constant; clear the push verification digest on push:disable
- worker: /help menu now lists only the consent triggers the parser accepts
- jmap/config: complete client and configuration validation
2026-09-27 12:53:05 +08:00
anonsaber 956ef6a1d3 feat(config): add global Redis-backed enable switch 2026-09-25 20:11:35 +08:00
anonsaber 4ecfa44485 fix(redis): install explicit rustls crypto provider 2026-09-25 18:02:21 +08:00
anonsaber a57cb4f75d fix(redis): enable TLS for Upstash connections 2026-09-25 17:45:24 +08:00
anonsaber e18cecca16 refactor: remove legacy project identity 2026-09-25 10:40:10 +08:00
anonsaber 8a24ec7e9e fix: report all missing setup requirements 2026-09-25 09:42:15 +08:00
anonsaber e5ec3aef4c feat: provide configuration setup mode 2026-09-25 09:30:45 +08:00
anonsaber dceecaf2f4 feat: initial MessageWeave release 2026-09-25 09:14:04 +08:00