tuturuuu-abuse-guard Worker (apps/abuse-guard) stores short-lived
abuse-protection state in SQLite-backed Durable Objects. It replaces Upstash
Redis for the families listed below. Supabase remains the system of record. The
store holds only losable counters, clearances and fences, and every row has a TTL.
The Worker is not yet published. It has no workers.dev URL, preview URLs or
routes. Its first staging and production publication needs owner admission with
the cost and stop evidence from this page.
Model
Clients hash every partition and key with
HMAC-SHA256(ABUSE_GUARD_KEY_PEPPER, …)
before sending. Raw IPs, emails, user or session IDs and tokens never reach
Durable Object names or storage keys. Changing the pepper starts every counter
fresh, so rotate it only with that effect accepted.
Commands
Replay safety
Every RPC carries a fresh random operation id inside the signed body. Each mutating step recordsop:<id>:<step> with its results and a hash of its
commands, in the same transaction as the mutation, for 180 seconds. That covers
the full two-sided ±60-second signature skew, including future timestamps. A
replayed or concurrent duplicate returns the recorded results without charging,
reserving or refunding again. An id reused with different commands is rejected.
Read-only steps record nothing. The Worker verifies the signature over the raw
body and path before parsing JSON.
Clients never retry. A caller that retries a mutating call after an unknown
outcome, such as a timeout, must pass the same operationId (from
createGuardOperationId()). The retry then returns the original subject result without applying the mutation
twice; the command admission allowance still counts each RPC attempt. Unknown names in ABUSE_GUARD_FAMILIES, including
wrong-case names, are ignored with a one-time warning. Their family stays on the
legacy path.
Bounds
One RPC is limited to 16 steps (partitions), 8 commands per step, 32 commands, 4 windows per hit, 16 KiB of body, 2 KiB per value, and a TTL of 1 second to 120 days. The upper TTL covers the 90-day CLI refresh-token replay fence.Partitioning
- Generic records, windows, clearance and replay fences get one partition per hashed subject key, so there is no hotspot and little storage per object.
- Password, Meet guest and Nova reservations use one partition per limiter scope, so the
source and target attempt budgets reserve atomically. Before subject execution,
GuardControlchecks durable source/principal command quotas and charges the family in one transaction. A fairness denial never reaches the subject or allocates a dedupe row. A subject denial still spends its admitted command. The target window is anchored to its first reservation: after a successful login refunds the target to zero, the next attempt keeps the existing expiry. The legacy Lua script instead restarts a full window on that 0→1 increment. The store keeps the anchored window, which can return a fresh target allowance up to one window sooner after a refund. That is bounded and accepted, and it is covered by an engine expiry test. - A sequential RPC runs its steps in order and skips the rest after the first denial. Callers do not rely on snapshot consistency across partitions.
Selecting the backend
Once a family is assigned (listed in
ABUSE_GUARD_FAMILIES or in a native
Worker’s injected families), an incomplete or invalid configuration selects
off, never the legacy store. A missing or cleartext URL, a missing or short
secret, and a missing or short pepper all count. A fresh legacy store would
forget consumed replay fences and spent limits.
Native Workers call
configureAbuseGuardRuntime({ binding: env.ABUSE_GUARD, pepper, families }) in their adapter instead of the HTTP variables.
Meet already calls its adapter (apps/meet/src/runtime/abuse-guard.ts) on every
request. It stays on the legacy path until the binding exists. Activate it only
after tuturuuu-abuse-guard is published for that environment, because a service
binding to an unpublished Worker fails the Meet deploy. To activate it:
- Add
{ binding: 'ABUSE_GUARD', service: 'tuturuuu-abuse-guard', entrypoint: 'AbuseGuardEntrypoint' }toservicesinapps/meet/void.config.ts, or in the staging config with the staging Worker’s name. - Set the
ABUSE_GUARD_KEY_PEPPERsecret to the environment’s pepper. - Set
ABUSE_GUARD_FAMILIES=password.
password from
ABUSE_GUARD_FAMILIES, or set ABUSE_GUARD_BACKEND=upstash to deliberately select
the legacy store, then redeploy Meet. Removing only the binding does not roll back.
Meet runs with nodejs_compat, so the selector still reads ABUSE_GUARD_FAMILIES
from the Worker’s variables. The family stays assigned and fails closed (off), or
uses the HTTP store if one is configured. A missing or short pepper on an assigned
family also reports off. Switching stores or peppers starts every password
counter fresh. Drain the 15-minute window, by waiting it out after unassigning,
before trusting limits on the new store.
Every store failure throws AbuseGuardUnavailableError, and each caller applies
the fail mode below. That includes a timeout, an auth or configuration error, an
operator or budget stop, and an off selection. A sensitive family whose legacy
policy failed open fails closed once it is assigned. Otherwise exhausting the
store’s budget would switch its protection off.
The
proxy family never fails every request closed, since that would be a
platform-wide outage. While the store is unavailable it degrades to per-instance
limits. ABUSE_GUARD_BACKEND=off affects only assigned families; unassigned
families keep their legacy path.
Cost and denial shortcuts
-
Proxy: all of a caller’s windows are charged in one
win.hit, which is one command. A denial is remembered per instance until its window resets, so blocked repeats cost no store operations. While the store errors, is stopped or isoff, the same limits apply per instance as fixed windows, which is degraded enforcement rather than the store’s sliding windows. Healthy traffic does not seed those counters, so a store outage starts them at zero. -
Offline: on the guard store, the session windows and clearance are spent only
for an actor the proxy verified (
useVerifiedBearerActor). Unverified or rotating bearers cost one IP operation and get 401. Daily windows of 50,000 reads per IP and per session bound each source. A read costs at most four commands:- IP and session
win.hit; - the clearance read;
- a clearance write when a Turnstile proof is granted.
- IP and session
-
Password: a subject reservation denial is cached by source and target for
at most one second in a bounded 10,000-entry map. This suppresses immediate
repeated
res.reservecalls without treating a target denial as immutable: another successful login can refund the target. Expiry, eviction, concurrent requests and caller restarts can cause another probe; each such admitted probe spends the durable identity allowance below. -
Replay: once
replayis assigned, a token proven replayed outside its grace period is answered from memory until it expires. After a lost concurrent claim, the route rereads the winner’s timestamp and applies grace without caching. Each instance pre-limits refresh attempts to 20 per minute per principal and 60 per minute per IP before any store call; excess attempts get 429 withRetry-After: 60. - Skipped steps: every RPC conservatively reserves the whole requested batch, including skipped steps and interrupted execution. Family and identity command usage is never refunded. Reservations commit before subject execution and survive isolate replacement; subject operation IDs independently deduplicate mutations.
- Cache limits: every in-memory map (replay, denials, outage counters) holds at most 10,000 entries and evicts the oldest first. All of them are per instance, so restarts, new instances and eviction under pressure reset them. The zero-cost claims hold only while an entry stays resident; the store, or the fixed-window outage limiter, still enforces the limits.
Durable command fairness
replay and password RPCs require a purpose-hashed source identity. Replay also
requires the verified token principal. Password adds the verified user for Nova;
anonymous Meet has only a source. Trusted adapters supply these values after
credential and ingress validation, never from arbitrary body/header identity
claims. IPv4-mapped IPv6 is canonicalized; IPv6 shares a /64. Missing or invalid
source IPs share unknown, rather than bypassing source fairness.
Each source bucket (one IPv4 /32, IPv6 /64 or the shared unknown bucket)
and each available principal may spend at most
floor(family daily budget / 20) commands per UTC day, across all token keys,
targets, scopes, processes and Worker isolates. With the default 100,000-command
share this is 5,000 commands. Each command in a batch counts, including a denied
subject reserve, a replay GET/SET/reread, and a successful-login refund. Retries
with the same operation id preserve subject idempotency but conservatively spend
another admission allowance. There are no automatic retries. Limits are not
refunded after subject errors, timeout-after-admission or skipped steps.
The control object checks both quotas and the remaining family allowance, updates
both quotas, and records family usage in one transactionSync. A fairness denial
returns guard_limited (HTTP 429) without spending the family allowance, issuing
a subject RPC, allocating new quota/dedupe rows or stopping the family. Clients
treat it as unavailable and retain each caller’s assigned fail-closed response.
This is a command allowance, separate from the existing 15-minute password attempt
counters. An operator resume resets family usage but preserves identity quotas.
UTC rollover resets both; a burst across midnight can use two daily allowances.
Shares smaller than 20 commands admit no fair commands.
Worker denial shortcuts retain at most 10,000 exhausted identity digests until UTC
rollover. A cache hit makes no DO request. An evicted entry, new isolate or restart
always rechecks the durable quota; it never grants a new allowance. Expired quota
rows are purged in bounded batches, with a terminal control-object cleanup alarm.
A cleanup failure logs once and stops scheduling until a later admitted write;
lazy expiry still enforces rollover.
client.withFairness({ source, principal }) creates an immutable request-scoped
view and HMAC-hashes identities separately from storage keys. Offline/proxy can
reuse this protocol and admission path, but their current callers do not supply
it. Their existing activation holds remain open. Source caps intentionally apply
to shared NATs and the shared unknown bucket; measure legitimate fan-out and
resolve ingress gaps before assigning any family. This change does not establish
botnet/account-rotation fairness or guarantee availability for callers sharing an
exhausted source.
Activation checklist (per environment)
- Publish the Worker and run its staging canary (separate admission).
-
Proxy: measure staging and production API volume first. Size the
proxyshare ofABUSE_GUARD_FAMILY_BUDGETSso a normal day uses at most 50% of it. Do not assignproxywithout that measurement. -
Offline — gate (unmet): verified admission is not yet wired on every
marked-read path. Today the opt-in has gaps:
- Calendar and Infrastructure pass a verified actor only on their calendar-preference routes. HEAD preference requests miss the opt-in.
- Web marked reads have no verified-actor callback.
- Cookie-only and app-coordination-token sessions never produce a verified actor.
offline:- enumerate every hop that serves marked reads, and every credential type it accepts;
- wire or implement the verified-actor path for each;
- prove end to end that authenticated reads succeed and that forged or opaque bearers are denied;
- run a staging drill showing that admitted traffic from realistic source fan-out cannot exhaust the offline share. The per-source daily windows bound one source, not many.
-
Replay — R1-REPLAY-DRAIN CLOSED AS SCOPED: one verified principal or
one source cannot drain replay’s family command share across instances,
isolates, scopes or token keys. Atomic durable admission and the concurrency,
restart and cache-pressure evidence support this closure. It does not
prove that a family stop cannot deny every caller. HIGH activation hold
FAIR-R1-SYBIL-DRAIN remains OPEN: 20 accounts plus 20 sources can consume
the share. Do not assign
replayuntil the successor fairness holds are resolved or receive an authorized scoped risk disposition. - Password — PW-R1-DRAIN CLOSED AS SCOPED: one source or one verified Nova user cannot drain password’s family command share. Denied subject probes and successful refunds count; the bounded one-second denial cache suppresses repeated reservations. Anonymous Meet has only the source axis. HIGH activation hold FAIR-R1-SYBIL-DRAIN remains OPEN: rotating source prefixes can still drain the share and deny all Meet and Nova password checks. The closure does not establish one-actor fairness or family-wide availability. Both families remain unassigned. The successor HIGH/P2 holds below and the existing hosted stop/resume, ingress identity, endpoint-to-store end-to-end admission, request-billing and full-day hosted-drill holds remain open. Local tests and independent source review do not establish hosted acceptance.
-
Replay cutover: fence keys consumed before cutover live only in the legacy
store, and CLI refresh tokens live 90 days. Choose one:
- Read-through. Stop every writer to the legacy fence keys first, so the
legacy store is frozen. Keep
UPSTASH_REDIS_REST_*configured and setABUSE_GUARD_REPLAY_LEGACY_UNTILto the cutover time plus 90 days, as a full ISO-8601 timestamp. A key present in the legacy store counts as used, and a legacy read error fails closed. A malformed deadline, or a future deadline with either credential missing, makes the replay store unavailable, so the routes return 503 instead of skipping the check. - Generation bump. If no legacy store is configured, rotate the refresh-token signing generation at cutover, so tokens issued before it no longer verify. Production had no Upstash, so its fences were effectively off and this applies there. Users re-authenticate once.
- Rollback: unassigning
replayreturns the routes to the legacy path, which does not know fences recorded in the guard store. Either bump the generation on rollback, or keep the guard store readable until its tokens expire.
- Read-through. Stop every writer to the legacy fence keys first, so the
legacy store is frozen. Keep
- Alerting is live (see Cost safety) and the operator stop has been exercised in staging.
Fairness activation holds
- HIGH — FAIR-R1-SYBIL-DRAIN (OPEN). Each identity receives
budget / 20, so 20 independent identities can exhaust a family. Anonymous Meet password admission is source-only: one actor rotating 20 IPv6 /64s within one /48 can spend 5,000 commands per /64. At approximately 5 requests/second in aggregate, the default 100,000-command password share can be drained in about 5.6 hours, then its family stop denies every Meet and Nova password check until rollover or operator intervention. Replay needs 20 verified accounts plus 20 sources. This is source arithmetic, not a measured attack or hosted load result. The planned follow-up is aggregate IPv6 /48 or /56 and IPv4 /24 tiers checked atomically with the existing quotas/family spend, and/or a graceful family stop with a known-good reserve. Prove a prefix-rotation drill before activation. Neither aggregate tiers nor a reserve is implemented by this change. - P2 — FAIR-R1-SHARED-SOURCE-LOCKOUT (OPEN). The fixed 5% daily cap also
locks out legitimate shared sources: an office NAT, mobile CGNAT address or
every caller when ingress resolves to
unknown. At the default replay share, 5,000 commands allow about 2,500 two-command GET/claim refreshes per day across all users behind that source; actual costs vary by route and grace/reread path. A caller sharing the source can spend this allowance and deny its neighbors until UTC midnight. Operator resume preserves identity quotas, and there is currently no audited fairness reset or configurable per-axis share. Before activation, add configurable source/principal shares (including a larger source allowance for verified-principal families), an audited per-identity/per-family fairness reset and fairness-denial alerts. Measure real per-source peaks and the unknown-source fraction; unresolved ingress coverage remains a blocker. - P2 — FAIR-R1-CONTROL-HOTSPOT (OPEN capacity hold). Every guarded request
now goes through the singleton global
control:v1Durable Object for durable admission; fair requests also check per-identity quotas there. Thefence.tsrefresh path was removed, so every request reaches the global control object and its serialized storage work. All families share this capacity; a high request rate or fresh-identity flood can make every family unavailable with 503s and concentrates storage cost on one object. Separate family budgets do not isolate this capacity. Keep every family unassigned until a load test characterizes the global hotspot and cost, a sharding design preserves cross-family stop and quota atomicity, and the HIGH Sybil drain hold above is addressed. Kill-switch authority stays with designated operational roles. - P3 — FAIR-R1-PEPPER-SCOPE (OPEN). Quotas are scoped by
ABUSE_GUARD_KEY_PEPPER. Different peppers on Web, Meet or Nova create separate allowances for the same source/principal. All callers sharing a family and environment must use the same pepper; a dedicated shared fairness pepper would require follow-up implementation. Rotating the current pepper resets all quotas immediately, including mid-day, as well as the other keyed state. Treat rotation as an explicit reset requiring acceptance, not a routine lockout remedy.
- A batch larger than the remaining family allowance can return
guard_stoppedwhile the durable family is not stopped. Do not infer the stored stop flag from that response alone; inspect control state. This is a telemetry limitation. - Control cleanup purges at most 1,000 quota rows per wake and re-arms after one second when expired rows remain. The loop is bounded and terminal, but alarm requests, row deletes and scheduling operations belong in the request-cost envelope below.
- A stolen, spent but unexpired refresh token can charge the victim principal’s quota before a local replay-cache hit suppresses another lookup. For a token proven replayed outside grace, this is normally one command per instance; eviction/restart can add probes. This is a low-impact residual, not a token-mint bypass or evidence of family-wide availability.
Cost safety
- Bounds: the limits above are enforced on the client and again in the Worker before any Durable Object call. Each RPC makes at most one subject call per step. There are no retries anywhere.
- Stop fence:
POST /control/stopwithAuthorization: Bearer $ABUSE_GUARD_OPERATOR_TOKENpersists a stop. Every RPC then returns 503guard_stoppedbefore any subject call. Stopped requests spend no subject commands, but Worker/control requests remain billable. Every admitted RPC consults the durable fence before executing subjects.POST /control/resumeclears it, andGET /control/statereads it. Stored data is preserved. - Budget: each family has its own daily allowance and budget-stop flag.
Exhausting one allowance does not set another family’s flag, but the shared
control DO can still make other families’ admissions unavailable under overload
(FAIR-R1-CONTROL-HOTSPOT). Unauthenticated marked-bulk traffic spends only
offline’s share. All RPCs reserve commands durably before subject execution, without isolate buffering or overshoot. At 80% of a family’s budget the control object logs a warning once. When a family exhausts its budget, the control object persists a stop for that family only. The stop survives restarts and clears at the next UTC day, so the daily command cap holds without one flood becoming an open-ended outage. The cap applies per calendar UTC day, not per rolling 24 hours, so a burst across UTC midnight can admit up to about twice a family’s budget within a short span. An operator resume clears it earlier. Admission rejections spend no commands; admitted subject denials do count. No RPC can reserve beyond the remaining family command allowance. Each caller of a stopped family applies its own fail policy. An invalid budget configuration makes every RPC return 503guard_unavailable, the same as a bad secret. That covers a malformed total, an unknown or duplicate family, a malformed value, or entries above the total. - Alerting: alert on the
daily operation budget atwarning and thedaily operation budget exhaustederror, filtered per family. A stop at 10:00 GMT+7 lasts until 07:00 GMT+7, the next UTC midnight, unless an operator first checksGET /control/stateand then resumes or stops globally. - Subject alarm failures: a failed cleanup is logged and re-armed with capped exponential backoff (1, 2, 4, 8 minutes). After five consecutive failures it stops until the next write; lazy expiry keeps reads correct meanwhile.
- Alarms: each partition keeps one alarm at its latest expiry, moved only when
a write extends it by more than 60 seconds. Cleanup purges at most 1,000 rows per
wake. An empty partition is wiped with
deleteAll()and stores nothing. A still-warm instance recreates its tables before its next use, and noabort()is needed. It is never re-armed; otherwise it re-arms once at a future expiry. Cleanup makes no network calls, and it logs errors instead of throwing so automatic alarm retries cannot loop. Without new writes, a partition’s alarms end within its longest TTL. Control quota cleanup instead purges at most 1,000 rows per wake and re-arms after one second while expired rows remain. Count every such wake, delete and scheduling operation in the cost envelope; a bounded terminal loop is not free. - Ingress: when the owner attaches a hostname, add a Cloudflare WAF rate-limit
rule for it, such as per-IP requests to
/v1/rpcand/control/*. It sheds unauthenticated floods before Worker billing. Family budgets cap admitted subject commands; total request billing remains an activation hold. - Accounting limits / open request-cost hold: the budget counts subject commands, not all billed operations. A fair admission cache miss makes a control DO request, even when rejected. Cache eviction, restarts and multiple instances multiply those probes; their request billing has no demonstrated total cap. Denied probes spend no subject commands, but must not be described as free or protected by a hard dollar cap. A family-command quota cannot cap an unbounded stream of Worker or control-DO requests. Admission/WAF sizing and a separately accepted request-cost envelope remain activation holds. Each accepted fair RPC writes up to two quota rows plus the control state, in addition to subject/dedupe rows, deletes and alarm operations. Each accepted non-fair RPC also makes one control request and durably writes the reservation before executing subjects. Stops are checked on every admission. Storage is bounded by 2 KiB × 120 days × the daily budget. See the adoption guidance.
Pricing envelope
Workers Paid pricing, checked 26 October 2026:
Idle Durable Objects are not billed for duration.
The
proxy family (every API request) has no measured volume and stays off until one exists.
Local verification
src/engine.test.ts runs the command engine over node:sqlite with a
controlled clock. It covers windows, NX/XX semantics, expiry, reservation parity,
cleanup planning and row-write counts. src/worker.test.ts bundles the Worker with
esbuild and runs it in workerd through Miniflare. It covers auth rejection with
zero admitted work, secret rotation, a 50-way NX race, clearance expiry,
sequential windows, the service-binding entrypoint, the operator stop fence, and
a budget stop that persists across a restart. src/fairness.test.ts uses two
Worker isolates sharing real DO namespaces to exercise both identity axes,
concurrent admissions, denial probes, caller/runtime restarts, successful refunds
and unrelated caller success. src/fair-admission.test.ts covers UTC rollover,
batch accounting and bounded denial-cache pressure. Focused negative controls
remove principal fairness, source fairness and denial caching separately; their
corresponding assertions must fail before the original source is restored. Client behavior (timeouts, no
retries, family gating, configuration validation) lives in
packages/utils/src/abuse-guard/__tests__/.
The real-store password cases call the shared limiter through real HTTP and
service-binding transports. Meet/Nova endpoint and entrypoint suites use fixtures;
they do not prove an end-to-end request through the deployed app and hosted store.
Local Durable Objects do not prove hosted placement, latency or billing. Before
production, the admitted staging publication must record each of these:
- p50/p95/p99 latency from
sin1, measured with a bounded number of RPCs; - a hosted stop and resume;
- the observed request and row-write counts.