The single-record write ceiling justified itself with "every one of these
routes is already wrapped in `withIdempotency`". Ten of the eleven are.
`POST /api/medications/intake` is not, and it is the one of the eleven a
native client actually loops — it is replay-tolerant for its own reason,
being an update of a named `intakeId` rather than a create. The comment
now says which is which, and adds the clause it was missing: the check
runs before the body is read, so a refused request spends the allowance
too.
The custom-symptom wrapper said a replay "answered the 409 that
uniqueness raises". There is no such 409. The row's key is minted fresh
per request and the label lives in an AES-GCM column with a per-write
IV, which can carry no unique index, so an un-keyed replay wrote a
second row with the same label — a silent duplicate in the person's own
vocabulary, up to the fifty-row cap. The wrapper is worth more than the
old sentence claimed, not less.
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.
All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.
The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.
Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.
`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.
The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
The facts fold already skips the empty assistant row a cancelled turn
leaves behind; the plan fold read it as a blank turn and spent one of
its recent-turn slots on nothing. Same skip, same reason. Also state
the accepted one-slot slack a late rate-limit refund can leave in a
freshly opened window. Refs #781.
The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.
- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
(default 6, clamped 1-1000), wired end to end: compose environment
whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
it was documented but never listed, so an operator value could not reach
the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
existing X-RateLimit-Reset header); the client derives "try again in
about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
the local index/echo work): malformed bodies, unreadable originals,
refused consent, and exhausted daily budgets refund the charged slot via
a window-scoped refundRateLimit primitive. Consistent across all four
routes sharing the bucket.
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.
Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.
Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
Bridge the existing transport-agnostic MCP server factory to a Next.js
route at /mcp using the SDK's Web-standard Streamable HTTP transport, so a
remote client can connect with a manually-minted hlk_ Bearer token. The
same tool/resource layer the local stdio transport serves is reused
verbatim — no duplicated tool logic.
The route owns four gates in front of the transport:
- Auth: Authorization: Bearer hlk_… resolved by the same validator the
HTTP edge uses, so revoked/expired/scope semantics never drift. A cookie
is never consulted, so the cookie-only admin boundary stays unreachable
here regardless of token scope.
- Rate limit: the Postgres limiter keyed by the <userId>:<tokenId> binding
(mcp:<binding>), so a single shared credential cannot drain the account.
- Module gate: a new opt-in mcp module, OFF by default. With it off the
endpoint answers 404 and reveals nothing — a self-hoster who never opts
in gets zero new exposure. The Modules settings hub renders the toggle.
- Transport: a fresh stateless server + transport per request.
Read-only by construction (only read tools are registered) and same-origin
by construction (no CORS headers). The proxy lets /mcp skip the page cookie
redirect since it authenticates by header, mirroring the webhook surfaces.
POST /api/consent/ai, POST /api/consent/ai/web, and GET/DELETE
/api/consent/ai/latest had no rate limit, so a scripted loop or a
stolen session could pin the receipts table. Add a shared per-user
bucket (20/min, keyed by user id) mirroring the coach-chat ceiling,
checked right after requireAuth, returning 429 with the X-RateLimit
headers attached. The web heal endpoint runs on every AI-settings
mount, so the bucket is generous enough to never throttle normal use.
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.