Skip to content

perf(redis): shield residual dashboard read egress#5263

Merged
koala73 merged 5 commits into
mainfrom
codex/issue-5259-redis-egress
Jul 13, 2026
Merged

perf(redis): shield residual dashboard read egress#5263
koala73 merged 5 commits into
mainfrom
codex/issue-5259-redis-egress

Conversation

@koala73

@koala73 koala73 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

Dashboard refreshes can now reuse shared CDN responses for the news digest and displacement summary through explicit caller-invariant public=1 URLs. Their legacy URLs remain credential-gated and non-shared, including when callers attach credentials to the public URL.

Wildfire bootstrap hydration now reads a seed-time compact payload instead of paying Redis egress for the full canonical detection set and trimming it after the read. The canonical wildfire key remains intact for RPC and analytical consumers.

Signal Before This PR / validation
Redis GET egress ~64 GB/day rate <=17 GB/day target requires post-deploy measurement
Wildfire bootstrap Full 79 KB-1.36 MB canonical read Dedicated payload capped at 500 highest-signal detections
News + displacement refreshes Repeated origin reads Explicit CDN cache keys; deployed MISS -> HIT proof pending

Related: #5259

Test plan

  • npm run build:full
  • npm run test:data - 15,225 passed, 0 failed, 6 skipped
  • npm run typecheck
  • npm run typecheck:api
  • node --test tests/edge-functions.test.mjs - 228 passed
  • 123 focused bootstrap, gateway, public-RPC, wildfire, and session tests
  • Pre-push gate passed, including boundary, safe-HTML, rate-limit, premium-fetch, edge-bundle, handler, changed-test, and version checks

Post-Deploy Monitoring & Validation

  1. Before the web rollout depends on it, confirm the Railway seeder has populated wildfire:fires-bootstrap:v1 with a healthy envelope and no more than 500 detections.
  2. During a representative peak window, probe each public URL twice from an allowed origin and confirm x-vercel-cache transitions from MISS to HIT while CDN-Cache-Control remains present:
    • /api/news/v1/list-feed-digest?variant=full&lang=en&public=1
    • /api/displacement/v1/get-displacement-summary?flow_limit=50&public=1
  3. Confirm anonymous legacy URLs remain rejected and credentialed legacy URLs remain non-shared. Also confirm credentials attached to the explicit public URLs do not change their response or cache posture.
  4. Run a 300-second Upstash MONITOR sample at peak, normalize health-sweep and EVALSHA noise, and verify bootstrap no longer reads wildfire:fires:v1 while the two RPC origin-read counts collapse behind CDN hits.
  5. Measure a full UTC day. Keep perf(redis): residual ~64 GB/day egress after #5250 — still ~4x the 500GB/mo plan; oversized bootstrap tier payloads (24 GB/d) + unshielded RPC routes (32 GB/d) #5259 open unless sustained Redis GET egress is <=17 GB/day; if it remains above budget, use the measured long tail to select the next smaller-bundle or longer-safe-TTL lever.

Rollback by reverting this commit. If deployment ordering is the only failure, hold the web rollout until the compact wildfire key exists rather than restoring the oversized bootstrap read.


Compound Engineering
GPT-5

@strix-security

strix-security Bot commented Jul 13, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 72b21fa.


Reviewed by Strix
Re-run review · Configure security review settings

@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 13, 2026 9:46am

Request Review

@mintlify

mintlify Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jul 13, 2026, 6:38 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR reduces Redis GET egress on two fronts: the wildfire bootstrap hydration now reads a pre-compacted 500-detection key (wildfire:fires-bootstrap:v1) written atomically by the seeder, and the news-digest and displacement-summary dashboard refreshes are routed through an explicit ?public=1 URL shape that gives Vercel's CDN a separate, credential-free cache key.

  • Wildfire bootstrap key split: the seeder's extraKeys mechanism writes a compact payload alongside the canonical key; the canonical wildfire:fires:v1 remains intact for RPC and analytical consumers. Both api/bootstrap.js and server/_shared/cache-keys.ts are updated in sync to point at the new key.
  • Public CDN paths: src/shared/public-rpc-cache.ts adds a tight allowlist consumed by both the gateway auth bypass and the wm-session interceptor; publicRpcFetch strips credentials and adds ?public=1 before calling native fetch.
  • Shared wildfire utility: detection sorting/compaction helpers are extracted into api/_wildfire-dashboard.js to avoid duplication across the seeder, bootstrap edge function, and the RPC handler.

Confidence Score: 3/5

The CDN-routing and session-bypass logic is well-validated; the main concern is the seeder — the new wildfire:fires-bootstrap:v1 key has no seed-meta write, so the health system cannot detect stale or missing bootstrap data, and the dashboard will silently show no wildfire detections if the seeder has not yet populated the key before the web rollout.

The missing metaKey on the bootstrap extra key is the critical path risk: the app now bootstraps from wildfire:fires-bootstrap:v1, the seeder writes it via extraKeys, but no corresponding seed-meta is emitted. If the seeder runs and the payload is empty or malformed, there is no automated health signal to catch it.

scripts/seed-fire-detections.mjs — the extraKeys entry for wildfire:fires-bootstrap:v1 needs a metaKey so health monitoring covers the new bootstrap key.

Important Files Changed

Filename Overview
scripts/seed-fire-detections.mjs Adds extraKeys to write the compact wildfire:fires-bootstrap:v1 key alongside the canonical key, but does not supply metaKey, leaving the new bootstrap key without a seed-meta health-monitoring write.
api/bootstrap.js Switches wildfire bootstrap read key to wildfire:fires-bootstrap:v1 and delegates compaction to the new shared module; behavior preserved via re-export alias.
src/shared/public-rpc-cache.ts New module defining the allowlist and parameter-validation logic for the two CDN-shareable public RPC paths; validation covers enum allowlists, bounded integers, and single-value guards.
src/services/public-rpc-fetch.ts New fetch wrapper that adds public=1 marker and strips credential headers; used as raw transport for DisplacementServiceClient, creating tight coupling to the allowlist.
server/gateway.ts Extends isPublicNoAuthRpc to also match isPublicSharedRpcRequest; narrow URL-shape validation ensures only the two validated public paths skip auth.
src/services/wm-session.ts Extends the session-interceptor bypass to include isPublicSharedRpcRequest; correctly gated on credentials: 'omit'.
src/services/displacement/index.ts Replaces the inline fetch wrapper with publicRpcFetch as the client transport; works for the single getDisplacementSummary call but couples the whole client to the public-RPC allowlist.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant WmSessionInterceptor
    participant VercelCDN
    participant Gateway
    participant Redis

    Note over Browser,Redis: Dashboard refresh (news/displacement)
    Browser->>WmSessionInterceptor: "fetch list-feed-digest?variant=full&lang=en"
    WmSessionInterceptor->>WmSessionInterceptor: "publicRpcFetch adds public=1, strips credentials"
    WmSessionInterceptor->>VercelCDN: "GET with public=1 credentials omit"
    alt CDN HIT
        VercelCDN-->>Browser: 200 cached no Redis read
    else CDN MISS
        VercelCDN->>Gateway: forward request
        Gateway->>Gateway: isPublicSharedRpcRequest skips auth
        Gateway->>Redis: read cache
        Redis-->>Gateway: payload
        Gateway-->>VercelCDN: 200 plus CDN-Cache-Control s-maxage
        VercelCDN-->>Browser: 200 cached for next caller
    end

    Note over Browser,Redis: Wildfire bootstrap
    Browser->>Gateway: GET /api/bootstrap
    Gateway->>Redis: GET wildfire:fires-bootstrap:v1
    Redis-->>Gateway: up to 500 detections
    Gateway-->>Browser: bootstrap payload

    Note over Redis: Railway seeder cron
    Redis->>Redis: write wildfire:fires:v1 canonical
    Redis->>Redis: write wildfire:fires-bootstrap:v1 compact top-500
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant WmSessionInterceptor
    participant VercelCDN
    participant Gateway
    participant Redis

    Note over Browser,Redis: Dashboard refresh (news/displacement)
    Browser->>WmSessionInterceptor: "fetch list-feed-digest?variant=full&lang=en"
    WmSessionInterceptor->>WmSessionInterceptor: "publicRpcFetch adds public=1, strips credentials"
    WmSessionInterceptor->>VercelCDN: "GET with public=1 credentials omit"
    alt CDN HIT
        VercelCDN-->>Browser: 200 cached no Redis read
    else CDN MISS
        VercelCDN->>Gateway: forward request
        Gateway->>Gateway: isPublicSharedRpcRequest skips auth
        Gateway->>Redis: read cache
        Redis-->>Gateway: payload
        Gateway-->>VercelCDN: 200 plus CDN-Cache-Control s-maxage
        VercelCDN-->>Browser: 200 cached for next caller
    end

    Note over Browser,Redis: Wildfire bootstrap
    Browser->>Gateway: GET /api/bootstrap
    Gateway->>Redis: GET wildfire:fires-bootstrap:v1
    Redis-->>Gateway: up to 500 detections
    Gateway-->>Browser: bootstrap payload

    Note over Redis: Railway seeder cron
    Redis->>Redis: write wildfire:fires:v1 canonical
    Redis->>Redis: write wildfire:fires-bootstrap:v1 compact top-500
Loading

Reviews (1): Last reviewed commit: "fix(ci): refresh generated docs stats" | Re-trigger Greptile

Comment thread scripts/seed-fire-detections.mjs
Comment thread src/services/displacement/index.ts Outdated

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head f48d9be14fd82fc331739be44f57b54dfdffaca2. CI fully green. I filed #5259, so this is a review against the intent of that issue.

What's right (verified, not assumed)

  • Both routes really are caller-invariant. list-feed-digest.ts and get-displacement-summary.ts contain zero ctx./userId/entitlement branching (the tier hits in the digest are source tiers, not user tiers). The central premise holds.
  • No entitlement bypass. Neither path is in ENDPOINT_ENTITLEMENTS (getRequiredTier → null) nor PREMIUM_RPC_PATHS, so setting isPublicNoAuthRpc doesn't unlock anything paid.
  • Rate limiting still applies — the limiter block is gated only on !internalMcpVerified, so public URLs still hit checkRateLimit.
  • Classifying the public URL identically regardless of attached credentials is correct, not a bug — a Vercel cache hit precedes the handler. Same contract as #5250, and publicRpcFetch strips credential headers + sets credentials: 'omit' anyway.
  • runSeed genuinely honors extraKeys[].transform (_seed-utils.mjs:1617), so the compact key really is compacted — I checked, because a silent no-op there would have made the whole wildfire change worthless.
  • Deploy ordering is safe: src/services/wildfires/index.ts:50-52 falls back to the RPC when bootstrap lacks wildfires, so the window before the seeder publishes degrades rather than breaks.
  • Dropping cache: 'no-cache' from the digest fetch is required, not incidental — it would have forced revalidation and defeated the CDN.

🔴 BLOCKER — the displacement public URL is far too permissive

src/shared/public-rpc-cache.ts:38-48 admits, credential-free:

  • year — any integer 1950–2100
  • country_limit1–500
  • flow_limit1–500
  • all three optional

The client only ever emits ?flow_limit=50&public=1 (src/services/displacement/index.ts:117-121 sends year: 0, countryLimit: 0, and the generated client omits zero values — service_client.ts:138-140). So the accepted shape space is enormously wider than anything we use, and it creates two problems on the exact resource #5259 is trying to protect:

1. CDN-defeating cache-key fan-out. country_limit × flow_limit alone = ~251k distinct public URLs that all resolve to the same 713 KB displacement:summary:v1:2026 read. Every distinct shape is a MISS → origin → full-key Redis read. At the generic per-IP limit (GLOBAL_RATE_LIMIT = 600), a single unauthenticated IP that varies flow_limit never gets a cache hit: 600/min × 713 KB ≈ 616 GB/day of Redis egress from one IP — several times the entire monthly plan, in a day.

2. Attacker-chosen year reaches a live upstream fetch. The server parses year straight from the query (service_server.ts:150), and on a seed miss the handler falls through to cachedFetchJsonfetchUnhcrYearItems(requestYear) (get-displacement-summary.ts:182-192) — a paginated crawl against api.unhcr.org with an attacker-supplied year. 151 unseeded years are reachable with no credential at all.

Neither is a brand-new capability in the strictest sense (a freely-mintable wms_ session could already reach the legacy URL), but this PR removes the last credential hop and, more importantly, passes up the chance to close it. The whole point of an explicit public URL is that repeats become free — that only works if the shape is pinned.

#5250 set the right precedent: exactly two fixed shapes (tier=fast|slow). Please do the same here — restrict the displacement public shape to what the client actually sends and drop year / country_limit from the public surface entirely:

const DISPLACEMENT_QUERY_KEYS = new Set(['flow_limit', 'public']);
// and require flow_limit === '50' (or omit it), nothing else

That collapses the public key space to 1, makes every repeat a CDN hit, and makes the surface strictly safer than today. The credentialed legacy URL keeps serving any other shape, unchanged. (News is fine as-is: 6 variants × 25 langs = 150 bounded keys.)


🟡 P2 — list-fire-detections still pays the 1.36 MB read it no longer needs

SEED_CACHE_KEY stays wildfire:fires:v1 (list-fire-detections.ts:16), so the RPC still pulls the full canonical (up to 1.36 MB at peak) and trims after. But the handler already caps every response at 500 with no paginationlimitFireDetectionsForDashboard(rawDetections) with the default limit, nextCursor: '' always.

Reading wildfire:fires-bootstrap:v1 instead would return a byte-identical response: compactWildfireDashboardPayload preserves pagination.totalCount (the pre-trim count), and when detections ≤ 500 it returns the payload untouched. So the compact-key path yields the same fireDetections, the same totalCount, the same fetchedAt.

This matters because it's the largest remaining wildfire term (~10.9k RPC reads/day × up to 1.36 MB ≈ up to ~14.9 GB/day at peak, vs ~0.9 GB/day off the compact key) — and it's the path the client falls back to whenever bootstrap lacks the key. Suggest reading compact with a canonical fallback for cold start.

🟡 P2 — the new keys are unmonitored, so the fix can silently stop working

wildfire:fires-bootstrap:v1 and seed-meta:wildfire:fires-bootstrap are not registered in api/health.js (BOOTSTRAP_KEYS still maps wildfires → wildfire:fires:v1; SEED_META still has only seed-meta:wildfire:fires).

The failure mode is nasty precisely because the client fallback works: if the extraKey write breaks or the transform regresses, the wildfire panel keeps rendering (via the RPC), health stays green, and egress quietly reverts to the expensive path — i.e. this PR's savings evaporate with no alarm anywhere. Please register the served key + its seed-meta.


Verdict: the architecture is right and the caller-invariance claim checks out. Please pin the displacement public shape before merge (blocker); the two P2s can land as follow-ups, though the list-fire-detections one is most of the remaining wildfire win and is a ~3-line change.

Also agree with keeping #5259 open until a full-day measurement lands — and note the measurement must be taken at a peak hour: 64 GB/day was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB.

Pin displacement sharing to one canonical URL, serve wildfire RPCs from the compact seed with canonical fallback, and monitor the compact publish independently.

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head 5d141e50da4d21db41fc04ea45f19d9741ad855d (was f48d9be). CI green, 27/27.

🔴 BLOCKER — resolved, and resolved decisively

src/shared/public-rpc-cache.ts:47 now does an exact-string match:

const DISPLACEMENT_PUBLIC_SEARCH = '?flow_limit=50&public=1';
...
return url.search === DISPLACEMENT_PUBLIC_SEARCH;

That collapses the public displacement surface from ~251k shapes to exactly one, which closes both halves of the finding:

  • No CDN fan-out — every repeat is a hit, which is the entire point of the public URL.
  • year is no longer accepted on the public path at all, so the unauthenticated, attacker-chosen paginated UNHCR crawl (get-displacement-summary.ts:182-192) is unreachable without credentials.

I checked the fail-closed behaviour too: any other shape (reordered params, extra params, different flow_limit) simply isn't classified as public, so it falls through to the credentialed legacy URL and 401s for anon. Correct direction to fail. And it matches what the client actually emits — addPublicSharedRpcMarker appends public=1 to the generated client's ?flow_limit=50, producing that exact string.

🟡 P2s — both resolved

list-fire-detections (:31-44) now reads wildfire:fires-bootstrap:v1 first and falls back to the canonical when it's absent — exactly the compact-with-cold-start-fallback shape. Response stays byte-identical (the compact payload preserves pagination.totalCount).

Health registration (api/health.js:31, :277) — wildfiresBootstrap is now in both BOOTSTRAP_KEYS and SEED_META. I also verified the piece that would have made this half-work silently: runSeed's extra-key loop honors ek.metaKey under contract mode (_seed-utils.mjs, if (contractMode && ek.metaKey) → writeSeedMeta(...)), and this seeder is in contract mode, so seed-meta:wildfire:fires-bootstrap really does get written.


🟡 NEW P2 (introduced by the health-registration fix) — cold-start false CRIT

EMPTY: 'crit' (api/health.js:866), and wildfiresBootstrap is not in EMPTY_DATA_OK_KEYS.

So from the moment this deploys until the Railway seeder's first post-deploy tick publishes wildfire:fires-bootstrap:v1, health scores a crit on a key that has simply never been written. At ~200 checks, one crit lands under the 3% threshold → overall DEGRADED, and it writes health:last-failure + a health:failure-log entry — a false incident, on every environment, for the length of that window.

The PR's own new test pins this as expected behaviour:

assert.equal(missingCompact.status, 'EMPTY');   // tests/wildfire-bootstrap-health.test.mjs

This exact cold-start case has been solved twice already, and the precedents are sitting two lines away in the same Set (api/health.js:635-636):

'forecastBets',   // ... absent before the cron ships it ... tolerate as STALE_SEED (warn), not EMPTY (crit).
'forecastFunnel', // ... before the first seed-forecasts run ships it the key is absent ...

Please add wildfiresBootstrap to EMPTY_DATA_OK_KEYS with the same rationale (and flip the test to assert STALE_SEED). Monitoring strength is not lost: once the key exists, the seed-meta:wildfire:fires-bootstrap staleness check (maxStaleMin: 360) still catches a writer that stops, and canonical wildfires still CRITs on real data loss. All this changes is that "never written yet" degrades to a warn instead of a crit — which is precisely what the two precedents encode.

To be clear on blast radius: HTTP status stays 200 for DEGRADED, so UptimeRobot won't flip. This is false-alarm and incident-log pollution, not an outage.


Verdict: the blocker is properly closed and the architecture is sound. One line in EMPTY_DATA_OK_KEYS (plus the test assertion) and this is ready.

Merge-order note: this and #5262 both touch api/health.js in different regions — no logical conflict, but the second one in will need a trivial rebase.

And when the post-deploy measurement runs, take it at a peak hour: the 64 GB/day baseline in #5259 was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB.

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head a68c6caa1672725c5d149a215df8a8054078753b (was 5d141e5). CI green 27/27, mergeStateStatus: CLEAN.

The cold-start finding is resolved — and the test got stronger

api/health.js:637 adds wildfiresBootstrap to EMPTY_DATA_OK_KEYS with a rationale matching the forecastBets / forecastFunnel precedents directly above it. Missing-before-first-seed now classifies as STALE_SEED (warn) instead of EMPTY (crit), so the deploy → first-seeder-tick window no longer produces a false DEGRADED verdict or a bogus health:failure-log entry.

The test change is the right one, and it does more than flip the assertion — it adds a second case pinning that canonical wildfires still classifies as EMPTY (crit) when absent. That's exactly the invariant worth locking: the softening applies to the compact side-write only and does not leak to the key that actually signals real wildfire data loss.

I probed the obvious way this softening could backfire — it holds

Adding a key to EMPTY_DATA_OK_KEYS means classifyKey:812 returns OK for missing data + fresh seed-meta:

else if (EMPTY_DATA_OK_KEYS.has(name)) status = seedStale === true ? 'STALE_SEED' : 'OK';

That would reopen the exact monitoring gap the earlier P2 was meant to close — a compact write that fails while its meta stays fresh would read green. So I checked whether that state is reachable, and it isn't: writeExtraKey throws on a failed write (scripts/_seed-utils.mjs:678), and the writeSeedMeta call for the extra key only runs after it returns. A fresh compact meta therefore implies a successful compact data write moments earlier. Good — the softening is safe because the writer is fail-fast, not because we got lucky.

One residual nuance (not blocking, just so it's known)

If the compact write starts throwing after having previously succeeded, there's a bounded window where health reads OK for the compact key: the data key expires at its 2 h TTL, while the meta (7-day TTL) stays inside maxStaleMin: 360 for up to 6 h — so roughly a ≤4 h gap before it flips to STALE_SEED.

It is not silent, though, on any axis that matters:

  • the seeder itself crashes on the throw (non-zero exit → Railway alarm), and
  • after the 6 h staleness threshold, health warns anyway.

So the worst case is a delayed health warning alongside an immediate seeder alarm. Not worth another round.

Merge state

#5262 has since landed on main, and both PRs touch api/health.js — but they edit different regions (5262: the snapshot constants + handler; this: BOOTSTRAP_KEYS / SEED_META / EMPTY_DATA_OK_KEYS). I test-merged: zero conflicts, and GitHub agrees (CLEAN). No rebase needed.


Verdict: ship it. Every finding from both prior rounds is closed:

Round Finding Status
1 🔴 displacement public URL: ~251k CDN keys + unauthenticated UNHCR crawl via year ✅ pinned to one exact shape
1 🟡 list-fire-detections still read the 1.36 MB canonical ✅ reads compact, falls back to canonical
1 🟡 new bootstrap key unmonitored ✅ registered in BOOTSTRAP_KEYS + SEED_META
2 🟡 cold-start false CRIT from that registration EMPTY_DATA_OK_KEYS + strengthened test

Reminder for the post-deploy step: take the measurement at a peak hour. The 64 GB/day figure in #5259 was sampled at trough, and wildfire alone swings 79 KB ↔ 1.36 MB — a trough reading will flatter the result.

@koala73
koala73 merged commit becec80 into main Jul 13, 2026
27 checks passed
@koala73
koala73 deleted the codex/issue-5259-redis-egress branch July 13, 2026 10:10
koala73 added a commit that referenced this pull request Jul 13, 2026
…lded (#5285) (#5287)

Both public RPC URLs shipped by #5263 returned 401 in production and were never
CDN-cached, so the ~19 GB/day of Redis egress they were meant to shield kept
hitting origin — and the deployed client, which now calls only those URLs, fell
back to a stale news digest and rendered an empty displacement panel.

Vercel serves these through api/<domain>/v1/[rpc].ts and its filesystem router
echoes the matched segment back as ?rpc=<lastPathSegment>. isPublicSharedRpcRequest
validates the query with an exhaustive allowlist (news) and an exact-string compare
(displacement); neither tolerates the injected param, so the gateway never
classified the request as public and fell through to validateApiKey.

/api/bootstrap (#5250) is unaffected because it is a static route. Every other
public RPC only matches on pathname, so #5263 was the first query-strict check on
a dynamic route — and the first to trip on this. server/_shared/mcp-internal-hmac.ts
already strips the same echo before signing; this reuses that guard.

Strip `rpc` only when every value equals the final path segment (the router's own
echo). A caller-supplied ?rpc=<anything-else> is left in place and still fails the
shape check, so this is not a bypass vector. The echo is removed from the RAW query
string rather than a re-serialised URLSearchParams, because re-encoding would
normalise flow_limit=%35%30 into flow_limit=50 and silently widen the displacement
contract, which is deliberately an exact-string match to keep the CDN key space at
one entry.

Tests: the hand-built URLs in the existing suite never carried the echo, which is
why unit tests stayed green while production was broken. Adds coverage for the
router-echo shape on both routes, plus rejection of a caller-supplied ?rpc=.

Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
koala73 added a commit that referenced this pull request Jul 14, 2026
…ier freight (#5300) (#5302)

Every client downloads both bootstrap tiers on every boot, so a key belongs in a
tier only if the median client actually reads it. cyberThreats did not.

loadCyberThreats is double-gated — on the VITE_ENABLE_CYBER_LAYER build flag AND
on mapLayers.cyberThreats (src/app/data-loader.ts:881) — and that layer is OFF by
default in all 12 variant configs (src/config/panels.ts). So the slow tier shipped
cyber:threats-bootstrap:v2 (364 KB) to every visitor, and the default visitor never
read a byte of it: ~2.15 GB/day of Redis egress for data nobody consumed.

Introduces ON_DEMAND_KEYS as a first-class third category. A key now rides in the
fast tier, the slow tier, or neither — and "neither" is a deliberate, tested state
rather than a hole in the invariant. On-demand keys are fetched individually, and
only by the clients that render them, through `?keys=<name>&public=1`.

That per-key URL is restricted to ONE key drawn from ON_DEMAND_KEYS. An arbitrary
`?keys=a,b,c` would make the CDN key space combinatorial, and every distinct
combination is a miss that re-reads the registry from Redis — the amplification
#5259 exists to prevent. One key per URL keeps the space at |ON_DEMAND_KEYS|
entries, each independently cached. The legacy multi-key `?keys=` URL is unchanged:
still credentialed, still no-store.

Client-side, ensureHydrated() returns the tier value when present (so promoting a
key back into a tier needs no consumer change) and otherwise fetches the per-key
public URL. It deliberately does NOT fall back to the domain RPC: the RPC reads the
same Redis key with no CDN in front of it, so routing misses there would relocate
the egress rather than remove it — the trap that made #5263's RPC work a no-op
until #5287. Callers keep their existing RPC fallback for the failure case.

Tests: the tier invariant now covers SLOW ∪ FAST ∪ ON_DEMAND with pairwise
no-overlap; the hydration-coverage guard accepts ensureHydrated() as a consumer
form; and the public per-key URL is pinned against widening (multi-key, non-
on-demand key, unknown key, and empty all fall through to credentialed no-store).

Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
koala73 added a commit that referenced this pull request Jul 14, 2026
…not the full watch (#5300) (#5303)

* perf(bootstrap): serve thermal escalation as a dashboard-sized view, not the full watch (#5300)

The bootstrap slow tier is downloaded by every client on every boot, so what
rides in it should be what the UI renders. thermal:escalation:v1 ships ~117
ranked clusters. The dashboard renders 12 — fetchThermalEscalations(maxItems=12)
slices the array and recomputes its summary from that slice, so every cluster
past the cap is downloaded and thrown away. At peak that is 467 KB per origin
miss for a payload the UI never shows: ~2.9 GB/day of Redis egress.

Publishes thermal:escalation-bootstrap:v1 — the same watch, capped to the top 24
clusters — and hydrates the tier from it. computeThermalEscalationWatch already
ranks clusters (strategic relevance -> severity -> total FRP -> observation
count), so the client's slice(0, 12) is a top-12-by-rank and the capped array is
the identical ranked prefix. Behaviour is preserved exactly; only the bytes the
client discards are gone.

The canonical key is untouched and still serves the RPC and analytical consumers.
Same shape as the wildfire compaction in #5263: canonical = source, *-bootstrap =
the view the dashboard actually renders.

Guards, each earned from a prior incident:
- the compactor lives in scripts/ and imports nothing outside it. A ../api/
  import escapes Railway's scripts-only Nixpacks root and crashes the seeder at
  startup (#5268); the no-escape-import guard now covers this entry point.
- health monitors thermal:escalation-bootstrap:v1 and its seed-meta separately,
  so a transform or write failure cannot hide behind a healthy canonical key.
- the compact key is in EMPTY_DATA_OK_KEYS: it is legitimately absent for one
  cron interval after deploy, and EMPTY scores as crit, which would flip
  /api/health to DEGRADED on a false alarm (the trap #5263 hit on round two).
  Warn instead; seed-meta staleness still catches a writer that stops.
- a drift guard pins the client's maxItems default at or below the published cap,
  so raising one without the other can never silently truncate the dashboard.

Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9

* test(mcp): document thermal:escalation-bootstrap:v1 as a cascade-mirror (#5300)

The U7 parity guard requires every health-registered seeded key to be covered by
an MCP tool or carry a documented exclusion. Registering the compact key in
health (so a transform/write failure can't hide behind a healthy canonical key)
correctly tripped it.

Excluded for the same reason wildfire:fires-bootstrap:v1 is: it is a
pre-compacted dashboard VIEW, not a new data source. The canonical
thermal:escalation:v1 remains the tool/RPC source and carries the full cluster
set — agents must read that, not the top-24 slice the dashboard renders.

Claude-Session: https://claude.ai/code/session_01NTYAunDvEaTSD9oWUG3tC9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant