Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
Replace the per-Vercel-instance in-memory Map rate-limit cache with an
atomic shared store backed by the existing Turso RateLimit table, so limits
hold across all instances/redeploys and cannot be bypassed by distributing
brute-force attempts across instances (audit finding p8-010, MEDIUM).
- checkRateLimit now performs a single atomic round-trip:
INSERT ... ON CONFLICT(identifier) DO UPDATE ... RETURNING count, reset_at
with window-reset semantics (CASE WHEN reset_at < now THEN 1 ELSE count+1).
- The DB is now the primary source of truth (no longer a fire-and-forget
fallback). The per-instance Map is reduced to a short-TTL local cache used
ONLY to fast-fail already-blocked identifiers (cuts DB load during brute-
force storms); it can never let a request bypass the limit.
- ensureRateLimitSchema() creates the table + a UNIQUE identifier index so
ON CONFLICT upserts are well-defined; added RateLimit to db/create.ts.
- resetLoginRateLimits / clearRateLimitStore invalidate the local cache.
- getClientIP now trusts proxy headers in non-development environments
(production + test); local dev stays strict against header spoofing.
- bunfig.toml defines import.meta.env.SSR=true so the server-only env guard
loads under 'bun test'.
- Tests: await clearRateLimitStore in beforeEach (fixes a race where an
un-awaited clear let leftover rows corrupt the next upsert); unique test
identifiers; realistic remote-shared-store perf bounds; new p8-010
distributed-store tests (restart-survival, multi-instance aggregation,
no bypass by alternating instances).