Summary, flow, files and invariants for the public progress API. Client-facing behavior: api.md.
Summary. External clients (TarkovMonitor, RatScanner, tarkov.dev) read and write user progress
through the api-gateway Worker on api.tarkovtracker.org. A progress read touches several
layers; this section is the canonical map of those layers so a failure can be located quickly
instead of guessed at. Rate-limit ownership details live in
rate-limiting.md; client-facing quota docs live in
api.md.
sequenceDiagram
participant C as Client (Bearer token)
participant W as api-gateway Worker
participant AG as IP abuse gate (CF RateLimit binding)
participant DO as ApiGatewayRateLimiter DO (daily quota)
participant SB as Supabase
participant TD as json.tarkov.dev (1h memory cache)
C->>W: GET /progress (User-Agent + Authorization)
W->>AG: limit({ key: "api:{ip}" })
alt abuse gate denies
W-->>C: 429 + Retry-After (pre-auth, no X-RateLimit-*)
end
W->>SB: api_tokens lookup (token hash)
W->>SB: supporter_entitlements tier (60s cache)
W->>DO: daily-read:{user_id} (utc-day anchor, retain)
alt DO unavailable
W-->>C: 200 or 304 (fail open, no X-RateLimit-* headers)
else daily quota denied
W-->>C: 429 + Retry-After + X-RateLimit-*
end
W->>SB: user_game_mode_progress select (token mode + active season)
W->>SB: user_progress select (account game edition)
W->>TD: tasks + hideout metadata (cached)
W->>W: transform + invalidation + hideout auto-complete
alt If-None-Match matches payload ETag
W-->>C: 304 Not Modified (empty body)
else no acceptable Accept-Encoding
W-->>C: 406 no_acceptable_encoding
else
W-->>C: 200 JSON (weak ETag, private max-age=15, gzip if accepted)
end
- Routing + User-Agent gate.
src/router.tsnormalizes the path, enforces the api host boundary (non-api host requests outside/healthreturn 404, while loopback hosts such aslocalhostand127.0.0.1are admitted for local development), and rejects protected endpoint requests without a 5–200 characterUser-Agent; infrastructure routes are exempt.wrangler.tomlbinds onlyapi.tarkovtracker.org/*; retired apex/www addresses are answered by TarkovTracker Pages with a static 410 and never reach this Worker. - Pre-auth abuse gate. A Cloudflare Workers Rate Limiting binding (
API_ABUSE_LIMITER) keys onCF-Connecting-IPand shields theapi_tokenslookup from token-rotation floods. It is infrastructure protection, not a customer quota, and fails open on binding errors. - Token auth.
src/auth.tsvalidates the bearer token againstapi_tokensby hash and checks the permission (GP/TP/WP). After successful validation, the request'sExecutionContext.waitUntilretains the entire lifetime-accounting operation, including any permitted fallback, without blocking the response. This attempts one atomicincrement_token_usageRPC before daily-quota enforcement, so valid tokens count even when later throttled or rejected by input validation. Invalid, inactive, expired, mode-mismatched, or permission-denied tokens do not count. Calls without a Worker execution context await accounting rather than leaving an unowned promise. Each accounting request has a three-second timeout covering headers and body handling; only a bounded error body (at most 1 KiB) is parsed. HTTP 404 with PostgREST codePGRST202confirms a missing RPC and permits one timestamp-only PATCH. That fallback does not recover the lostusage_countincrement. Network errors, timeouts, authorization errors, other HTTP failures, and malformed/oversized missing-RPC responses never trigger an increment retry or fallback: the original increment may already have committed. Failures and timestamp-only outcomes emittoken_usage_outcomewith a fixed operation/outcome and optional HTTP status, without tokens, keys, user payloads, response bodies, or exception messages. Accounting stays best effort:waitUntilis bounded by the runtime and does not provide durable or exactly-once delivery. - Tier + daily quota.
resolveTierreadspublic.supporter_entitlements(falling back topublic.supportersonly when that view is missing; cached up to 60s), then a singleApiGatewayRateLimiterDurable Object call (daily-{kind}:{user_id}, UTC-day anchor, retained) admits or denies the request. The quota counts admitted requests — downstream Supabase failures do not trigger refunds. The daily quota fails open on DO unavailability. - Data fetch.
src/handlers/progress.tsselects the token mode'sprogress_datafromuser_game_mode_progressusing the mode's season number, selects only account-widegame_editionfromuser_progress, resolves a display-name fallback from Supabase Auth (24h cache). This optional lookup uses the existing three-second Supabase request deadline, including body parsing; a timeout preserves the user-ID display fallback and is not cached. It loads matching tasks/hideout metadata fromjson.tarkov.devviasrc/services/tarkov.ts(1h memory cache). New isolates first try a versioned, per-colo Cache API snapshot of the normalized public tasks/hideout data. A snapshot retains its original absolute expiry; restoring it never extends the one-hour freshness window. Missing, malformed, expired or unavailable cache entries fall back to the required upstream fetch. Only successfully normalized public metadata is stored; tokens, player progress, permissions, quotas and response bodies never enter this cache. Task snapshots rebuild their immutable prepared graph when restored. Bump the cache version insrc/services/catalog-cache.tswhen changing the normalized format or its interpretation. Concurrent misses share one fetch/normalization per resource and upstream mode within an isolate. The originating request retains that operation withwaitUntil; streams are consumed there and only parsed public catalog data is shared. Every settled operation leaves the in-flight map; failures remain uncached and retryable, with the existing 30-second fetch timeout. Missing, empty, or structurally malformed required catalogs return 503 with the ordinary error envelope and any available quota headers. Progress and team reads do not return partial derived state or ETags on this path. Single and batch task writes await validated task rules before applying transitions or calling the progress merge RPC; level and objective patches do not evaluate task rules and retain their existing behavior. Expired catalogs are not used as a last-good fallback. - Transform.
src/utils/transform.tsconverts the JSONB objects into the public array format, applies invalidation (@tarkovtracker/progress-contracts/progressInvalidation, the same algorithm the app uses) and game-edition hideout auto-completes. Browser and Worker catalogs compile completion-triggered failure edges through@tarkovtracker/progress-contracts/taskFailureEdges. The existing internalalternativesprojection remains compatible with repair/action consumers; it is derived fromfailConditions, not upstreamalternatives. Missing references do not create edges, and a reverse edge is inferred only for the existing active-only requirement pattern backed by an explicit completion failure. Gateway task catalogs are frozen and registered with a prepared dependency graph once per catalog snapshot. Expiry replaces the catalog and graph together. Each player still gets fresh invalidation state. The app's mutable entry point rebuilds its graph so in-place metadata edits remain visible. - Conditional response.
conditionalReadResponseinsrc/responses.tsserializes once, derives a weakETagfrom the payload, answers304on a matchingIf-None-Match, and setsCache-Control: private, max-age=15plusVary: Accept-Encoding, Authorization, Origin. Bodies ≥1 KiB are gzipped when the client accepts gzip; an explicitgzip;q=0is honored as a rejection,identity;q=0bypasses the size threshold, and a client that refuses every available coding gets406. - Usage accounting.
src/services/usage.tsrecords the read/write (and throttle flag) inpublic.api_usage_dailyviarecord_api_usage, off the response path.
src/index.ts— Worker entrypoint; delegates to the modules belowsrc/router.ts— path normalization, User-Agent gate, API host boundary enforcement, route dispatchsrc/authentication.ts— abuse gate, token auth, daily-quota enforcementsrc/rateLimiter.ts—ApiGatewayRateLimiterDurable Object + quota clientsrc/responses.ts— CORS, envelopes, conditional response, ETag/compressionsrc/auth.ts— token validationsrc/handlers/progress.ts,src/handlers/team.ts— progress reads/writessrc/services/supporter.ts,src/services/usage.ts,src/services/tarkov.tssrc/utils/transform.ts@tarkovtracker/progress-contracts/progressInvalidation— runtime-independent task/objective invalidation (faction, failed-only and failed prerequisites,failed-tolerant requirements), shared by the app progress store, public profile/streamer views, and the Worker transform@tarkovtracker/progress-contracts/requirementStatus— runtime-independent task-requirement status predicates, shared by invalidation, app task actions, failed-state repair, and the Worker@tarkovtracker/progress-contracts/taskTransitions— runtime-independent explicit task-state transitions (dependent lock/unlock) used by Worker task writes@tarkovtracker/progress-contracts/userMetadata— runtime-independent provider metadata parsing, shared with app user hydration through@tarkovtracker/progress-contracts/userMetadatain Nuxt and the Workersrc/utils/user-display-name.ts— cached Auth metadata lookup shared by personal and team API progress- rate-limiting.md, api.md — ownership map and client-facing docs
- App user hydration, personal API progress, and team API progress share provider metadata fallback
ordering. Both API consumers use the same per-user display-name cache, so either endpoint must
resolve the same metadata before populating it. The app and API share one invalidation algorithm:
a requirement whose
statusincludesfailednever invalidates its task when the prerequisite is failed. Shared utilities must not import Nuxt or Worker runtime modules; invalidation logic must not be re-implemented per runtime. - Gateway task catalogs are frozen with one prepared dependency graph per snapshot. Catalog expiry replaces the catalog and graph together.
- Each player receives fresh invalidation state; mutable player state is never shared between requests through the catalog or prepared graph.
- Completed and failed tasks (including legacy records with both flags set) and their objectives are never marked invalid. This terminal-state guard applies to faction, prerequisite, and legacy alternative entry points. Completed tasks stop propagation; failed tasks still invalidate strict dependents, but not dependents whose requirements accept failure. Faction mismatch alone does not cascade.
- A request makes at most one Durable Object call (the daily quota). There is no burst bucket, no IP backstop bucket, and no refund reconciliation; reintroducing any of those is a regression.
- The daily quota fails open on DO unavailability (logs
daily_quota_unavailable); the pre-auth abuse gate fails open on binding errors (logsabuse_gate_unavailable). Neither is recorded as a throttle inapi_usage_daily. - The daily quota counts admitted requests, not successful responses. A Supabase 500 after admission consumes one slot — no refund system exists or is needed.
- Invalid task/objective states return 400 with rate-limit headers after authentication and quota checks. Error messages preserve scalar values but label arrays and objects without invoking client-controlled string coercion, which could otherwise turn malformed input into a 500.
- Task/objective JSON write bodies are streamed with a 256 KiB actual-byte ceiling before JSON
decoding. Oversized bodies return 413 with CORS headers. Quota headers are included when the
quota service is available. The ceiling is independent of
Content-Length. Batch arrays/maps allow at most 1000 entries; array duplicates count before transitions. IDs are nonblank and at most 64 characters, including padding before URL trimming. All requested task/objective IDs must belong to the token mode's current catalog before progress reads, transitions or persistence; stored unknown IDs never become an allowlist. A mixed known/unknown batch fails as a whole. Objective writes now also fail with 503 when that catalog is unavailable. These HTTP guards live in the Worker; shared pure transition rules and database migrations stay unchanged. - Personal progress reads select one exact
(user_id, game_mode, season_number)row and account metadata; team reads select that same mode and season for each member. Neither reads another mode's progress or usesselect=*. - Read responses derive the
ETagfrom the serialized payload (notupdated_at), so a304can never hide a change that came from task metadata or invalidation rather than the user's row. - Read responses are
private(token-scoped) — no shared/edge caching of authenticated progress. - The ETag digest and the gzip decision both derive from the same serialized UTF-8 payload bytes,
so the validator and the payload can never disagree. The wire body is those bytes uncompressed,
or a
CompressionStream('gzip')over them when gzip is negotiated — the ETag always represents the uncompressed entity, keyed byVary: Accept-Encoding. - gzip is applied when the client accepts it and the payload clears the 1 KiB size threshold, or
when the client accepts gzip but explicitly refused identity (
identity;q=0), in which case uncompressed is not an acceptable response and the threshold is bypassed. If no acceptable encoding exists the gateway returns406 no_acceptable_encoding.