Add live protest feed ingestion and map integration#9
Closed
koala73 wants to merge 1 commit into
Closed
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
6 tasks
EleCor79
added a commit
to EleCor79/BehavioralHealthPulse
that referenced
this pull request
Mar 2, 2026
Distribute all 32 feeds from feeds-health-italy-eu.csv into the FEEDS config consumed by loadNews() / fetchCategoryFeeds(): - ministero-salute: +MinSalute News Specifiche (CSV koala73#18) - iss-epicentro: +ISS Notizie (CSV koala73#3), +Epicentro Coronavirus (CSV koala73#17) - aifa-tracker: +AIFA Feed (CSV koala73#5), +EMA News (CSV koala73#8), +FDA (CSV koala73#12) - agenas-ospedali: +AGENAS RSS (CSV koala73#4), +PNRR (CSV koala73#6/koala73#19), +Lombardia (CSV koala73#20), +Lazio (CSV koala73#21) - ema-europa: +EMA Clinical Trials (CSV koala73#22) - ecdc-sorveglianza:+ECDC Weekly Threats (CSV koala73#23), +WHO DON (CSV koala73#9), +ProMED (CSV koala73#10) - live-news: +Sanitainformazione (CSV koala73#24), +Humanitas (CSV koala73#26) - europe: +EU Core Health Indicators (CSV koala73#31), +GLOBSEC HRI (CSV koala73#32), +ISTAT (CSV koala73#13), +EIN Health Europe (CSV koala73#29) - rare-diseases: NEW category — EURORDIS (CSV koala73#14), Orphanet IT (CSV koala73#15), Telethon (CSV koala73#16), CDC FluView (CSV koala73#11) Panel disabled by default, available in settings Co-Authored-By: Claude Opus 4.6 <[email protected]>
bradleybond512
referenced
this pull request
in bradleybond512/worldmonitor-macos
Mar 2, 2026
Closes task #9. Extends sound-manager.ts with three continuous layers powered by the Web Audio API (no audio files required): - Tension drone: 432 Hz sine, pitch interpolates 432→512 Hz as wm:war-score rises (0→100), 4s fade-in on first gesture - Ambient chatter: bandpass-filtered noise clicks (1.8–3.2 kHz), gap compresses from 8s (quiet) to 1s after 20 breaking events - Escalation pings: two-note descending sine (880/660 Hz critical, 660/520 Hz high) fired on every wm:breaking-news event - Shared _masterGain → independent volume from mode-transition sounds - Mute-on-idle: fades to silence after 5 min of no interaction - Tab visibility: mutes/restores on visibilitychange - All three layers individually toggleable via localStorage keys (wm-spatial-ambient, wm-spatial-drone, wm-spatial-pings) - Master volume: wm-spatial-volume (default 0.50) Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
gefa-sc
pushed a commit
to gefa-sc/worldmonitor
that referenced
this pull request
Mar 2, 2026
Migrate to SvelteKit with TypeScript
20 tasks
10 tasks
This was referenced Apr 24, 2026
koala73
added a commit
that referenced
this pull request
Apr 25, 2026
…3394) DNS (Cloudflare) and the Vercel domain are already provisioned by the operator; this lands the matching code-side wiring so the variant actually resolves and renders correctly. Changes: middleware.ts - Add `'energy.worldmonitor.app': 'energy'` to VARIANT_HOST_MAP. This also auto-includes the host in ALLOWED_HOSTS via the spread on line 87. - Add `energy` entry to VARIANT_OG with the Energy-Atlas-specific title + description from `src/config/variant-meta.ts:130-152`. OG image points at `https://energy.worldmonitor.app/favico/energy/og-image.png`, matching the per-variant convention used by tech / finance / commodity / happy. vercel.json - Add `https://energy.worldmonitor.app` to BOTH `frame-src` and `frame-ancestors` in the global Content-Security-Policy header. Without this, the variant subdomain would render but be blocked from being framed back into worldmonitor.app for any embedded flow (Slack/LinkedIn previews, future iframe widgets, etc.). This supersedes the CSP-only portion of PR #3359 (which mixed CSP with unrelated relay/military changes). convex/payments/checkout.ts:108-117 - Add `https://energy.worldmonitor.app` to the checkout returnUrl allowlist. Without this, a PRO upgrade flow initiated from the energy subdomain would fail with "Invalid returnUrl" on Convex. src-tauri/tauri.conf.json:32 - Add `https://energy.worldmonitor.app` to the Tauri desktop CSP frame-src so the desktop app can embed the variant the same way it embeds the other 4. public/favico/energy/* (NEW, 7 files) - Stub the per-variant favicon directory by copying the root-level WorldMonitor brand assets (android-chrome 192/512, apple-touch, favicon 16/32/ico, og-image). This keeps the launch unblocked on design assets — every referenced URL resolves with valid bytes from day one. Replace with energy-themed designs in a follow-up PR; the file paths are stable. Other variant subdomains already on main (tech / finance / commodity / happy) are unchanged. APP_HOSTS in src/services/runtime.ts already admits any `*.worldmonitor.app` via `host.endsWith('.worldmonitor.app')` on line 226, so no edit needed there. Closes gaps §L #9, #10, #11 in docs/internal/energy-atlas-registry-expansion.md.
koala73
added a commit
that referenced
this pull request
May 10, 2026
* feat(convex): add mcpProTokens table + issue/validate/revoke + http routes (U1)
Non-key Pro MCP identity layer for the Pro-tier MCP access plan. Mirrors
apiKeys.ts shape but stores no key material — the row's _id IS the
bearer identifier (referenced from OAuth code/token records as
mcpTokenId).
- mcpProTokens table: {userId, clientId?, name?, createdAt, lastUsedAt?,
revokedAt?} indexed by_userId; userApiKeys untouched.
- Internal mutations: issueProMcpToken (tier ≥ 1 gate, 5-row cap with
silent oldest-revoke rotation, audit-preserving), validateProMcpToken,
internalRevokeProMcpToken (server-side rollback path for U5 — no Clerk
context needed), touchProMcpTokenLastUsed (debounced 5min).
- Public mutations: revokeProMcpToken + listProMcpTokens (Clerk-auth).
- HTTP internal routes: /api/internal-{issue,validate,revoke}-pro-mcp-token
with x-convex-shared-secret guard. Validate route schedules touch via
ctx.scheduler.runAfter, matching apiKeys pattern (http.ts:839).
28 new tests; full convex suite 245/245.
Plan unit U1: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* feat(server): pro-mcp-token edge helper — issue/validate/revoke (U2)
Edge-runtime-safe wrappers around U1's Convex internal HTTP routes. No
positive cache on validate (revoke takes effect on next request);
negative-cache only at pro-mcp-token-neg:<tokenId> (60s TTL) for
known-bad bearers.
- issueProMcpTokenForUser: typed ProMcpIssueFailed (pro-required /
invalid-user-id / config / network), 3s timeout.
- validateProMcpToken: neg-cache short-circuit BEFORE Convex hit; fail-soft
on 5xx/timeout (returns null but does NOT write neg-cache sentinel —
prevents transient-blip poisoning of legitimate tokens). Convex's
validate route schedules touchProMcpTokenLastUsed internally; no
edge-side waitUntil needed.
- revokeProMcpToken: returns result object (no throw) so rollback callers
don't have their original error masked. Sets neg-cache sentinel after
successful revoke.
- invalidateProMcpTokenCache: public sentinel writer for U9.
25 tests including a load-bearing integration test for revoke → next
validate short-circuits without Convex round-trip.
Plan unit U2.
* feat(oauth): apex Clerk grant page + signed-grant mint API (U3)
Bridge between the apex Clerk session and the api-subdomain Pro MCP
consent flow. Apex /mcp-grant SPA reads the OAuth nonce + registered
client metadata so users see the real client_name + redirect host
(anti-phishing). On Authorize the page POSTs to mcp-grant-mint, gets
back a fixed redirect URL with a HMAC-signed grant token, and navigates.
- mcp-grant.html + src/mcp-grant-main.ts: Vite multi-page entry (matches
existing settings.html / live-channels.html convention).
- api/internal/mcp-grant-mint.ts: Clerk-auth POST. Re-checks tier ≥ 1,
re-validates redirect_uri allowlist (defense-in-depth), HMAC-signs
{userId, nonce, exp+5min}, SETEX mcp-grant:<nonce>, returns FIXED
redirect URL (https://api.worldmonitor.app/oauth/authorize-pro).
- api/internal/mcp-grant-context.ts: GET companion for the SPA to fetch
the real client_name + redirect_host. Same Pro-tier gate as mint.
- api/_mcp-grant-hmac.ts: shared sign/verify helper. Signature is over
exact post-base64url-decode bytes (key-order/whitespace independent).
U5 imports verifyGrant from this module.
- vercel.json: /mcp-grant → /mcp-grant.html rewrite + no-store header
rule. Catch-all SPA fallback adjusted to exclude the new page.
- api/oauth/register.js: export isAllowedRedirectUri so DCR allowlist is
reused (no parallel impl).
- 35 tests; full deploy-config + edge-functions + mcp + pro-mcp-token
suites green.
Plan unit U3.
* feat(oauth): consent page Pro-CTA-default + 'Use API key instead' (U4)
R1 of the plan: a logged-in Pro user authorising MCP from claude.ai
never sees the API-key input field by default. The consent page now
leads with a brand-green "Sign in with WorldMonitor Pro" CTA pointing
at the apex /mcp-grant page; the existing API-key form is hidden
behind a "Use API key instead" disclosure for Starter+ holders.
- Default state: form display:none; Pro CTA dominant.
- Error state: existing errorMsg path still works — when ke.textContent
is non-empty (or XHR-retry returns invalid_key), inline script auto-
reveals the form so the user doesn't lose context after a bad key.
- Deep-link: /oauth/authorize?...#api-key shows the form on initial
load, so Starter+ users can bookmark.
- Pro CTA href: https://worldmonitor.app/mcp-grant?nonce=<URL-encoded n>.
Apex page reads the OAuth nonce server-side (no client_name forwarded
via URL — already covered by U3's mcp-grant-context).
- nonce shared with U5: the same oauth:nonce:<n> row that U5's
/oauth/authorize-pro will GETDEL. Handler still mints exactly one
nonce per GET — no double-issue.
- XSS-escape preserved on client_name + redirect_host + nonce + errorMsg.
- 18 new tests; existing handler logic untouched.
Plan unit U4.
* feat(oauth): /oauth/authorize-pro bounce-back endpoint (U5)
Receives the apex grant bounce-back, validates the HMAC-signed grant,
atomically consumes both Redis nonces (mcp-grant + oauth:nonce), issues
a non-key Pro identity row in Convex via U2, and writes the OAuth code
with {kind:'pro', userId, mcpTokenId, ...} for U6 to read at exchange
time.
Security ordering (HMAC-first to avoid burning nonces on forged tokens):
1. verifyGrant (U3's _mcp-grant-hmac, no Redis)
2. URL-nonce vs payload-nonce match
3. GETDEL mcp-grant:<n>
4. strict {userId, exp} tuple match vs grant payload (forge defense)
5. GETDEL oauth:nonce:<n>
6. GET oauth:client:<client_id> + redirect_uri allowlist re-check
7. getEntitlements(userId) tier ≥ 1 re-check (lapse defense)
8. issueProMcpTokenForUser (U2)
9. SETEX oauth:code:<code> (kind:'pro', 600s)
10. 302 redirect_uri?code=<code>[&state=...] with Cache-Control:no-store
oauth:code shape (load-bearing for U6):
{kind:'pro', userId, mcpTokenId, client_id, redirect_uri,
code_challenge, scope:'mcp_pro'}
Best-effort revoke when SETEX fails after issueProMcpTokenForUser
succeeds — calls U2's no-throw revokeProMcpToken; logs orphan-row id
on revoke failure but never masks the original 500.
35 tests; vercel rewrite + api-route-exceptions registered as
external-protocol.
Plan unit U5.
* feat(oauth): McpAuthContext discriminated union + Pro token shape (U6)
Token endpoint and bearer resolver learn about Pro-shape tokens without
breaking Starter+ legacy paths.
api/_oauth-token.js:
- New resolveBearerToContext(token) returns a discriminated union:
{kind:'env_key', apiKey} -- legacy WORLDMONITOR_VALID_KEYS resolution
{kind:'pro', userId, mcpTokenId} -- new Pro identity
- resolveApiKeyFromBearer kept as backward-compat wrapper. Returns
apiKey for env_key kind, null for pro kind (so legacy callers can't
mis-handle a Pro bearer).
api/oauth/token.js → api/oauth/token.ts:
- Converted to TypeScript so it can import validateProMcpToken from
server/_shared/pro-mcp-token cleanly. Vercel routes by basename;
/oauth/token rewrite unaffected.
- Default handler wires injected deps via tokenHandler(req, deps) for
testability (mirrors U3/U5 pattern).
- authorization_code branch: dispatches on codeData.kind. Pro path
validates client_id + redirect_uri bind, PKCE-verifies, mints tokens
via storeProTokens (object shape). Legacy path unchanged.
- refresh_token branch: Pro refreshes call validateProMcpToken
(per-request Convex hit, no positive cache); revoked row → invalid_grant.
Cross-user defensive guard: Convex-returned userId must match bearer's.
family_id, mcpTokenId, scope ('mcp_pro') preserved across rotation.
- client_credentials grant untouched.
oauth:token:<uuid> shapes:
- Legacy (preserved): JSON.stringify("<sha256-hex-64>") or "<fingerprint-16>"
- Pro (new): JSON.stringify({kind:'pro', userId, mcpTokenId})
- oauth:refresh:<uuid> Pro shape includes client_id, scope, family_id
for rotation discipline (matches legacy semantics).
26 new tests; sibling suites (oauth-authorize, oauth-authorize-pro, mcp,
pro-mcp-token — 101 tests) regress green. tsc -p tsconfig.api.json clean.
Plan unit U6.
* feat(mcp): Pro identity, atomic INCR-first quota, internal HMAC fetch (U7)
Behavioral heart of the Pro MCP plan. api/mcp.ts now resolves bearers
as McpAuthContext (env_key | pro), runs Pro-specific pre-checks, and
enforces a hard 50/UTC-day cap via INCR-first reservation with
DECR-rollback on cap-exceed and on tool-dispatch failure.
Pre-dispatch chain for Pro context (synchronous, in order):
1. validateProMcpToken(mcpTokenId) — null = 401 revoked
2. defensive userId match — 401 cross-user
3. getEntitlements(userId) tier ≥ 1, mcpAccess: true, validUntil — fail-closed
4. per-minute slidingWindow(60, 60s) keyed pro-user:<userId> — fail-open
5. for tools/call ONLY: pipeline INCR + EXPIRE 172800
newCount > 50 → DECR rollback + -32029 + 429 + Retry-After
INCR Redis transient → -32603 + 503 + Retry-After:5 (hard-cap correctness)
6. dispatch tool; on throw → DECR rollback + -32603
Internal-HMAC tool fetches (replaces X-WorldMonitor-Key for Pro):
payload = ${ts}:${METHOD}:${pathname}:${queryHash}:${bodyHash}:${userId}
queryHash = SHA-256(canonicalQueryString) sorted keys, URL-encoded
bodyHash = SHA-256(bodyBytes) SHA-256("") for empty
sig = HMAC-SHA-256(MCP_INTERNAL_HMAC_SECRET, payload)
headers = X-WM-MCP-Internal: ${ts}.${b64url(sig)}, X-WM-MCP-User-Id
Replay defense: payload binds method+path+queryHash+bodyHash, so a
captured signature for /api/news/v1/list-feed-digest cannot be reused
on /api/intelligence/v1/deduct-situation. ±30s window.
server/_shared/mcp-internal-hmac.ts: single source of truth for sign
helpers. U8 verify imports the same canonicalisation primitives.
server/_shared/pro-mcp-token.ts: dailyCounterKey(userId), 50/172800s
constants exported for U9 to read the SAME Redis key the enforcement
writes.
_execute signatures changed (params, base, context) — option A from
the plan. Cache-only tools unchanged.
waitUntil unused: Convex internal-validate-pro-mcp-token schedules
touchProMcpTokenLastUsed itself (verified at convex/http.ts:1035-1040
from U1's commit 4530bdf).
22 new tests + 23 Starter+ regression tests in tests/mcp.test.mjs;
sibling chain (oauth-token, pro-mcp-token, oauth-authorize-pro,
mcp-grant-mint, mcp-proxy) all green. tsc + biome clean.
Plan unit U7.
* feat(gateway): HMAC-verify internal-MCP requests + sanitised propagation (U8)
Verifier counterpart to U7's signer. Gateway accepts X-WM-MCP-Internal
HMAC headers in lieu of an API key for Pro internal-MCP traffic, then
hands a freshly-constructed Request with sanitised trusted markers to
the downstream handler. isCallerPremium learns to honour those markers
so Pro framework/systemAppend semantics survive in summarize-article,
get-country-intel-brief, deduct-situation.
Gateway flow at the top of createDomainGateway's handler:
1. STRIP inbound x-wm-mcp-internal-verified + x-user-id (anti-injection)
2. If X-WM-MCP-Internal present:
verifyInternalMcpRequest re-canonicalises and HMAC-verifies the
request shape (method+pathname+queryHash+bodyHash+userId), ±30s
window, timing-safe compare. Failure → 401 invalid_internal_mcp_
signature (no fall-through to validateApiKey — present-but-bad is
a deliberate forge attempt).
getEntitlements(userId): tier ≥ 1 + mcpAccess === true (fail-closed).
Construct NEW Request via new Request(url, {method, body, headers})
with x-wm-mcp-internal-verified=<per-process nonce> +
x-user-id=<verified userId>. Skip validateApiKey + IP rate limit.
isCallerPremium adds a NEW first branch: timing-safe compare of
x-wm-mcp-internal-verified against the per-process nonce + non-empty
x-user-id + defensive getEntitlements re-fetch. Catches direct-edge-
function consumers (api/widget-agent.ts, chat-analyst.ts, me/entitlement.ts,
v2/shipping/webhooks/...) that don't run the gateway strip step.
DEFENSE-IN-DEPTH BEYOND PLAN: trusted marker is a per-process random
16-byte nonce, NOT the literal '1'. Sweep found direct edge functions
calling isCallerPremium without gateway-strip protection — a constant
marker would be spoofable from outside. The nonce is born once at
process startup, only the gateway knows it, comparison is timing-safe.
verifyInternalMcpRequest lives in server/_shared/mcp-internal-hmac.ts
next to U7's sign helpers — single source of truth for canonical form
+ payload + ±30s window. Drift between sign and verify is structurally
impossible.
26 new tests + 100 sibling regression tests (gateway-cdn-origin-policy,
premium-stock-gateway, premium-fetch, pro-mcp-token, mcp). tsc clean.
Plan unit U8.
* feat(catalog): mcpAccess feature flag + env vars + Pro-MCP docs (U10)
Catalog, schema, type, env, and docs scaffolding to make the Pro MCP
flow deployable end-to-end. U9 unblocks on this — settings UI gates on
hasFeature('mcpAccess') (distinct from existing apiAccess paywall).
- convex/config/productCatalog.ts: PlanFeatures.mcpAccess on every tier.
Free=false. Pro_monthly/Pro_annual=true. API_starter / API_starter_annual
/ API_business=true. Enterprise=true. Pro plan marketing copy mentions
"MCP access for Claude Desktop & other AI clients (50 calls/day)".
- convex/schema.ts + convex/payments/cacheActions.ts: mcpAccess optional
field on the entitlements validator; legacy rows pass.
- server/_shared/entitlement-check.ts: CachedEntitlements.features
mcpAccess?: boolean (consumed by U7 + U8).
- src/services/entitlements.ts: EntitlementState.features.mcpAccess?:
boolean — unblocks hasFeature('mcpAccess') for U9's settings tab gate.
- .env.example: new "Pro MCP" section with MCP_PRO_GRANT_HMAC_SECRET
(apex grant bridge, U3/U5) and MCP_INTERNAL_HMAC_SECRET (gateway
service-auth, U7/U8). Both 32-byte base64; both required.
- docs/mcp-server.mdx: "Pro sign-in flow" section + 50/day quota
callout + "Connected MCP clients" management pointer.
Bundled fixups for U7/U8 strict-mode TS errors:
- mcp-internal-hmac.ts: bufferToBase64Url uses for-of (avoids
noUncheckedIndexedAccess error on bytes[i]).
- gateway.ts: drop unused INTERNAL_MCP_USER_ID_HEADER import.
tsc -p tsconfig.api.json + tsc -p tsconfig.json clean.
9 entitlements tests + 71 gateway-internal-mcp + mcp tests pass.
Plan unit U10.
* feat(settings): Connected MCP clients tab + quota endpoint + revoke (U9)
User-facing surface that makes the Pro MCP plan visible. Settings UI
gets a new "Connected MCP clients" tab gated on hasFeature('mcpAccess')
(distinct from the existing apiAccess-gated 'API Keys' tab — Pro users
without apiAccess see only the new tab).
Endpoints:
- GET /api/user/mcp-quota → {used, limit:50, resetsAt}. Reads the
SAME Redis key (dailyCounterKey from U2) that U7 enforcement writes.
Single source of truth.
- POST /api/user/mcp-revoke {tokenId} → forwards Clerk-derived userId
(NOT client-supplied) to Convex internal-revoke-pro-mcp-token, then
calls invalidateProMcpTokenCache so any in-flight bearer with this
tokenId 401s within 60s. 404 on NOT_FOUND, 409 on ALREADY_REVOKED.
Tenancy enforced inside Convex (row.userId === userId).
UI (in src/components/UnifiedSettings.ts, no child component split —
matches existing API Keys tab pattern):
- Tab gated on hasFeature('mcpAccess') so Pro users see it without
needing apiAccess.
- Quota header auto-refreshes every 30s; interval cleared on tab-
switch / close / destroy.
- Each row: name, createdAt, lastUsedAt (relative), revokedAt
(struck-through + badge); active rows show Revoke button with
confirm() dialog (matches existing pattern).
- Empty state: click-to-copy https://api.worldmonitor.app/mcp.
Frontend service in src/services/mcp-clients.ts: listMcpClients (Convex
query), revokeMcpClient (POST /api/user/mcp-revoke), fetchMcpQuota.
11 + 14 = 25 new tests; api-route-exceptions registered as
internal-helper.
Plan unit U9.
---
PLAN COMPLETE — 10/10 units shipped. Follow-up polish noted in U9
report: CSS rules for .mcp-clients-* classes (suggest copy from
.api-keys-* rules); empty-state URL is hardcoded to
api.worldmonitor.app/mcp.
* style(settings): mcp-clients CSS rules mirroring api-keys (U9 polish)
* fix(pro-mcp): apply Tier-2 code-review residuals — security + correctness
Review pass after U10 (3 reviewers: code-reviewer, ce-security-reviewer,
ce-adversarial-reviewer). Findings BLOCKING + HIGH + MEDIUM + LOW
applied per user direction.
BLOCKING (gateway):
- Add validUntil check to gateway's HMAC-verify entitlement re-check.
Defense-in-depth against Convex-fallback paths returning a stale row
past its expiry.
HIGH — adv-001: cross-user OAuth nonce hijack:
- mcp-grant-mint now claims oauth nonces atomically via Upstash SET NX
semantics. If the nonce is already claimed by a DIFFERENT userId,
return 403 NONCE_CLAIMED_BY_OTHER_USER. Same userId multi-tab retry
is idempotent: re-sign the grant using the existing claim's exp so
authorize-pro's strict tuple equality still matches.
- mcp-grant-context performs the same cross-user check so the apex SPA
refuses to render consent context for a hijacked nonce.
MEDIUM — adv-008: refresh-token loss during Convex transient:
- validateProMcpToken returns a discriminated union {ok:'valid',userId}
| {ok:'revoked'} | {ok:'transient'}. Negative-cache writes only on
'revoked'. validateProMcpTokenOrNull wrapper preserves backward
compat for callers that don't need the distinction (per-request MCP
edge stays fail-closed).
- token.ts refresh path: on 'transient', best-effort restore the
consumed refresh token to Redis with the original TTL and return
503 server_error + Retry-After:5. Client retries; if Convex recovers,
refresh succeeds. No more permanent session loss on a Convex blip.
MEDIUM — adv-002: counter overshoot lockout:
- mcp.ts: on cap-exceeded after DECR rollback, if newCount is still
far above PRO_DAILY_QUOTA_LIMIT (overshoot from prior transient),
pipelined INCR+DECR probe + bounded DECR-sweep to converge the
counter back near the limit. Prevents one Redis hiccup from locking
out a paying Pro user for the rest of the UTC day.
MEDIUM — code-reviewer #4: 5-row cap race in Convex:
- mcpProTokens.issueProMcpToken now revokes ALL active rows beyond
MAX-1 (sorted by createdAt) on each issue, so concurrent inserts
that briefly produce 6 active rows converge back to 5 on the next
call.
LOW:
- adv-003: executeTool throws cache_all_null when every cache read
returns null AND there were keys configured — triggers DECR rollback
in dispatchToolsCall instead of silently burning quota on a degenerate
empty result.
- code-reviewer #10: gateway strips X-WM-MCP-Internal + X-WM-MCP-User-Id
from the trusted Request before forwarding to handler (no info leak).
- adv-004: 256 KB body cap (Content-Length + post-buffer count) on
both gateway strip and HMAC-verify paths — bounds memory amplification.
- adv-006: dailyCounterKey now env-prefixes (preview deploys can't
collide with production traffic on the same Upstash instance).
Reader (mcp-quota.ts) and writer (mcp.ts) share byte-identical keys
via the helper.
- adv-007 / code-reviewer #5: signInternalMcpRequest throws on
Blob/FormData/ReadableStream/plain-object bodies instead of silently
JSON.stringify'ing them (which would produce a hash that can't match
the wire bytes).
- code-reviewer #9: timestamp regex tightened to ^[0-9]{1,15}$ so
future ms-precision timestamps don't silently truncate through Number().
- code-reviewer #8: MCP_INTERNAL_HMAC_SECRET preflight in runProPreChecks
surfaces config errors at auth-resolution rather than mid-tool-fetch.
NITPICK:
- code-reviewer #6: rewritten readNegCache comment.
- code-reviewer #7: malformed-body error now reports reason
'malformed_request' instead of misleading 'auth_401'. New
RequestReason value added to usage.ts enum.
30 new tests + ~14 adapted; full convex suite 247/247, edge/server
suites 254/254. tsc clean across both tsconfig.json and tsconfig.api.json.
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* chore(pro-marketing): regenerate /pro bundle for U10 catalog copy
U10's productCatalog.ts added an mcpAccess feature flag and updated the
Pro plan description copy to "MCP access for Claude Desktop & other AI
clients (50 calls/day)". scripts/generate-product-config.mjs cascades
that copy into pro-test/src/generated/tiers.json, which the /pro
marketing app builds into public/pro/assets/.
Vercel does NOT rebuild pro-test on deploy — public/pro/ ships whatever
is committed. Pre-push hook auto-ran the build and produced a new bundle
hash; this commit lands the regenerated artifacts so deploy picks them up.
Plan unit U10 (follow-on artifact).
* fix(pro-mcp): apply external review round-2 findings (P1+P2+P3)
Round-2 review on PR #3646 surfaced 5 must-fix issues:
P1 — vercel.json `(?:...)` rejected by Vercel CI:
- Replace `mcp-grant(?:\\.html)?` with explicit `mcp-grant\\.html|mcp-grant`
inside the SPA catch-all negative-lookahead (lines 18 + 134).
- Split the headers `/mcp-grant(?:\\.html)?` source rule into two explicit
entries — Vercel's path-to-regexp `source` field doesn't accept `(?:...)`
with `?` quantifier as a standalone source.
- Update tests/deploy-config.test.mjs expectations to match.
P1 — api/api-route-exceptions.json points at deleted file:
- Update the entry path from `api/oauth/token.js` (deleted in U6) to
`api/oauth/token.ts`. `node scripts/enforce-sebuf-api-contract.mjs`
was failing pre-push CI.
P1 — mcpAccess migration gap on existing entitlement rows:
- convex/entitlements.ts now read-time merges `getFeaturesForPlan(planKey)`
defaults under stored features. Pre-U10 rows lacking `mcpAccess` see the
catalog default surfaced immediately, with NO wait for the next Dodo
webhook to rewrite the row. Stored features still win on conflict
(preserves per-user overrides). Mirrored explicitly in
convex/mcpProTokens.ts:55 (issueProMcpToken's direct ctx.db.query path
computes the same merge inline since it bypasses the query handler).
P2 — grant/issue path missing mcpAccess gate (let tier-1-without-mcpAccess
users complete OAuth then fail every tools/call at the gateway):
- mcp-grant-context.ts:95, mcp-grant-mint.ts:236, authorize-pro.ts:356,
convex/mcpProTokens.ts:55 now ALL gate on `tier ≥ 1 && mcpAccess === true`,
matching the downstream MCP-edge runProPreChecks check. Widens the
`getEntitlements` deps return type at all three handlers.
P3 — inline theme script blocked by global CSP:
- mcp-grant.html:34 inline `<script>` is removed (global CSP at vercel.json:92
is hash-allowlist-based and we don't allowlist this page's hashes).
- Theme application moved into the bundled module at src/mcp-grant-main.ts
(top of file, runs on module evaluation). Brief default-theme flash on
light-preference users is acceptable for a transient consent UI.
10 new tests covering: mcpAccess: false at each of the 4 gates,
mcpAccess: undefined (legacy row) at each of the 4 gates, and the
read-time catalog merge in both directions (legacy gets default,
override preserved).
Full gate post-fix:
- Convex 249/249 (+2 new merge tests).
- Edge/server 280/280 (+10 new gate tests).
- tsc both configs clean.
- sebuf contract clean (was failing before P1.2).
- Sentry coverage clean.
- deploy-config tests assert new explicit-source rules.
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* fix(entitlement-cache): treat pre-U10 cache entries lacking mcpAccess as stale
Reviewer round-2 P2 (cache layer): _getEntitlementsImpl returned hot
Redis cache entries as-is, bypassing the read-time catalog merge that
convex/entitlements.ts:50 applies on the Convex read path. Paying users
with cache entries written before plan 2026-05-10-001 U10 deployed see
mcpAccess !== true at the grant/MCP gates and are blocked for up to
the 15-min cache TTL.
Cache predicate now also requires `typeof features.mcpAccess === 'boolean'`.
Legacy entries (mcpAccess: undefined) fall through to Convex, which
returns the merged shape and the cache is rewritten with the post-U10
layout. Self-healing, bounded to one extra Convex round-trip per
affected user during the migration window.
Targeted choice over alternatives:
- Importing convex/config/productCatalog into server/_shared to apply
the merge at the cache layer would pull non-edge-safe deps. Rejected.
- Treating ALL cached entries as stale would defeat the cache layer.
Rejected.
- typeof check on the new field is the minimum delta that fixes the
migration window without restructuring.
Test fixture in server/__tests__/entitlement-check.test.ts updated:
makeEntitlements now includes mcpAccess (tier ≥ 1 → true). Two new
focused tests:
1. Legacy cache entry without mcpAccess → cache rejected → Convex
round-trip → result returned (verifies fetch called once).
2. Post-U10 cache entry WITH mcpAccess → cache honored → no Convex
round-trip (verifies fetch called zero times).
Convex 251/251 (+2 new cache tests).
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
11 tasks
koala73
added a commit
that referenced
this pull request
May 26, 2026
…ns + queries + service + UI (#3621) * feat(country-codes): add toIso2 normalizer for alpha-2/3/name input (U1) Foundation for the followed-countries watchlist primitive. Normalizes any country input form (ISO-3166 alpha-2, alpha-3, lowercase, common country names) to canonical alpha-2 uppercase. Returns null on unrecognized input. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U1 * feat(convex): add followedCountries + aggregate counter tables (U12) Two new Convex tables for the followed-countries watchlist primitive: - followedCountries: { userId, country, addedAt } indexed by_user, by_country, by_user_country. Per-country reverse lookup via by_country enables future fan-out (digest, breaking-news relay). - followedCountriesCounts: { country, count, updatedAt } indexed by_country. Aggregate counter maintained atomically by U13 mutations so public countFollowers query is O(1) per call rather than O(n) on by_country.collect(). Constants in convex/constants.ts: FREE_TIER_FOLLOW_LIMIT=3 (server- authoritative cap), MAX_MERGE_INPUT=100 (anti-abuse ceiling), COUNTRY_COUNT_PRIVACY_FLOOR=5 (returned-as-zero threshold for public counts). No migration; both tables empty on creation. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U12 * feat(convex): add followedCountries mutations + ISO-2 registry validator (U13) Three server-authoritative mutations on the new followedCountries table, with atomic counter maintenance for the aggregate followedCountriesCounts table: - followCountry({country}): auth + ISO-2 registry validate + idempotent on (userId, country) + free-tier cap (server-enforced via ConvexError({kind:'FREE_CAP'})) + atomic counter +1. - unfollowCountry({country}): auth + validate + idempotent + atomic counter -1 (max(0, ...) defensive). - mergeAnonymousLocal({countries}): auth + MAX_MERGE_INPUT ceiling (anti-abuse) + ISO-2 registry filter + first-seen dedupe + bounded accept for free users (cap-fitting; over-cap → droppedDueToCap[]) + atomic counter +N. ISO-2 registry validator at convex/lib/iso2.ts mirrors the canonical alpha-2 set from src/utils/country-codes.ts. Both registries must stay in lockstep (documented inline). All errors typed ConvexError({kind, ...}) with object data per the convex-error-string-data-strips-errordata-on-wire memory. 32 new tests covering: auth/validation, free-tier cap (under, at, exceeded), idempotency for both follow + unfollow, counter correctness across all paths (never goes negative), mergeAnonymous with grandfather rejection / partial accept / oversized input / duplicate inputs / mixed valid+invalid. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U13 * feat(convex): followedCountries queries + relay endpoint (U14) Three queries + one relay HTTP action over the followedCountries + followedCountriesCounts tables: - listFollowed = query({}): auth'd reactive query for current user; returns string[] sorted by addedAt asc; [] when no auth identity. Drives the client-side reactive subscription (U2/U3). - countFollowers = query({country}): public no-auth query backed by the aggregate counter table — O(1) per call, not O(n) on by_country.collect(). Privacy floor (COUNTRY_COUNT_PRIVACY_FLOOR=5) returns 0 below threshold to limit follower-set inference. Drives future 'X people watching' social-proof UI. - listFollowersPage = internalQuery({country, cursor?, limit}): internal-only paginated cursor on by_country index, limit clamped [1, 500]. NEVER exposed publicly (declared as internalQuery, NOT query — the typecheck-level privacy boundary). Drives future per-country fan-out (digest, breaking-news relay). - internalListFollowedForUser = internalQuery({userId}): internal helper used by the relay endpoint (which has no Clerk identity). POST /relay/followed-countries HTTP action mirrors the existing /relay/user-preferences pattern: shared-secret auth via timingSafeEqualStrings, body {userId}, returns {countries: string[]}. Used by PR C's brief composer to read followed-countries server-side. 29 new tests covering: per-user reads, sort order, no-auth empty, counter-table-backed counts, privacy floor edges (4 vs 5), cursor pagination across multi-page result, limit clamp [1,500], @ts-expect-error privacy assertion that listFollowersPage is NOT on api.* (only internal.*), relay 200/400/401 paths. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U14 * feat(followed-countries): client service — anonymous mode + entitlement gating (U2) Single client-side owner of watchlist semantics for the followed- countries primitive. U2 ships the anonymous (localStorage) path fully working; signed-in mode plumbing is stubbed with explicit TODO(U3) markers for the next unit to fill in. Public API: - getFollowed(): string[] - isFollowed(code: string): boolean - addCountry(input): Promise<FollowMutationResult> - removeCountry(input): Promise<FollowMutationResult> - subscribe(handler): unsubscribe - serviceEntitlementState(): 'pro' | 'free' | 'loading' - WM_FOLLOWED_COUNTRIES_CHANGED custom event Discriminated-union return (memory: discriminated-union-over-sentinel- boolean): { ok: true } | { ok: false, reason: 'DISABLED' | 'INVALID_INPUT' | 'FREE_CAP' | 'ENTITLEMENT_LOADING' | 'HANDOFF_PENDING' | 'STORAGE_FULL' }. Never throws. Anonymous-vs-loading distinction (Codex deepening round-1 P1): serviceEntitlementState() returns 'free' when getCurrentClerkUser() is null, regardless of entitlement state — anonymous users never block on entitlement loading. Only signed-in users with null entitlement state enter 'loading'. Storage: localStorage 'wm-followed-countries-v1' = JSON.stringify( { countries: string[] }). NOT enrolled in CLOUD_SYNC_KEYS — the dedicated Convex table replaces that path for this feature. Feature flag VITE_FOLLOW_COUNTRIES_ENABLED gates all mutations at the service layer (refusal at top of addCountry/removeCountry). Default ON; only '0' disables. 25 tests covering: happy paths, normalization (alpha-3 → alpha-2), idempotency, FREE_CAP cap enforcement, PRO unlimited, ENTITLEMENT_ LOADING (signed-in only), anonymous-never-loads, feature-flag DISABLED, corrupt/wrong-shape localStorage, STORAGE_FULL on quota throw. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U2 * feat(followed-countries): sign-in handoff + Convex bridge (U3) Wires the signed-in mode of the followed-countries service: - Auth-state listener installed once at app boot. On every Clerk user transition, increments _handoffGeneration and captures userIdAtStart for any in-flight handoff. Post-await callbacks verify both BEFORE clearing localStorage / subscribing — resolves the in-flight auth race (Codex deepening round-1 P1). - Sign-in handoff orchestrator reads localStorage, calls api.followedCountries.mergeAnonymousLocal, clears localStorage on success, subscribes to listFollowed reactive query. - handoffPending UX: addCountry/removeCountry return HANDOFF_PENDING so FollowButton can show a syncing tooltip; getFollowed unions localStorage with the user-scoped subscription snapshot for stable display, BUT only if snapshot.userId matches current Clerk userId (Codex deepening round-2 P1 — no cross-user leak). - Sign-out / user-A → user-B: increments _handoffGeneration, unsubscribes, CLEARS _lastKnownSubscriptionSnapshot = null, resets _handoffState. localStorage retained for next anon session. - Network failure: _handoffState = 'failed', visibilitychange retry. Idempotency means safe to re-run mergeAnonymousLocal. - Convex error → reason mapping via err.data.kind (memory: convex-error-string-data-strips-errordata-on-wire). FREE_CAP preserves currentCount + limit. - Cap-drop event: when mergeAnonymousLocal returns droppedDueToCap[], dispatch WM_FOLLOWED_COUNTRIES_CAP_DROP for the upgrade-CTA toast (consumed by U4 FollowButton). 24 new sign-in handoff tests covering: empty/corrupt localStorage skip + clean, free-tier bounded accept with cap-drop event, network failure → visibilitychange retry, in-flight auth-race sign-out (gen guard drops result), in-flight user-swap (userIdAtStart guard drops result), HANDOFF_PENDING blocks writes, getFollowed user-scoped union, sign-out clears snapshot, sign-in→sign-out→different-user flow, reactive snapshot updates. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U3 * feat(follow-button): reusable star button helper for 3 surfaces (U4) Single mountable factory used by CountryDeepDivePanel, CountryIntelModal, and CIIPanel rows in U5. Owns: - Visual states: outlined-star (unfollowed), filled-star (followed), spinner (entitlement loading), hidden (feature flag off). At-cap state shows 'Upgrade to follow more' tooltip pre-click. - Click handler: addCountry/removeCountry. FREE_CAP triggers the existing upgrade flow (mirroring notifications-settings.ts lazy- import pattern: openSignIn for anon, startCheckout for signed-in, fallback to /pro#pricing). HANDOFF_PENDING / DISABLED / loading → defensive no-op. - Reactivity: subscribes to WM_FOLLOWED_COUNTRIES_CHANGED and onEntitlementChange; teardown unsubscribes both. Idempotent teardown. - Anonymous-vs-loading distinction (Codex round-2 P1): driven by serviceEntitlementState() helper, NOT raw getEntitlementState(). Anonymous users render interactive (state a/b), only signed-in- awaiting-snapshot enters spinner state. Adds isFollowFeatureEnabled() exported helper to the service so the button gates on the same source of truth as the service. 18 tests covering visual states, anonymous click flow, entitlement- loading window with PRO/FREE resolution, subscription + teardown. Cap-drop toast (WM_FOLLOWED_COUNTRIES_CAP_DROP from U3) is NOT wired here — left as TODO for App-level toast service. CSS is semantic class names only; styling lands in PR B. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U4 * feat(panels): mount FollowButton on Deep Dive, Intel Modal, CII rows (U5) Surfaces the followed-countries primitive on the three PR-A entry points. No other behavior changes (pin-to-top, filter chips, brief weighting are PR B / PR C). CountryDeepDivePanel: - FollowButton (size: md) inserted in header next to title - Teardown wired into resetPanelContent() + hide(); idempotent CountryIntelModal: - FollowButton (size: md) in header between country name and level - Mount on show(); teardown on showLoading()/hide(); idempotent CIIPanel: - FollowButton (size: sm) as first child of every .cii-country row - Map<countryCode, teardown> tracks per-row mounts; cleared on every wholesale rebuild + on destroy() to prevent listener leaks - Click stopPropagation so star toggle does NOT also trigger row-level onCountryClick (mirrors the existing cii-share-btn pattern) Semantic class names only (.wm-follow-btn + per-host wrappers); CSS lands in PR B's UX polish. Verification: npm run typecheck + typecheck:api clean; full test:data suite still green (7898/7898). Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U5 * fix(followed-countries): convex server-side hardening pass (Phase 1) P3 #21: followCountry now reads entitlement tier BEFORE collecting all user rows; PRO callers skip the O(N) `.collect()` of followedCountries since they have no cap to check. Free users are unchanged. P2 #12: countFollowers privacy-floor doc/code alignment — comment now matches the `<` comparator (1-4 followers → 0; 5+ → exact count). P2 #19: /relay/followed-countries userId validation tightened to mirror /relay/user-preferences rigor — non-empty string with bounded length (<=256 chars) instead of just truthy. Mitigates oversized / non-string abuse vectors. P2 #13: ISO-2 dual-registry parity test upgraded from size-only (`.size === 239`) to set-equality. Catches drift where one side has, e.g., 'XK' and the other has 'EU' with the same total count. `ISO2_TO_ISO3` is now exported from `src/utils/country-codes.ts`. Tests: 278 → 283 (added 1 set-equality, 1 PRO-skip-collect, 3 relay userId validation tests). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(followed-countries): service-core hardening pass (Phase 2) P1 #3: _runHandoff catch now uses _extractConvexErrorKind. Permanent ConvexError kinds (INPUT_TOO_LARGE, EMPTY_INPUT, UNAUTHENTICATED) skip the visibilitychange retry path: clear localStorage, transition to new 'failed-permanent' state, install the reactive subscription so signed-in reads still work. Transient errors (network / undefined kind) still retry. P1 #4: max-retry counter (5) + exponential backoff (1, 2, 4, 8, 16 seconds) gate the visibilitychange retry path. After exhaustion the state flips to 'failed-permanent' and no further retries are scheduled. _clearFailedHandoffForTests() exposed as the test recovery hook. Production has no equivalent today; sign-out / sign-in starts a fresh generation. Test seam _setHandoffBackoffForTests collapses the backoff schedule so tests don't have to wait seconds. P1 #5: signed-in addCountry/removeCountry now return HANDOFF_PENDING when the convex client is null instead of falling back to localStorage. Stale partial-writes that never reconcile with the authoritative table are no longer possible in signed-in mode. _setDepsForTests gains a 'force-null' literal so test injection can return null without falling through to the production importer. P1 #6: dropped the `if (existing.includes(code)) return {ok:true}` short-circuit on the SIGNED-IN branch of addCountry/removeCountry. The Convex mutation is itself idempotent and authoritative; the client-side snapshot is eventually consistent and could lie (e.g., another tab just unfollowed). Anonymous-mode short-circuit retained because localStorage IS the source of truth there. P1 #8: replaced unknown-typed ConvexClientLike/ConvexApiLike with FunctionReference<...> generics from convex/server. Mutation arg/result shapes (e.g., {country: code} vs {countries: code}) are now checked at the call site, eliminating the entire typo class. P1 #9: imports MergeAnonymousLocalResult from convex/followedCountries instead of hand-rolling an inline subset. P1 #10: cross-tab `storage` event listener installed alongside the auth-state listener. Filters on key === FOLLOWED_COUNTRIES_STORAGE_KEY and re-dispatches as WM_FOLLOWED_COUNTRIES_CHANGED so FollowButtons in other tabs re-render after a Tab-A mutation. P1 #11: signed-in addCountry/removeCountry capture {userIdAtStart, genAtStart} BEFORE the await, then call _authStillMatches() after the await. A sign-out / user-swap mid-mutation surfaces as HANDOFF_PENDING instead of letting user-A's success "land" while we're already user-B. P2 #20: empty-handoff path defers dispatchChanged until the first reactive snapshot lands. Tracks _initialSnapshotReceived; getFollowed() falls back to localStorage during the gap between 'complete' being set and the first onUpdate callback firing. Avoids a brief flash of empty-list rendering during the subscription warm-up window. Tests: data 7898 → 7911 (added 13 — INPUT_TOO_LARGE/EMPTY_INPUT/ UNAUTHENTICATED permanent-kind handling, plain-error transient guard, max-retry exhaustion + recovery hook, client-null HANDOFF_PENDING for both add and remove, P1 #6 stale-snapshot non-short-circuit, cross-tab storage event re-dispatch x2, post-await auth re-check, empty-handoff deferred dispatch). Convex 283/283 unchanged. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(follow-button): UI hardening — exhaustive switch + inFlight guard (Phase 3) P2 #16: added `assertNever(result)` default branch to the FollowButton onClick switch on `FollowMutationResult.reason`. When every variant is handled by a `case`, `result` narrows to `never` at the default; adding a new reason to `FollowMutationResult` will widen the residual type and produce a TS2345 ('not assignable to never') at typecheck time. Catches the future-variant bug class at compile time, with a runtime fallback for malformed test fakes. P2 #17: introduced an `inFlight` boolean closed over by the click handler. When a mutation is already pending, additional clicks are dropped silently (no duplicate addCountry/removeCountry fires). Cleared in finally{} so a thrown service-layer error doesn't latch the button. Without this, a rapid double-click on an unfollowed button produced TWO follow mutations — the service is idempotent on (user, country) but the second add was wasted network + counter-increment work and a toggle pattern (click on, click off) could land in the unintended state. Tests: data 7911 → 7913 (added inFlight rapid-double-click suppression test + assertNever runtime-guard sanity test). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * chore(env): document VITE_FOLLOW_COUNTRIES_ENABLED in .env.example (Phase 4) P2 #18: adds the missing .env.example entry for the followed-countries feature flag. Mirrors the format of sibling flags (VITE_CLOUD_PREFS_ENABLED) and clarifies the default-on-unless-'0' semantics enforced by `isFeatureFlagEnabled()` in src/services/followed-countries.ts. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(followed-countries): per-user serialization doc — close TOCTOU cap-bypass (P0) Codex round-3 review run 20260502-195816-dae403d7 (adv-001 / adv-002) flagged a P0 cap-bypass on `followCountry` and `mergeAnonymousLocal`. Convex per-document OCC tracks reads at the DOCUMENT level, not at the index-range level — so two parallel `followCountry` mutations from the same user can both read empty/ under-cap from `followedCountries` (an index range), both pass the cap check, and both insert. Cap bypass + potential duplicate (userId, country) rows. The same shape applies to `mergeAnonymousLocal` from N tabs at sign-in. Mitigation: a per-user serialization document (new table `followedCountriesUserMeta`) that every mutation reads AND writes. Convex's real OCC then forces concurrent same-user mutations to serialize on this row: the loser of the race retries, re-reads the post-winner state (which now contains the winner's `(userId, country)` row + bumped count), and either passes correctly (still under cap), throws `FREE_CAP`, or returns idempotent. The denormalized `count` field also makes the cap check O(1) — happy side effect that closes P3 #21 (`.collect()` for cap purposes is gone). Schema: new table `followedCountriesUserMeta` keyed by userId, with a denormalized `count` and `updatedAt`. Convex auto-deploys schema before handlers in the same push, so single-PR shipping is safe. Mutations: - `followCountry`: read meta first, use `count` for cap check (free tier only), patch/insert meta at the END after row insert + counter +1. Idempotent (already-followed) path skips meta write — no observable change, no race to lose. - `unfollowCountry`: read meta first, decrement to `Math.max(0, count - 1)` at the end. Idempotent (no-row) path skips meta write. - `mergeAnonymousLocal`: read meta for the cap denominator, `existingRows` still required for the dedup set. Patch meta with `existingCount + accepted.length` at the end. Skip meta write when `accepted.length === 0`. Tests (+9, total 283 → 292): - Concurrent same-user same-country `Promise.all` → exactly 1 row + 1 idempotent response. - Concurrent same-user cap-boundary (2 seeded + 2 attempts on cap=3) → exactly 1 fulfilled, 1 rejected with FREE_CAP, final ≤ cap. - Concurrent mixed follow/unfollow → consistent end state, parity invariant. - Concurrent `mergeAnonymousLocal` from 5 tabs (free user) → final ≤ cap, no duplicate (userId, country) rows. - Concurrent `mergeAnonymousLocal` from 5 tabs (PRO user) → exactly the deduped union, all per-country counters at 1. - Meta-count parity invariant after mixed mutation sequence. - Idempotent paths (followCountry on existing, unfollowCountry on absent) don't bump/decrement meta count. - FREE_CAP throw rolls back all writes (transaction atomicity). Concurrency-test caveat documented in the test file: convex-test 0.0.43's TransactionManager (node_modules/convex-test/dist/index.js:1268) takes a single `_waitOnCurrentFunction` lock at top-level mutation begin, so `Promise.all` of mutations runs strictly sequentially in the mock. There is NO real OCC retry simulator. Tests therefore prove the FINAL-STATE INVARIANT — even when the second mutation runs back-to-back against the post-winner state, cap/idempotency/meta-parity hold. In production the Convex platform's OCC layer turns the same final-state invariant into the cap-bypass guarantee. See memory `convex-occ-retry-vs-app-cas-conflict- different-layers` for the layer separation. Test helper `seedFollowedCountries(t, userId, codes)` added to seed both the rows AND the user-meta row in parity for tests that bypass the mutation API. Files: - convex/schema.ts: +`followedCountriesUserMeta` table + index. - convex/followedCountries.ts: +`readUserMeta` / `writeUserMeta` helpers, meta read/write inserted into all 3 mutation paths, expanded inline docs on the OCC mechanism. - convex/__tests__/followed-countries-mutations.test.ts: +9 concurrent / parity tests + `seedFollowedCountries` helper + `readUserMetaCount` helper + caveat block on convex-test's serialized mock. Verification: `npm run typecheck` ✅, `npm run typecheck:api` ✅, `npm run test:convex` 292 / 292 ✅. * fix(followed-countries): pre-seeded sharded lock — close nested TOCTOU on user-meta create (P0 v2) Codex round-4 review of PR #3621 caught that the round-3 P0 fix (followedCountriesUserMeta per-user lock) did not actually close the cap-bypass: the meta document is created LAZILY on first mutation, and Convex per-document OCC tracks reads at the document level, not at the empty-index-range level. Two parallel first-ever mutations from the same brand-new user could both read meta=undefined and both INSERT, producing duplicate meta rows that break the next .unique() read AND re-open cap-bypass / counter double-increment. Fix: Approach B (pre-seeded sharded lock). - New table followedCountriesShards with one row per shard id in [0, SHARD_COUNT) (SHARD_COUNT=64 in convex/constants.ts), pre-seeded by _seedShards. - convex/lib/shards.ts::userIdToShard(userId) — deterministic djb2 hash. Frozen contract; changing it would silently remap users. - Every followCountry / unfollowCountry / mergeAnonymousLocal mutation reads its shard at the top (throws SHARDS_NOT_SEEDED loud if missing — operator error, never silent), then patches lastTouchedAt at the end of any non-idempotent path. The read+write pair on an ALREADY-EXISTING document is what triggers Convex OCC to serialize concurrent same-user mutations. - Tier 2 (existing user-meta row) kept additionally for the O(1) cap-check denominator and parity invariant — but its lazy create is now race-free under the shard lock. - Daily cron followed-countries-shards-seed at 03:00 UTC re-runs _seedShards (idempotent) so a missed deploy-seed step self-heals. - Public seedShards mutation for operator CLI: npx convex run --prod followedCountries:seedShards after a fresh deploy without waiting for the cron. Tests added (mutations test file, +6 tests, 292 -> 298): - first-ever follow on a brand-new user creates exactly 1 meta row - two back-to-back mergeAnonymousLocal calls on a brand-new user -> one meta row, no duplicates - operator running _seedShards after partial seed completes idempotently (0 steady-state, plugs holes only) - SHARDS_NOT_SEEDED throws when shards table is empty (operator error path, all three mutations) - public seedShards mutation reachable via operator CLI surface - userIdToShard determinism + range invariant Test fixtures (makeT() helper) call _seedShards before any mutation runs, mirroring the production deploy + cron post-condition. Files changed: - convex/constants.ts: SHARD_COUNT=64 - convex/schema.ts: followedCountriesShards table + index - convex/lib/shards.ts (new): userIdToShard djb2 - convex/followedCountries.ts: shard read/write at every mutation, _seedShards (internal) + seedShards (public operator) mutations - convex/crons.ts: daily _seedShards cron at 03:00 UTC - convex/__tests__/followed-countries-mutations.test.ts: makeT() helper, 6 new tests - convex/__tests__/followed-countries-queries.test.ts: makeT() helper (queries-test invokes followCountry, also needs shards) References: Codex review run /private/tmp/worldmonitor-pr3621-review/ findings_round4.md (P0 v2). Memory: convex-occ-retry-vs-app-cas-conflict-different-layers (layer separation: this is the app-side serialization layer that lets Convex OCC do its job). * fix(followed-countries): treat UNAUTHENTICATED as transient in handoff retry (P1) Codex round-4 P1: subscribeAuthState emits the current signed-in state IMMEDIATELY on subscribe, but Convex auth is not yet ready (the JWT has not been attached to the Convex client at that tick). mergeAnonymousLocal fires before Convex sees the auth -> throws ConvexError({kind:'UNAUTHENTICATED'}). The previous classification (Phase-2 P1 #3) put UNAUTHENTICATED in the PERMANENT-error list alongside INPUT_TOO_LARGE / EMPTY_INPUT, so every transient auth lag cleared localStorage and lost the anonymous follows. Two-part fix: (a) Treat UNAUTHENTICATED as TRANSIENT, not permanent. _runHandoff catch path no longer routes UNAUTHENTICATED to failed-permanent + removeLocalStorage. It falls through to _markFailedAndScheduleRetry, which arms the visibilitychange retry and counts toward MAX_HANDOFF_RETRIES (5). A genuinely-stuck auth mismatch eventually flips to failed-permanent after the budget is exhausted, same as the network-failure path. (b) Defer the merge until Convex auth is ready. waitForConvexAuth() exists at src/services/convex-client.ts:79 — it resolves when Convex's setAuth callback confirms the client is authenticated, with a 10s timeout. We import it and await it BEFORE the mergeAnonymousLocal call so the typical race never fires at all. On timeout we still attempt the call; the catch from (a) treats any resulting UNAUTHENTICATED as transient and the visibilitychange retry wins once Convex catches up. Test seam: _waitForConvexAuthFn module-level binding + new _setDepsForTests({waitForConvexAuth}) override so tests can drive the deferred-by-auth flow without going through the real Convex client. Tests added (tests/followed-countries-sign-in-handoff.test.mjs, +3 new tests, 1 modified — 37 -> 40 in this file): - first call throws UNAUTHENTICATED, visibility retry succeeds -> final state has merged data, localStorage cleared, no follows lost (the canonical scenario this fix targets) - UNAUTHENTICATED IS counted toward MAX_HANDOFF_RETRIES — 5 consecutive UNAUTHENTICATED throws -> failed-permanent (proves runaway-retry guard intact for genuinely-stuck auth) - waitForConvexAuth is awaited BEFORE the merge call (proves deferred-by-auth path is wired correctly) Modified: the original "UNAUTHENTICATED -> 'failed-permanent'; localStorage cleared" test is rewritten to assert the new behavior (state='failed', retry armed, localStorage retained) with an inline comment explaining the previous behavior was wrong. Files changed: - src/services/followed-countries.ts: import waitForConvexAuth from convex-client, _waitForConvexAuthFn seam, await before merge, drop UNAUTHENTICATED from permanent-error branch - tests/followed-countries-sign-in-handoff.test.mjs: 1 modified + 3 new tests References: Codex review run /private/tmp/worldmonitor-pr3621-review/ findings_round4.md (P1). waitForConvexAuth helper found at src/services/convex-client.ts:79 — exists today and is used by the existing entitlement subscription path; this fix wires it into the followed-countries handoff for the same reason. * fix(ci): seed followedCountries shards on Convex deploy (P1) The followCountry / unfollowCountry / mergeAnonymousLocal mutations throw SHARDS_NOT_SEEDED if followedCountriesShards is empty. Today the seed runs only via the 03:00 UTC daily cron, so a deploy landing at 04:00 UTC would leave the feature broken for ~23h until the next cron tick. Run npx convex run --prod followedCountries:_seedShards inline after npx convex deploy --yes so the table is populated before any traffic hits the new mutations. Idempotent: existing shard rows are skipped, only missing ids in [0, SHARD_COUNT) are inserted. * fix(followed-countries): race-tolerant shard seed + dedupe cron + remove public seed (P1) Two stacked P1 issues from Codex round-3 review of PR #3621: 1. Public seedShards mutation was unauthenticated — any browser ConvexHttpClient could call it. Removed; the post-deploy CI step now targets the internal _seedShards directly via `npx convex run --prod followedCountries:_seedShards` (npx convex run resolves internal functions by file:export path). 2. _seedShards has a TOCTOU race: two simultaneous calls against an empty table both read empty, both insert the full range, producing 2 rows per shardId. Previously readShardOrThrow used .unique() which throws on duplicates → bricks the affected shard for all users hashing to it. Approach D (real-world correct): make readShardOrThrow tolerant of duplicates via .first() (returns oldest by _creationTime tiebreaker, so OCC contention is preserved across all in-flight mutations on that shard during the duplicate window) AND add a daily _dedupeShards cron that deletes extras keeping the oldest row per shardId. Tests: - duplicate shard rows: mutation succeeds, counter parity holds - _dedupeShards: zero dups → no-op; N dups → reduces to 1 row per shardId, oldest survives - _seedShards idempotent under back-to-back concurrent re-run - public seedShards no longer exported (source-text negative assertion) Net: 298 → 302 tests, all green. * feat(follow-button): minimal CSS for star button + host layouts (PR A foundation) Third-pass review P2: PR A's src/utils/follow-button.ts:220 emits .wm-follow-btn* markup mounted on three live surfaces (CIIPanel, CountryDeepDivePanel, CountryIntelModal) but no CSS shipped — buttons rendered as native browser controls and the CIIPanel host (a span inside a block-layout .cii-country row) put the star on its own line. Visual analogue: .cii-share-btn (transparent + 1px var(--border) + var(--text-muted) text + var(--semantic-info) hover). Same border-radius family, same micro-padding scale. CSS variables used: --border, --border-strong, --text-muted, --semantic-info. Reuses the existing @Keyframes spin. Critical layout fix (CIIPanel): added position:relative to .cii-country and absolute-positioned .cii-follow-btn-host at top:6px left:6px. The :not(:empty) gate keeps the rule a no-op when the feature flag is off (handle.html === ''), and the sibling-combinator rule .cii-follow-btn-host:not(:empty) ~ .cii-header { padding-left:26px } shifts header content right ONLY when the star is mounted — flag-off rows render identically to today. CDP + CountryIntelModal hosts are simple display:inline-flex since both parent containers (.cdp-header-left, .country-intel-title) are already flex with gap. Polish (color tuning, hover transitions, animation curves, empty/cap nudge styling) intentionally deferred to PR B per the third-pass review. +156 lines (single appended block in src/styles/main.css). * fix(followed-countries): serialize country counters * fix(followed-countries): register picker global
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Description
src/config/protests.tswhich definesPROTEST_FEEDSandPROTEST_LOCATIONSfor feed sources and location keyword mapping.src/services/protests.tswithfetchProtestEvents()that matches titles to locations, extracts tags, and derivesseverity; exported viasrc/services/index.ts.src/App.ts(addedfetchProtestEvents,loadProtests, merged storedDEFAULT_MAP_LAYERS) and added status updates to theStatusPanel.src/components/Map.tsto compute clusters (calculateProtestClusters), renderprotest-markers, compute distances (getDistanceKm), exposesetProtests, and factor protests intoupdateHotspotActivity; added popup rendering insrc/components/MapPopup.ts, type definitions insrc/types/index.ts, and styles insrc/styles/main.css.Testing
Codex Task