mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
main
93 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0500733541 |
feat(variants): wire energy.worldmonitor.app subdomain (gaps #9-11) (#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. |
||
|
|
38f7002f19 |
fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock (#3357)
* fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock
Buyers completing a Dodo checkout on the subscription-trial flow get
stranded on Dodo's "Payment successful" page indefinitely. HAR evidence
(session cks_0NdL9xlzrFFNivgTeGFU9 / pay_0NdLA3yIfX3BVDoXrFltx, live):
after 3DS succeeds, Dodo's iframe navigates to
/status/{id}/wallet-return?status=succeeded and then emits nothing --
no checkout.status, no checkout.redirect_requested postMessage. Our
onEvent handler never runs, so onSuccess / banner / redirect never
fire. Prior PRs #3298, #3346, #3354 all depended on Dodo emitting a
terminal event; this path emits none.
Fix: merchant-side entitlement watchdog in both the /pro bundle
(pro-test/src/services/checkout.ts) and the dashboard bundle
(src/services/checkout.ts). When the overlay is open, poll
/api/me/entitlement every 3s with a 10min cap. When the webhook flips
the user to pro, close the stuck overlay and run the post-checkout
side effects -- independent of whatever Dodo's iframe does. Existing
event-driven paths are preserved unchanged (they remain the fast path
for non-wallet-return checkouts); the watchdog is the floor.
Idempotency via a successFired closure flag; both the event handler
and the watchdog route through the same runTerminalSuccessSideEffects
function, making double-fires impossible. checkout.closed stops the
watchdog cleanly on cancel.
Observability: Sentry breadcrumb with reason tag on every terminal
success, plus captureMessage at info level when the watchdog resolves
it -- countable signal for prevalence tracking while Dodo investigates.
Rebuilt public/pro/ bundle (index-CiMZEtgt.js to index-QpSvSkuY.js).
Plan: docs/plans/2026-04-23-002-fix-dodo-checkout-entitlement-watchdog-plan.md
Skill: .claude/skills/dodo-wallet-return-skips-postmessage/SKILL.md
* fix(checkout): stop watchdog on destroyCheckoutOverlay to prevent orphan side effects
Greptile P1 on #3357. destroyCheckoutOverlay cleared initialized and
onSuccessCallback but never called _resetOverlaySession, so if the
dashboard layout unmounted mid-checkout the watchdog setInterval kept
running inside the closed-over scope. On entitlement flip, the orphaned
watchdog would fire clearCheckoutAttempt / clearPendingCheckoutIntent /
markPostCheckout / safeCloseOverlay against whatever session was active
by then -- stepping on a new checkout's state or silently closing a
fresh overlay.
Fix: call _resetOverlaySession before dropping references, and null it
out after. _resetOverlaySession is the only accessor for the closure's
stopWatchdog so it must run before the module-scoped slot is cleared.
* test(checkout): extract testable entitlement watchdog + state-machine tests
Greptile residual risk on #3357: the watchdog state machine had no
targeted automated coverage, especially the wallet-return path where
no terminal Dodo event arrives and success is detected only via
entitlement polling.
Extract the watchdog into src/services/entitlement-watchdog.ts as a
pure DI module (fetch / setInterval / clock / token source / onPro
all injected). Mirror the file at pro-test/src/services/entitlement-
watchdog.ts since the two bundles have no cross-root imports (pro-test
alias '@' resolves to pro-test root only). Both src/services/checkout.ts
and pro-test/src/services/checkout.ts now consume createEntitlement-
Watchdog instead of inlining setInterval.
Tests cover the wallet-return scenario explicitly plus the full state
matrix:
- wallet-return path: isPro flips to true -> onPro fires exactly once
- timeout cap: isPro stays false past timeoutMs -> self-terminate
WITHOUT firing onPro
- missing token: tick no-ops, poller keeps trying
- non-2xx response (401/5xx): tick swallows, poller continues
- fetch rejection: tick swallows, poller continues
- idempotence: onPro never fires twice across consecutive pro ticks
- stop(): clears interval immediately, onPro never called
- double-start while active: second start is a no-op
- start after prior onPro: no-op (post-success reuse guard)
Parity test (tests/entitlement-watchdog-parity.test.mts) asserts the
two mirror files are byte-identical so drift alarms at CI time.
Rebuilt public/pro/ bundle (index-QpSvSkuY.js -> index-C-qy2Yt9.js).
|
||
|
|
def94733a8 |
feat(agent-readiness): Agent Skills discovery index (#3310) (#3355)
* feat(agent-readiness): Agent Skills discovery index (#3310) Closes #3310. Ships the Agent Skills Discovery v0.2.0 manifest at /.well-known/agent-skills/index.json plus two real, useful skills. Skills are grounded in real sebuf proto RPCs: - fetch-country-brief → GetCountryIntelBrief (public). - fetch-resilience-score → GetResilienceScore (Pro / API key). Each SKILL.md documents endpoint, auth, parameters, response shape, worked curl, errors, and when not to use the skill. scripts/build-agent-skills-index.mjs walks every public/.well-known/agent-skills/<name>/SKILL.md, sha256s the bytes, and emits index.json. Wired into prebuild + every variant build so a deploy can never ship an index whose digests disagree with served files. tests/agent-skills-index.test.mjs asserts the index is up-to-date via the script's --check mode and recomputes every sha256 against the on-disk SKILL.md bytes. Discovery wiring: - public/.well-known/api-catalog: new anchor entry with the agent-skills-index rel per RFC 9727 linkset shape. - vercel.json: adds agent-skills-index rel to the homepage + /index.html Link headers; deploy-config required-rels list updated. Canonical URLs use the apex (worldmonitor.app) since #3322 fixed the apex redirect that previously hid .well-known paths. * fix(agent-readiness): correct auth header + harden frontmatter parser (#3310) Addresses review findings on #3310. ## P1 — auth header was wrong in both SKILL.md files The published skills documented `Authorization: Bearer wm_live_...`, but WorldMonitor API keys must be sent in `X-WorldMonitor-Key`. `Authorization: Bearer` is for MCP/OAuth or Clerk JWTs — not raw `wm_live_...` keys. Agents that followed the SKILL.md verbatim would have gotten 401s despite holding valid keys. fetch-country-brief also incorrectly claimed the endpoint was "public"; server-to-server callers without a trusted browser origin are rejected by `validateApiKey`, so agents do need a key there too. Fixed both SKILL.md files to document `X-WorldMonitor-Key` and cross-link docs/usage-auth as the canonical auth matrix. ## P2 — frontmatter parser brittleness The hand-rolled parser used `indexOf('\n---', 4)` as the closing fence, which matched any body line that happened to start with `---`. Swapped for a regex that anchors the fence to its own line, and delegated value parsing to js-yaml (already a project dep) so future catalog growth (quoted colons, typed values, arrays) does not trip new edge cases. Added parser-contract tests that lock in the new semantics: body `---` does not terminate the block, values with colons survive intact, non-mapping frontmatter throws, and no-frontmatter files return an empty mapping. Index.json rebuilt against the updated SKILL.md bytes. |
||
|
|
d75bde4e03 |
fix(agent-readiness): host-aware oauth-protected-resource endpoint (#3351)
* fix(agent-readiness): host-aware oauth-protected-resource endpoint
isitagentready.com enforces that `authorization_servers[*]` share
origin with `resource` (same-origin rule, matches Cloudflare's
mcp.cloudflare.com reference — RFC 9728 §3 permits split origins
but the scanner is stricter).
A single static file served from 3 hosts (apex/www/api) can only
satisfy one origin at a time. Replacing with an edge function that
derives both `resource` and `authorization_servers` from the
request `Host` header gives each origin self-consistent metadata.
No server-side behavior changes: api/oauth/*.js token issuer
doesn't bind tokens to a specific resource value (verified in
the previous PR's review).
* fix(agent-readiness): host-derive resource_metadata + runtime guardrails
Addresses P1/P2 review on this PR:
- api/mcp.ts (P1): WWW-Authenticate resource_metadata was still
hardcoded to apex even when the client hit api.worldmonitor.app.
Derive from request.headers.get('host') so each client gets a
pointer matching their own origin — consistent with the host-
aware edge function this PR introduces.
- api/oauth-protected-resource.ts (P2): add Vary: Host so any
intermediate cache keys by hostname (belt + suspenders on top of
Vercel's routing).
- tests/deploy-config.test.mjs (P2): replace regex-on-source with
a runtime handler invocation asserting origin-matching metadata
for apex/www/api hosts, and tighten the api/mcp.ts assertion to
require host-derived resource_metadata construction.
---------
Co-authored-by: Elie Habib <elie@worldmonitor.app>
|
||
|
|
64edfffdfc |
fix(checkout): implement checkout.redirect_requested — the Dodo handler we were missing (#3346)
* fix(checkout): implement checkout.redirect_requested — the Dodo handler we were missing (revert #3298) Buyers got stuck on /pro after successful Dodo payment because NEITHER pro-test nor dashboard checkout services handled `checkout.redirect_requested` — the event Dodo's SDK fires under `manualRedirect: true` carrying the URL the MERCHANT must navigate to. We were only listening for `checkout.status`, so navigation never happened for Safari users (saw the orphaned about:blank tab). PR #3298 chased the wrong theory (flipped /pro to `manualRedirect: false`, hoping the SDK would auto-navigate). Dodo docs explicitly say that mode disables BOTH `checkout.status` AND `checkout.redirect_requested` ("only when manualRedirect is enabled"), and the SDK's internal redirect is where Safari breaks. Fix: - Revert /pro to `manualRedirect: true` - Add `checkout.redirect_requested` handler in BOTH /pro and dashboard: `window.location.href = event.data.message.redirect_to` - Align `status` read to docs-documented `event.data.message.status` only (drop legacy top-level `.status` guess) - `checkout.link_expired` logged to Sentry (follow-up if volume warrants UX) - Rebuilt public/pro/ bundle on Node 22 (new hash: index-CiMZEtgt.js) Docs: https://docs.dodopayments.com/developer-resources/overlay-checkout ## Test plan - [ ] Vercel preview: complete Dodo test-mode checkout on /pro with 4242 card. Verify console shows `[checkout] dodo event checkout.status {status: "succeeded"}` followed by `checkout.redirect_requested`, and the tab navigates to worldmonitor.app/?wm_checkout=success&... WITHOUT an about:blank second tab. - [ ] Vercel preview: same flow with a 3DS-required test card. - [ ] Vercel preview: dashboard in-app upgrade click → overlay → success → same-origin navigation lands on worldmonitor.app with Dodo's appended ?payment_id=...&status=succeeded&... - [ ] Post-deploy: Sentry breadcrumbs show full event sequence on every success; no new "stuck after paying" user reports in 24h. ## Rollback Single `git revert` + bundle rebuild. Fallback state is PR #3298's broken-for-Safari `manualRedirect: false`. * chore(ci): retrigger Vercel preview build — initial push skipped via ignore-step |
||
|
|
9c3c7e8657 |
fix(agent-readiness): align OAuth resource with public MCP origin (#3345)
* fix(agent-readiness): align OAuth resource with actual public MCP origin isitagentready.com's OAuth Protected Resource check enforces an origin match between the scanned host and the metadata's `resource` field (per the spirit of RFC 9728 §3). Our metadata declared `resource: "https://api.worldmonitor.app"` while the MCP endpoint is publicly served at `https://worldmonitor.app/mcp` (per vercel.json's /mcp → /api/mcp rewrite and the MCP card's transport.endpoint). Flip `resource` to `https://worldmonitor.app` across the three places that declare it: - public/.well-known/oauth-protected-resource - public/.well-known/mcp/server-card.json (authentication block) - api/mcp.ts (two WWW-Authenticate resource_metadata pointers) `authorization_servers` intentionally stays on api.worldmonitor.app — that's where /oauth/{authorize,token,register} actually live. RFC 9728 permits AS and resource to be at different origins. No server-side validation breaks: api/oauth/*.js and api/mcp.ts do not bind tokens to the old resource value. * fix(agent-readiness): align docs/tests + add MCP origin guardrail Addresses P1/P2 review on this PR. The resource-origin flip in the previous commit only moved the mismatch from apex to api unless the repo also documents apex as the canonical MCP origin. - docs/mcp.mdx: swap api.worldmonitor.app/mcp -> worldmonitor.app/mcp (OAuth endpoints stay on api.*, only the resource URL changes) - tests/mcp.test.mjs: same fixture update - tests/deploy-config.test.mjs: new guardrail block asserting that MCP transport.endpoint origin, OAuth metadata resource, MCP card authentication.resource, and api/mcp.ts resource_metadata pointers all share the same origin. Includes a regression guard that authorization_servers stays on api.worldmonitor.app (the intentional resource/AS split). |
||
|
|
dff14ed344 |
feat(agent-readiness): RFC 9727 API catalog + native openapi.yaml serve (#3343)
* feat(agent-readiness): RFC 9727 API catalog + native openapi.yaml serve Closes #3309, part of epic #3306. - Serves the sebuf-bundled OpenAPI spec natively at https://www.worldmonitor.app/openapi.yaml with correct application/yaml content-type (no Mintlify proxy hop). Build-time copy from docs/api/worldmonitor.openapi.yaml. - Publishes RFC 9727 API catalog at /.well-known/api-catalog with service-desc pointing at the native URL, status rel pointing at /api/health, and a separate anchor for the MCP endpoint referencing its SEP-1649 card (#3311). Refs PR #3341 (sebuf v0.11.1 bundle landed). * test(deploy-config): update SPA catch-all regex assertion The deploy-config guardrail hard-codes the SPA catch-all regex string and asserts its Cache-Control is no-cache. The prior commit added openapi.yaml to the exclusion list; this updates the test to match so the guardrail continues to protect HTML entry caching. * fix(agent-readiness): address Greptile review on PR #3343 - Extract openapi.yaml copy into named script `build:openapi` and prefix every web-variant build (build:full/tech/finance/happy/ commodity). prebuild delegates to the same script so the default `npm run build` path is unchanged. Swap shell `cp` for Node's cpSync for cross-platform safety. - Bump service-desc MIME type in /.well-known/api-catalog from application/yaml to application/vnd.oai.openapi (IANA-registered OpenAPI media type). Endpoint Content-Type stays application/yaml for browser/tool compatibility. * fix(agent-readiness): P1 health href + guardrail tests on PR #3343 - status.href in /.well-known/api-catalog was pointing at https://api.worldmonitor.app/health (which serves the SPA HTML, not a health response). Corrected to /api/health, which returns the real {"status":"HEALTHY",...} JSON from api/health.js. - Extend tests/deploy-config.test.mjs with assertions that would have caught this regression: linkset structure, status/service- desc href shapes, and presence of build:openapi across every web-variant build script. |
||
|
|
fe0e13b99e |
feat(agent-readiness): publish MCP Server Card at /.well-known/mcp/server-card.json (#3329)
SEP-1649 discovery manifest for the existing MCP server at api/mcp.ts (public endpoint https://worldmonitor.app/mcp, protocol 2025-03-26). Authentication block mirrors the live /.well-known/oauth-protected-resource doc byte-for-byte on resource, authorization_servers, and scopes. No $schema field — SEP-1649 schema URL not yet finalised. Closes #3311, part of epic #3306. |
||
|
|
3918cc9ea8 |
feat(agent-readiness): declare Content-Signal in robots.txt (#3327)
Per contentsignals.org draft RFC: declare AI content-usage preferences at the origin level. Closes #3307, part of epic #3306. Values: ai-train=no — no consent for model-training corpora search=yes — allow search indexing for referral traffic ai-input=yes — allow live agent retrieval (Perplexity, ChatGPT browsing, Claude, etc.) |
||
|
|
c489aa6dab |
fix(pro-marketing): /pro reflects entitlement — swap upgrade CTAs to dashboard link for Pro users (#3301)
* fix(pro-marketing): /pro reflects entitlement — swap upgrade CTAs for dashboard link when user is already Pro
Reported: a Pro subscriber visiting /pro was pitched "UPGRADE TO PRO"
in the nav + "CHOOSE YOUR PLAN" in the hero, even though the dashboard
correctly recognized them as Pro. The avatar bubble was visible (per
PR-3250) but the upgrade CTAs were unconditional — none of the 14 prior
rollout PRs added an entitlement check to the /pro bundle.
Root cause: pro-test (/pro) is a separate React bundle with no Convex
client and no entitlement awareness. PR-3250 made the nav auth-aware
(signed-in vs anonymous) but not entitlement-aware (pro vs free). A
paying Dodo subscriber whose Clerk `publicMetadata.plan` isn't written
(our webhook pipeline doesn't set it — documented in panel-gating.ts)
still sees the upgrade pitch.
## Changes
### api/me/entitlement.ts (new)
Tiny edge endpoint returning `{ isPro: boolean }` via the existing
`isCallerPremium` helper, which does the canonical two-signal check
(Clerk pro role OR Convex Dodo entitlement tier >= 1). `Cache-Control:
private, no-store` — entitlement flips when Dodo webhooks fire, and
/pro reads it on every load.
### pro-test/src/App.tsx — useProEntitlement hook + conditional CTAs
- New `useProEntitlement(signedIn)` hook. When signed in, fetches
`/api/me/entitlement` with the Clerk bearer token. Falls back to
`isPro: false` on any error — /pro stays in upgrade-pitch mode
rather than silently hiding the purchase path on a flaky network.
- Navbar: "UPGRADE TO PRO" → "GO TO DASHBOARD" (→ worldmonitor.app)
when `isLoaded && user && isChecked && isPro`. Free/anonymous users
see the original upgrade CTA unchanged.
- Hero: "CHOOSE YOUR PLAN" → "GO TO DASHBOARD" under the same condition.
Also removes the #pricing anchor jump which is actively misleading
for a paying customer.
- Deliberately delays the swap until the entitlement check resolves —
a one-frame flash of "Upgrade" for a free signed-in user is better
than a flash of "Go to Dashboard" for an unpaid visitor.
### Locale: en.json adds `nav.goToDashboard` + `hero.goToDashboard`
Other locales fall back to English via i18next's `fallbackLng` — no
translation files need updating for this change to work everywhere.
Bundle rebuilt on Node 22 to match CI.
## Post-Deploy Monitoring & Validation
- Test: sign in on /pro as an existing Pro user → nav shows
"GO TO DASHBOARD", hero CTA shows "GO TO DASHBOARD".
- Test: sign in on /pro as a free user → original "UPGRADE TO PRO" /
"CHOOSE YOUR PLAN" CTAs remain unchanged.
- Test: anonymous visitor → identical to pre-change behavior.
- Failure signal: any user report of "went to /pro as Pro user, still
saw upgrade" within 48h → rollback trigger. Check Sentry for
`surface: pro-marketing` + action `load-clerk-for-nav` or similar.
* fix(pro-marketing): address PR review — sebuf exception + Sentry on entitlement check
- api/api-route-exceptions.json: register /api/me/entitlement.ts as an
internal-helper exception. It's a thin wrapper over the canonical
isCallerPremium helper; the authoritative gates remain in panel-gating,
isCallerPremium, and gateway.ts PREMIUM_RPC_PATHS. This endpoint exists
only so the separate pro-test bundle (no Convex client) can ask the
same question without reimplementing the two-signal check. Unblocks
the sebuf API contract lint.
- Greptile P2: capture entitlement-check failures to Sentry to match
the useClerkUser catch-block pattern. Tag surface=pro-marketing,
action=check-entitlement.
* fix(pro-marketing): address PR review — retry-on-null-token + share entitlement state via context
Addresses reviewer P1 + P2 on PR #3301:
P1 — useProEntitlement treated a first null token as a final "not Pro"
result. Clerk can expose `user` before the session-token endpoint is
ready (same reason services/checkout.ts:getAuthToken retries once after
2s). Without retry, a real Pro user hitting /pro on a cold Clerk load
got a permanent isPro=false for the whole session, so the upgrade CTAs
stayed visible even after Clerk finished warming up. Fix: mirror the
checkout.ts retry pattern — try, sleep 2s, try again.
P2 — Navbar and Hero each called useProEntitlement(!!user), producing
two independent /api/me/entitlement fetches AND two independent state
machines that could disagree on transient failure (one 200, one 500 →
nav and hero showing different CTAs). Fix: hoist the effect into a
ProEntitlementProvider at the App root; Navbar and Hero now both read
from the same Context. One fetch per page load, one source of truth.
No behavior change for anonymous users or for successful Pro checks.
* fix(api/me/entitlement): distinguish auth failure from free-tier
Reviewer P2: returning 200 { isPro: false } for both "free user" and
"bearer missing/invalid" collapses the two states, making a /pro auth
regression read like normal free-tier traffic in edge logs / monitoring.
Fix: validate the bearer with validateBearerToken BEFORE delegating to
isCallerPremium. On missing/malformed/invalid bearer return 401
{ error: "unauthenticated" }; on valid bearer return 200 { isPro } as
before. /pro's client already treats any non-200 as isPro:false (safe
default), so no behavior change for callers — only observability
improves.
P1 (reviewer claim): PR-3298's wm_checkout=success bridge is not wired
end-to-end. NOT reproducible — src/services/checkout-return.ts lines
35-36, 52, and 100 already recognize the marker and return
{ kind: 'success' }, which src/app/panel-layout.ts:190 consumes via
`returnResult.kind === 'success'` to trigger showCheckoutSuccess. No
code change needed; the wiring landed in PR-3274 before PR-3298.
|
||
|
|
12365129c0 |
fix(pro-marketing): hotfix — Dodo auto-redirect on success (paid users stuck on /pro) (#3298)
* fix(pro-marketing): hotfix — let Dodo auto-redirect on success (manualRedirect=false) Production bug: user paid via /pro overlay, payment succeeded (wallet-return?status=succeeded&payment_id=...) but the parent window stayed at /pro#pricing with spinner visible. No success banner, no dashboard redirect. Root cause: with `manualRedirect: true`, the Dodo SDK defers redirect responsibility to the parent's onEvent handler waiting on `checkout.status === 'succeeded'`. In SDK 0.109.2 the iframe appears to navigate internally to its `wallet-return` page (two 404s on `/api/checkout/sessions/*/payment-link` visible in the user's console) without reliably postMessaging the success event to the parent — so our handler never fires, onSuccess never runs. Fix: set `manualRedirect: false` and include `?wm_checkout=success` in the returnUrl sent to /api/create-checkout. Dodo's SDK then performs the parent-window redirect itself via its built-in `checkout.redirect` → `window.location.href = redirect_to` path, landing on https://worldmonitor.app/?wm_checkout=success. The existing dashboard bridge (src/services/checkout-return.ts) already handles that marker and shows the success banner. Also: add `console.info('[checkout] dodo event', ...)` to every onEvent callback so a future regression in Dodo's event flow is diagnosable from Sentry breadcrumbs instead of a user-reported "stuck on spinner." ## Post-Deploy Monitoring & Validation - Logs: filter Sentry breadcrumbs for `[checkout] dodo event` — should see the full event sequence on every checkout completion. - Validation: complete a test live-mode purchase at /pro, confirm the parent window auto-navigates to worldmonitor.app/?wm_checkout=success and the success banner renders. - Failure signal: any user report of "stuck on /pro after paying" within 24h. Rollback trigger: same report. * fix(pro-marketing): rebuild bundle on Node 22 + address PR review - Rebuild public/pro/ with Node 22 (matches CI) — my Node 24 build produced different content-hashes, breaking the Pro bundle freshness check. - Greptile P2: scrub onEvent breadcrumb — only log event_type + status, not raw event.data (can contain PII: email, billing address, payment_id) which would leak via Sentry's console integration. - Greptile P2: add comment noting onSuccess is best-effort — with manualRedirect=false the SDK's `checkout.redirect` can race our `checkout.status=succeeded` handler. Authoritative success path is the `?wm_checkout=success` bridge. |
||
|
|
2765b46dad |
fix(pro-marketing): honor catalog CTA labels + same-tab for in-product hrefs (#3268)
Squashed rebase onto current main (which now includes #3263's CheckoutPhase machine + URL-intent resume). After #3263 merged, this branch's conflicts with main were mostly cosmetic comment drift on the phase machine docs (same logic, slightly different wording). Took main's versions for shared checkout.ts / App.tsx changes (already-merged and authoritative). Preserved this PR's unique contributions in PricingSection.tsx: 1. `isInProductHref`: catch relative paths without leading slash (`pricing`, `./dashboard`) — resolve against window.location so same-origin relative links don't get misclassified as external 2. CTA label override: honor per-tier `cta` from the catalog (e.g. "Start Pro", "Subscribe") instead of hardcoded "Get Started" Pro bundle rebuilt fresh. |
||
|
|
cfedcc3ea3 |
feat(pro-marketing): per-tier loading state on pricing checkout buttons (#3263)
Squashed rebase onto current main (which now includes #3262's interstitial + #3273's duplicate dialog + #3270's referral + #3274's overlay marker). Source-only diff extracted from merge-base; 2 conflicts in pro-test/src/services/checkout.ts resolved additively: 1. doCheckout entry: both interstitial mount (PR-5, from main) and setPhase(creating_checkout, productId) (PR-6) needed; kept both. 2. doCheckout finally: both unmountCheckoutInterstitial (PR-5, main) and setPhase(idle) (PR-6) needed; kept both. Changes: - pro-test/src/services/checkout-intent-url.ts (NEW): pure URL-param intent helpers (parseCheckoutIntentFromSearch, stripCheckoutIntent- FromSearch, buildCheckoutReturnUrl) - pro-test/src/services/checkout.ts: CheckoutPhase state machine, subscribeCheckoutPhase; bind intent to sign-in via afterSignInUrl - pro-test/src/components/PricingSection.tsx: subscribe to phase, per-tier loading + billing toggle lock - pro-test/src/App.tsx: tryResumeCheckoutFromUrl on mount - tests/pro-checkout-intent-url.test.mts: 18-test coverage including 3 reviewer scenario regression guards Pro bundle rebuilt fresh. |
||
|
|
e9d07949a9 |
feat(pro-marketing): "Opening checkout…" interstitial + 10s safety toast (#3262)
Squashed rebase onto current main (which now includes #3273 and #3270's pro-test changes). Source diff extracted from merge-base `git diff $(git merge-base origin/main HEAD) HEAD -- ':!public/pro/**'` applied cleanly — PR-5's interstitial mount/unmount sits in a different function location than PR-7's duplicate-subscription dialog, so no source conflicts. Changes: - pro-test/src/services/checkout.ts: mountCheckoutInterstitial at doCheckout entry (inside try/finally), auto-unmount on settle, 10s safety toast fallback if SDK lazy-load or network hangs Pro bundle rebuilt fresh against current source (which includes PR-7's duplicate-dialog + PR-9's overlay-success marker + PR-14's referral). |
||
|
|
dee3b97cfd |
feat(pro-marketing): /pro overlay success bridges to dashboard via ?wm_checkout=success (#3274)
Squashed rebase onto current main (which now includes #3259, #3260,
#3261, #3270, #3273). Source-only diff extracted via `git diff
|
||
|
|
f2ea87d1f1 |
feat(checkout): duplicate-subscription dialog + unified new-tab portal (#3273)
Squashed rebase of PR-7's 5 commits onto current main, which now
includes #3259/#3260/#3261/#3270. Source changes extracted via
`git diff
|
||
|
|
89d5dc6f59 |
feat(referral): propagate ref code /pro → dashboard via localStorage (#3270)
Squashed rebase of PR-14's 3 commits onto current main. Source changes extracted via `git diff` (excluding stale pro-bundle artifacts) and reapplied cleanly on main (which now includes #3259/#3260/#3261). Only import-section conflict in src/services/checkout.ts — resolved additively (kept all four parents' imports: checkout-attempt, error taxonomy, reload-unify, referral-capture). Changes: - src/services/referral-capture.ts (NEW): localStorage primitives (captureActiveReferral, loadActiveReferral, clearReferralOnAttribution) - src/App.ts: capture ref on boot - src/services/checkout.ts: read loadActiveReferral in startCheckout - src/services/checkout-attempt.ts: clearCheckoutAttempt also clears referral on success / signout (cross-user leak guard) - pro-test/src/App.tsx: validate ref code before appendRefToUrl - tests/referral-capture.test.mts: 11-test suite Pro bundle rebuilt fresh to match current source. |
||
|
|
58e42aadf9 |
chore(api): enforce sebuf contract + migrate drifting endpoints (#3207) (#3242)
* chore(api): enforce sebuf contract via exceptions manifest (#3207) Adds api/api-route-exceptions.json as the single source of truth for non-proto /api/ endpoints, with scripts/enforce-sebuf-api-contract.mjs gating every PR via npm run lint:api-contract. Fixes the root-only blind spot in the prior allowlist (tests/edge-functions.test.mjs), which only scanned top-level *.js files and missed nested paths and .ts endpoints — the gap that let api/supply-chain/v1/country-products.ts and friends drift under proto domain URL prefixes unchallenged. Checks both directions: every api/<domain>/v<N>/[rpc].ts must pair with a generated service_server.ts (so a deleted proto fails CI), and every generated service must have an HTTP gateway (no orphaned generated code). Manifest entries require category + reason + owner, with removal_issue mandatory for temporary categories (deferred, migration-pending) and forbidden for permanent ones. .github/CODEOWNERS pins the manifest to @SebastienMelki so new exceptions don't slip through review. The manifest only shrinks: migration-pending entries (19 today) will be removed as subsequent commits in this PR land each migration. * refactor(maritime): migrate /api/ais-snapshot → maritime/v1.GetVesselSnapshot (#3207) The proto VesselSnapshot was carrying density + disruptions but the frontend also needed sequence, relay status, and candidate_reports to drive the position-callback system. Those only lived on the raw relay passthrough, so the client had to keep hitting /api/ais-snapshot whenever callbacks were registered and fall back to the proto RPC only when the relay URL was gone. This commit pushes all three missing fields through the proto contract and collapses the dual-fetch-path into one proto client call. Proto changes (proto/worldmonitor/maritime/v1/): - VesselSnapshot gains sequence, status, candidate_reports. - GetVesselSnapshotRequest gains include_candidates (query: include_candidates). Handler (server/worldmonitor/maritime/v1/get-vessel-snapshot.ts): - Forwards include_candidates to ?candidates=... on the relay. - Separate 5-min in-memory caches for the candidates=on and candidates=off variants; they have very different payload sizes and should not share a slot. - Per-request in-flight dedup preserved per-variant. Frontend (src/services/maritime/index.ts): - fetchSnapshotPayload now calls MaritimeServiceClient.getVesselSnapshot directly with includeCandidates threaded through. The raw-relay path, SNAPSHOT_PROXY_URL, DIRECT_RAILWAY_SNAPSHOT_URL and LOCAL_SNAPSHOT_FALLBACK are gone — production already routed via Vercel, the "direct" branch only ever fired on localhost, and the proto gateway covers both. - New toLegacyCandidateReport helper mirrors toDensityZone/toDisruptionEvent. api/ais-snapshot.js deleted; manifest entry removed. Only reduced the codegen scope to worldmonitor.maritime.v1 (buf generate --path) — regenerating the full tree drops // @ts-nocheck from every client/server file and surfaces pre-existing type errors across 30+ unrelated services, which is not in scope for this PR. Shape-diff vs legacy payload: - disruptions / density: proto carries the same fields, just with the GeoCoordinates wrapper and enum strings (remapped client-side via existing toDisruptionEvent / toDensityZone helpers). - sequence, status.{connected,vessels,messages}: now populated from the proto response — was hardcoded to 0/false in the prior proto fallback. - candidateReports: same shape; optional numeric fields come through as 0 instead of undefined, which the legacy consumer already handled. * refactor(sanctions): migrate /api/sanctions-entity-search → LookupSanctionEntity (#3207) The proto docstring already claimed "OFAC + OpenSanctions" coverage but the handler only fuzzy-matched a local OFAC Redis index — narrower than the legacy /api/sanctions-entity-search, which proxied OpenSanctions live (the source advertised in docs/api-proxies.mdx). Deleting the legacy without expanding the handler would have been a silent coverage regression for external consumers. Handler changes (server/worldmonitor/sanctions/v1/lookup-entity.ts): - Primary path: live search against api.opensanctions.org/search/default with an 8s timeout and the same User-Agent the legacy edge fn used. - Fallback path: the existing OFAC local fuzzy match, kept intact for when OpenSanctions is unreachable / rate-limiting. - Response source field flips between 'opensanctions' (happy path) and 'ofac' (fallback) so clients can tell which index answered. - Query validation tightened: rejects q > 200 chars (matches legacy cap). Rate limiting: - Added /api/sanctions/v1/lookup-entity to ENDPOINT_RATE_POLICIES at 30/min per IP — matches the legacy createIpRateLimiter budget. The gateway already enforces per-endpoint policies via checkEndpointRateLimit. Docs: - docs/api-proxies.mdx — dropped the /api/sanctions-entity-search row (plus the orphaned /api/ais-snapshot row left over from the previous commit in this PR). - docs/panels/sanctions-pressure.mdx — points at the new RPC URL and describes the OpenSanctions-primary / OFAC-fallback semantics. api/sanctions-entity-search.js deleted; manifest entry removed. * refactor(military): migrate /api/military-flights → ListMilitaryFlights (#3207) Legacy /api/military-flights read a pre-baked Redis blob written by the seed-military-flights cron and returned flights in a flat app-friendly shape (lat/lon, lowercase enums, lastSeenMs). The proto RPC takes a bbox, fetches OpenSky live, classifies server-side, and returns nested GeoCoordinates + MILITARY_*_TYPE_* enum strings + lastSeenAt — same data, different contract. fetchFromRedis in src/services/military-flights.ts was doing nothing sebuf-aware. Renamed it to fetchViaProto and rewrote to: - Instantiate MilitaryServiceClient against getRpcBaseUrl(). - Iterate MILITARY_QUERY_REGIONS (PACIFIC + WESTERN) in parallel — same regions the desktop OpenSky path and the seed cron already use, so dashboard coverage tracks the analytic pipeline. - Dedup by hexCode across regions. - Map proto → app shape via new mapProtoFlight helper plus three reverse enum maps (AIRCRAFT_TYPE_REVERSE, OPERATOR_REVERSE, CONFIDENCE_REVERSE). The seed cron (scripts/seed-military-flights.mjs) stays put: it feeds regional-snapshot mobility, cross-source signals, correlation, and the health freshness check (api/health.js: 'military:flights:v1'). None of those read the legacy HTTP endpoint; they read the Redis key directly. The proto handler uses its own per-bbox cache keys under the same prefix, so dashboard traffic no longer races the seed cron's blob — the two paths diverge by a small refresh lag, which is acceptable. Docs: dropped the /api/military-flights row from docs/api-proxies.mdx. api/military-flights.js deleted; manifest entry removed. Shape-diff vs legacy: - f.location.{latitude,longitude} → f.lat, f.lon - f.aircraftType: MILITARY_AIRCRAFT_TYPE_TANKER → 'tanker' via reverse map - f.operator: MILITARY_OPERATOR_USAF → 'usaf' via reverse map - f.confidence: MILITARY_CONFIDENCE_LOW → 'low' via reverse map - f.lastSeenAt (number) → f.lastSeen (Date) - f.enrichment → f.enriched (with field renames) - Extra fields registration / aircraftModel / origin / destination / firstSeenAt now flow through where proto populates them. * fix(supply-chain): thread includeCandidates through chokepoint status (#3207) Caught by tsconfig.api.json typecheck in the pre-push hook (not covered by the plain tsc --noEmit run that ran before I pushed the ais-snapshot commit). The chokepoint status handler calls getVesselSnapshot internally with a static no-auth request — now required to include the new includeCandidates bool from the proto extension. Passing false: server-internal callers don't need per-vessel reports. * test(maritime): update getVesselSnapshot cache assertions (#3207) The ais-snapshot migration replaced the single cachedSnapshot/cacheTimestamp pair with a per-variant cache so candidates-on and candidates-off payloads don't evict each other. Pre-push hook surfaced that tests/server-handlers still asserted the old variable names. Rewriting the assertions to match the new shape while preserving the invariants they actually guard: - Freshness check against slot TTL. - Cache read before relay call. - Per-slot in-flight dedup. - Stale-serve on relay failure (result ?? slot.snapshot). * chore(proto): restore // @ts-nocheck on regenerated maritime files (#3207) I ran 'buf generate --path worldmonitor/maritime/v1' to scope the proto regen to the one service I was changing (to avoid the toolchain drift that drops @ts-nocheck from 60+ unrelated files — separate issue). But the repo convention is the 'make generate' target, which runs buf and then sed-prepends '// @ts-nocheck' to every generated .ts file. My scoped command skipped the sed step. The proto-check CI enforces the sed output, so the two maritime files need the directive restored. * refactor(enrichment): decomm /api/enrichment/{company,signals} legacy edge fns (#3207) Both endpoints were already ported to IntelligenceService: - getCompanyEnrichment (/api/intelligence/v1/get-company-enrichment) - listCompanySignals (/api/intelligence/v1/list-company-signals) No frontend callers of the legacy /api/enrichment/* paths exist. Removes: - api/enrichment/company.js, signals.js, _domain.js - api-route-exceptions.json migration-pending entries (58 remain) - docs/api-proxies.mdx rows for /api/enrichment/{company,signals} - docs/architecture.mdx reference updated to the IntelligenceService RPCs Verified: typecheck, typecheck:api, lint:api-contract (89 files / 58 entries), lint:boundaries, tests/edge-functions.test.mjs (136 pass), tests/enrichment-caching.test.mjs (14 pass — still guards the intelligence/v1 handlers), make generate is zero-diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(leads): migrate /api/{contact,register-interest} → LeadsService (#3207) New leads/v1 sebuf service with two POST RPCs: - SubmitContact → /api/leads/v1/submit-contact - RegisterInterest → /api/leads/v1/register-interest Handler logic ported 1:1 from api/contact.js + api/register-interest.js: - Turnstile verification (desktop sources bypass, preserved) - Honeypot (website field) silently accepts without upstream calls - Free-email-domain gate on SubmitContact (422 ApiError) - validateEmail (disposable/offensive/typo-TLD/MX) on RegisterInterest - Convex writes via ConvexHttpClient (contactMessages:submit, registerInterest:register) - Resend notification + confirmation emails (HTML templates unchanged) Shared helpers moved to server/_shared/: - turnstile.ts (getClientIp + verifyTurnstile) - email-validation.ts (disposable/offensive/MX checks) Rate limits preserved via ENDPOINT_RATE_POLICIES: - submit-contact: 3/hour per IP (was in-memory 3/hr) - register-interest: 5/hour per IP (was in-memory 5/hr; desktop sources previously capped at 2/hr via shared in-memory map — now 5/hr like everyone else, accepting the small regression in exchange for Upstash-backed global limiting) Callers updated: - pro-test/src/App.tsx contact form → new submit-contact path - src-tauri/sidecar/local-api-server.mjs cloud-fallback rewrites /api/register-interest → /api/leads/v1/register-interest when proxying; keeps local path for older desktop builds - src/services/runtime.ts isKeyFreeApiTarget allows both old and new paths through the WORLDMONITOR_API_KEY-optional gate Tests: - tests/contact-handler.test.mjs rewritten to call submitContact handler directly; asserts on ValidationError / ApiError - tests/email-validation.test.mjs + tests/turnstile.test.mjs point at the new server/_shared/ modules Deleted: api/contact.js, api/register-interest.js, api/_ip-rate-limit.js, api/_turnstile.js, api/_email-validation.js, api/_turnstile.test.mjs. Manifest entries removed (58 → 56). Docs updated (api-platform, api-commerce, usage-rate-limits). Verified: npm run typecheck + typecheck:api + lint:api-contract (88 files / 56 entries) + lint:boundaries pass; full test:data (5852 tests) passes; make generate is zero-diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(pro-test): rebuild bundle for leads/v1 contact form (#3207) Updates the enterprise contact form to POST to /api/leads/v1/submit-contact (old path /api/contact removed in the previous commit). Bundle is rebuilt from pro-test/src/App.tsx source change in |
||
|
|
c279f6f426 |
fix(pro-marketing): nav reflects auth state, hide pro banner for pro users (#3250)
* fix(pro-marketing): reflect auth state in nav, hide pro banner for pro users
Two related signed-in-experience bugs caught by the user during the
post-purchase flow:
1. /pro Navbar's SIGN IN button never reacted to auth state. The
component was a static const Navbar = () => <nav>...</nav>; with
no Clerk subscription, so signing in left the SIGN IN button in
place even though the user was authenticated.
2. The "Pro is launched — Upgrade to Pro" announcement banner on the
main app showed for ALL visitors including paying Pro subscribers.
Pitching upgrade to a customer who already paid is a small but
real annoyance, and it stays sticky for 7 days via the localStorage
dismiss key — so a returning paying user dismisses it once and
then never sees the (genuinely useful) banner again if they later
downgrade.
## Changes
### pro-test/src/App.tsx — useClerkUser hook + ClerkUserButton
- New useClerkUser() hook subscribes to Clerk via clerk.addListener
and returns { user, isLoaded } so any component can react to auth
changes (sign-in, sign-out, account switch).
- New ClerkUserButton component mounts Clerk's native UserButton
widget (avatar + dropdown with profile/sign-out) into a div via
clerk.mountUserButton — inherits the existing dark-theme appearance
options from services/checkout.ts::ensureClerk.
- Navbar swaps SIGN IN button for ClerkUserButton when user is
signed in. Slot is intentionally empty during isLoaded=false to
avoid a SIGN IN → avatar flicker for returning users.
- Hero hides its redundant SIGN IN CTA when signed in; collapses to
just "Choose Plan" which is the relevant action for returning users.
- Public/pro/ rebuilt to ship the change (per PR #3229's bundle-
freshness rule).
### src/components/ProBanner.ts — premium-aware show + reactive auto-hide
- showProBanner returns early if hasPremiumAccess() — same authoritative
signal used by the frontend's panel-gating layer (unions API key,
tester key, Clerk pro role, AND Convex Dodo entitlement).
- onEntitlementChange listener auto-dismisses the banner if a Convex
snapshot arrives mid-session that flips the user to premium (e.g.
Dodo webhook lands while they're sitting on the dashboard). Does NOT
write the dismiss timestamp, so the banner reappears correctly if
they later downgrade.
## Test plan
### pro-test (sign-in UI)
- [ ] Anonymous user loads /pro → SIGN IN button visible in nav.
- [ ] Click SIGN IN, complete Clerk modal → button replaced with
Clerk's UserButton avatar dropdown.
- [ ] Open dropdown, click Sign Out → reverts to SIGN IN button.
- [ ] Hard reload as signed-in user → SIGN IN button never flashes;
avatar appears once Clerk loads.
### main app (banner gating)
- [ ] Anonymous user loads / → "Pro is launched" banner shows.
- [ ] Click ✕ to dismiss → banner stays dismissed for 7 days
(existing behavior preserved).
- [ ] Pro user (active Convex entitlement) loads / → banner does
NOT appear, regardless of dismiss state.
- [ ] Free user opens /, then completes checkout in another tab and
Convex publishes the entitlement snapshot → banner auto-hides
in the dashboard tab without reload.
- [ ] Pro user whose subscription lapses (validUntil < now) → banner
reappears on next page load, since dismiss timestamp wasn't
written by the entitlement-change auto-hide.
* fix(pro-banner): symmetric show/hide on entitlement change
Reviewer caught that the previous iteration only handled the upgrade
direction (premium snapshot → hide banner) but never re-showed the
banner on a downgrade. App.ts calls showProBanner() once at init, so
without a symmetric show path, a session that started premium and
then lost entitlement (cancellation, billing grace expiry, plan
downgrade for the same user) would stay banner-less for the rest of
the SPA session — until a full reload re-ran App.ts init.
Net effect of the bug: the comment claiming "the banner reappears
correctly if they later downgrade or the entitlement lapses" was
false in practice for any in-tab transition.
Two changes:
1. Cache the container on every showProBanner() call, including
the early-return paths. App.ts always calls showProBanner()
once at init regardless of premium state, so this guarantees
the listener has the container reference even when the initial
mount was skipped (premium user, dismissed, in iframe).
2. Make onEntitlementChange handler symmetric:
- premium snapshot + visible → hide (existing behavior)
- non-premium snapshot + not visible + cached container +
not dismissed + not in iframe → re-mount via showProBanner
The non-premium re-mount goes through showProBanner() so it gets the
same gate checks as the initial path (isDismissed, iframe, premium).
We can never surface a banner the user has already explicitly ✕'d
this week.
Edge cases handled:
- User starts premium, no banner shown, downgrades mid-session
→ listener fires, premium false, no bannerEl, container cached,
not dismissed → showProBanner mounts banner ✓
- User starts free, sees banner, upgrades mid-session
→ listener fires, premium true, bannerEl present → fade out ✓
- User starts free, dismisses banner, upgrades, downgrades
→ listener fires on downgrade, premium false, no bannerEl,
container cached, isDismissed=true → showProBanner returns early ✓
- User starts free, banner showing, multiple entitlement snapshots
arrive without state change → premium=false && bannerEl present,
neither branch fires, idempotent no-op ✓
* fix(pro-banner): defer initial mount while entitlement is loading
Greptile P1 round-2: hasPremiumAccess() at line 48 reads isEntitled()
synchronously, but the Convex entitlement subscription is fired
non-awaited at App.ts:868 (`void initEntitlementSubscription()`).
showProBanner() runs at App.ts:923 during init Phase 1, before the
first Convex snapshot arrives.
So a Convex-only paying user (Clerk role 'free' + Dodo entitlement
tier=1) sees this sequence:
t=0 init runs → hasPremiumAccess() === false (isEntitled() reads
currentState===null) → "Upgrade to Pro" banner mounts
t=~1s Convex snapshot arrives → onEntitlementChange fires → my
listener detects premium=true && bannerEl !== null → fade out
That's a 1+ second flash of "you should upgrade!" content for someone
who has already paid. Worst case is closer to ~10s on a cold-start
Convex client, which is much worse — looks like the upgrade pitch is
the actual UI.
Defer the initial mount when (1) the user is signed in (so they
plausibly have a Convex entitlement) AND (2) the entitlement state
hasn't loaded yet (currentState === null). The existing
onEntitlementChange listener will mount it later if the first
snapshot confirms the user is actually free.
Two reasons this is gated on "signed in":
- Anonymous users will never have a Convex entitlement, so
deferring would mean the banner NEVER mounts for them. Bad
regression: anon visitors are the highest-value audience for
the upgrade pitch.
- For signed-in users, the worst case if no entitlement EVER
arrives is the banner stays absent — which is identical to a
paying user's correct state, so it fails-closed safely.
Edge case behavior:
- Anonymous user: no Clerk session → first condition false →
banner mounts immediately ✓
- Signed-in free user with first snapshot pre-loaded somehow:
second condition false → banner mounts immediately ✓
- Signed-in user, snapshot pending: deferred → listener mounts
on first snapshot if user turns out free ✓
- Signed-in user, snapshot pending, user turns out premium: never
mounted ✓ (the desired path)
- Signed-in user, snapshot pending, never arrives (Convex outage):
banner never shows → see above, this fails-closed safely
|
||
|
|
56a792bbc4 |
docs(marketing): bump source-count claims from 435+ to 500+ (#3241)
Feeds.ts is at 523 entries after PR #3236 landed. The "435+" figure has been baked into marketing copy, docs, press kit, and localized strings for a long time and is now noticeably understated. Bump to 500+ as the new canonical figure. Also aligned three stale claims in less-visited docs: docs/getting-started.mdx 70+ RSS feeds => 500+ RSS feeds docs/ai-intelligence.mdx 344 sources => 500+ sources docs/COMMUNITY-PROMOTION-GUIDE 170+ news feeds => 500+ news feeds 170+ news sources => 500+ news sources And bumped the digest-dedup copy 400+ to 500+ (English + French locales + pro-test/index.html prerendered body) for consistency with the pricing and GDELT panels. Left alone on purpose (different metric): 22 services / 22 service domains 24 feeds (security-advisory seeder specifically) 31 sources (freshness tracker) 45 map layers Rebuilt /pro bundle so the per-locale chunks + prerendered index.html under public/pro/assets ship the new copy. 20 locales updated. |
||
|
|
42a86c5859 |
fix(preview): skip premium RPCs when main app runs inside /pro live-preview iframe (#3235)
* fix(preview): skip premium RPCs when main app runs inside /pro preview iframe pro-test/src/App.tsx embeds the full main app as a "live preview" via <iframe src="https://worldmonitor.app?alert=false" sandbox="...">. The iframe boots an anonymous main-app session, which fires premium RPCs (get-regional-snapshot, get-tariff-trends, list-comtrade-flows, and on country-click the fetchProSections batch) with no Clerk bearer available. Every call 401s, the circuit breakers catch and fall through to empty fallbacks (so the preview renders fine), but the 401s surface on the PARENT /pro page's DevTools console and Sentry because `sandbox` includes `allow-same-origin`. Net effect: /pro pricing page shows a flood of fake-looking errors that cost us a session of debugging to trace back to the iframe. PR #3233's premiumFetch swap didn't help here (there's simply no token to inject for an anonymous iframe). Introduce `src/utils/embedded-preview.ts::IS_EMBEDDED_PREVIEW`, a module-level boolean evaluated once at load from `window.top !== window` (with try/catch for cross-origin sandboxes), and short-circuit three init-time premium entry points when true: - RegionalIntelligenceBoard.loadCurrent → renderEmpty() - fetchTariffTrends → return emptyTariffs - fetchComtradeFlows → return emptyComtrade Plus one defensive gate in country-intel.fetchProSections for the case a user clicks a country inside the iframe preview. Each gate returns the exact same empty fallback the breaker would have produced after a 401, so visual behavior is unchanged — the preview iframe still shows the dashboard layout with empty premium panels, just without the network request and its console/Sentry trail. Live-tab /pro page should now see zero 401s from regional-snapshot / tariff-trends / comtrade-flows on load. * fix(preview): narrow iframe gate to ?embed=pro-preview marker only Reviewer flagged that the first iteration's `window.top !== window` check was too broad. The repo explicitly markets "Embeddable iframe panels" as an Enterprise feature (pro-test/src/locales/en.json: whiteLabelDesc), so legitimate customer embeds must keep firing premium RPCs normally. Only the /pro marketing preview — which is known-anonymous and generates expected 401 noise — should short-circuit. Fix: replace the blanket iframe check with a unique marker that only /pro's preview iframe carries. - pro-test/src/App.tsx: iframe src switched from `?alert=false` (dead param, unused in main app) to `?embed=pro-preview`. Rebuilt public/pro/ to ship the change. - src/utils/embedded-preview.ts: two-gate check now. Gate 1 still requires `window.top !== window` so the marker leaking into a top-level URL doesn't disable premium RPCs for the top-level app. Gate 2 requires `?embed=pro-preview` in location.search so only the known embedder matches. Enterprise white-label embeds without this marker behave exactly like a top-level visit. Same three premium fetchers + the one country-intel path still gate on IS_EMBEDDED_PREVIEW; the semantic change is purely in how the flag is computed. Per PR #3229 / #3228 lesson, the pro-test rebuild ships in the same PR as the source change — public/pro/assets/index-*.js and index.html reflect the new iframe src. |
||
|
|
d7393d8010 |
fix(pro): downgrade @clerk/clerk-js to v5 to restore auto-mount UI (#3232)
The actual root cause behind the "Clerk was not loaded with Ui
components" sign-in failure on /pro is NOT the import path — it's
that pro-test was on @clerk/clerk-js v6.4.0 while the main app
(which works fine) is on v5.125.7.
Clerk v6 fundamentally changed `clerk.load()`: the UI controller
is no longer auto-mounted by default. Both `@clerk/clerk-js` (the
default v6 entry) and `@clerk/clerk-js/no-rhc` (the bundled-UI
variant) expect the caller to either:
- load Clerk's UI bundle from CDN and pass `window.__internal_ClerkUICtor`
to `clerk.load({ ui: { ClerkUI } })`, or
- manually wire up `clerkUICtor`.
That's why my earlier "switch to no-rhc" fix (PR #3227 + #3228)
didn't actually unbreak production — both v6 variants throw the same
assertion. The error stack on the deployed bundle confirmed it:
`assertComponentsReady` from `clerk.no-rhc-UeQvd9Xf.js`.
Fix: pin pro-test to `@clerk/clerk-js@^5.125.7` to match the main
app's working version. v5 still auto-mounts UI on `clerk.load()` —
no extra wiring needed. The plain `import { Clerk } from '@clerk/clerk-js'`
pattern (which the main app uses verbatim and which pro-test had
before #3227) just works under v5.
Verification of the rebuilt bundle (chunk: clerk-PNSFEZs8.js):
- 3.05 MB (matches main app's clerk-DC7Q2aDh.js: 3.05 MB)
- 44 occurrences of mountComponent (matches main: 44)
- 3 occurrences of SignInComponent (matches main: 3)
- 0 occurrences of "Clerk was not loaded with Ui" (the assertion
error string is absent; UI is unconditionally mounted)
Includes the rebuilt public/pro/ artifacts so this fix is actually
deployed (PR #3229's CI check will catch any future PR that touches
pro-test/src without rebuilding).
|
||
|
|
2b7f83fd3e |
fix(pro): regenerate /pro bundle with no-rhc Clerk so deploy reflects #3227 (#3228)
PR #3227 fixed pro-test/src/services/checkout.ts to import @clerk/clerk-js/no-rhc instead of the headless main export, but the deployed bundle in public/pro/assets/ was never regenerated. The Vercel deploy ships whatever is committed under public/pro/ — the root build script does not run pro-test's vite build — so production /pro continued serving the old broken clerk-C6kUTNKl.js even after #3227 merged. Sign-in still threw "Clerk was not loaded with Ui components". Rebuild: cd pro-test && npm run build, which writes the new chunks to ../public/pro/assets/. Deletes the stale clerk-C6kUTNKl.js + index-J1JYVlDk.js, adds clerk.no-rhc-UeQvd9Xf.js + index-CFLOgmG-.js, and updates pro/index.html to reference them. |
||
|
|
135082d84f |
fix(pro): correct service-domain count — 22 → 30+ (server has 31) (#3202)
* fix(pro): correct service-domain count — 22 → 30+ (server has 31, growing) The /pro page advertised '22 services' / '22 service domains' but server/worldmonitor/, proto/worldmonitor/, and src/generated/server/worldmonitor/ all have 31 domain dirs (aviation, climate, conflict, consumer-prices, cyber, displacement, economic, forecast, giving, health, imagery, infrastructure, intelligence, maritime, market, military, natural, news, positive-events, prediction, radiation, research, resilience, sanctions, seismology, supply-chain, thermal, trade, unrest, webcam, wildfire). api/scenario/v1/ adds a 32nd recently shipped surface. Used '30+' rather than the literal '31' so the page doesn't drift again every time a new domain ships (the '22' was probably accurate at one point too). 168 string substitutions across all 21 locale JSON files (8 keys each: twoPath.proDesc, twoPath.proF1, whyUpgrade.fasterDesc, pillars.askItDesc, dataCoverage.subtitle, proShowcase.oneKey, apiSection.restApi, faq.a8). Plus 10 in pro-test/index.html (meta description, og:description, twitter:description, SoftwareApplication ld+json description + Pro Monthly offer, FAQ ld+json a8, noscript fallback). Bundle rebuilt. * fix(pro): Bulgarian grammar — drop definite-article suffix after 30+ |
||
|
|
cce46a1767 |
fix(pro): API tier is launched — drop 'Coming Soon' label (#3198)
The /pro comparison-table column header still read 'API (Coming Soon)' across all 21 locales (and locale-translated variants), but convex/config/productCatalog.ts has api_starter at currentForCheckout=true, publicVisible=true, priceCents=9999 — $99.99/month, with api_starter_annual at $999/year. The API tier is shipped and self-serve. Updated pricingTable.apiHeader → 'API ($99.99)' for every locale, matching the same '<Tier> ($<price>)' pattern as 'Free ($0)' and 'Pro ($39.99)'. Bundle rebuilt. |
||
|
|
e1c3b28180 |
feat(notifications): Phase 6 — web-push channel for PWA notifications (#3173)
* feat(notifications): Phase 6 — web-push channel for PWA notifications
Adds a web_push notification channel so PWA users receive native
notifications when this tab is closed. Deep-links click to the
brief magazine URL for brief_ready events, to the event link for
everything else.
Schema / API:
- channelTypeValidator gains 'web_push' literal
- notificationChannels union adds { endpoint, p256dh, auth,
userAgent? } variant (standard PushSubscription identity triple +
cosmetic UA for the settings UI)
- new setWebPushChannelForUser internal mutation upserts the row
- /relay/deactivate allow-list extended to accept 'web_push'
- api/notification-channels: 'set-web-push' action validates the
triple, rejects non-https, truncates UA to 200 chars
Client (src/services/push-notifications.ts + src/config/push.ts):
- isWebPushSupported guards Tauri webview + iOS Safari
- subscribeToPush: permission + pushManager.subscribe + POST triple
- unsubscribeFromPush: pushManager.unsubscribe + DELETE row
- VAPID_PUBLIC_KEY constant (with VITE_VAPID_PUBLIC_KEY env override)
- base64 <-> Uint8Array helpers (VAPID key encoding)
Service worker (public/push-handler.js):
- Imported into VitePWA's generated sw.js via workbox.importScripts
- push event: renders notification; requireInteraction=true for
brief_ready so a lock-screen swipe does not dismiss the CTA
- notificationclick: focuses+navigates existing same-origin client
when present, otherwise opens a new window
- Malformed JSON falls back to raw text body, missing data falls
back to a minimal WorldMonitor default
Relay (scripts/notification-relay.cjs):
- sendWebPush() with lazy-loaded web-push dep. 404/410 triggers
deactivateChannel('web_push'). Missing VAPID env vars logs once
and skips — other channels keep delivering.
- processEvent dispatch loop + drainHeldForUser both gain web_push
branches
Settings UI (src/services/notifications-settings.ts):
- New 'Browser Push' tile with bell icon
- Enable button lazy-imports push-notifications, calls subscribe,
renders 'Not supported' on Tauri/in-app webviews
- Remove button routes web_push specifically through
unsubscribeFromPush so the browser side is cleaned up too
Env vars required on Railway services:
VAPID_PUBLIC_KEY public key
VAPID_PRIVATE_KEY private key
VAPID_SUBJECT mailto:support@worldmonitor.app (optional)
Public key is also committed as the default in src/config/push.ts
so the client bundle works without a build-time override.
Tests: 11 new cases in tests/brief-web-push.test.mjs
- base64 <-> Uint8Array round-trip + null guards
- VAPID default fallback when env absent
- SW push event rendering, requireInteraction gating, malformed JSON
+ no-data fallbacks
- SW notificationclick: openWindow vs focus+navigate, default url
154/154 tests pass. Both tsconfigs typecheck clean.
* fix(brief): address PR #3173 review findings + drop hardcoded VAPID
P1 (security): VAPID private key leaked in PR description.
Rotated the keypair. Old pair permanently invalidated. Structural fix:
Removed DEFAULT_VAPID_PUBLIC_KEY entirely. Hardcoding the public
key in src/config/push.ts gave rotations two sources of truth
(code vs env) — exactly the friction that caused me to paste the
private key in a PR description in the first place. VAPID_PUBLIC_KEY
now comes SOLELY from VITE_VAPID_PUBLIC_KEY at build time.
isWebPushConfigured() gates the subscribe flow so builds without
the env var surface as 'Not supported' rather than crashing
pushManager.subscribe.
Operator setup (one-time):
Vercel build: VITE_VAPID_PUBLIC_KEY=<public>
Railway services: VAPID_PUBLIC_KEY=<public>
VAPID_PRIVATE_KEY=<private>
VAPID_SUBJECT=mailto:support@worldmonitor.app
Rotation: update env on both sides, redeploy. No code change, no
PR body — no chance of leaking a key in a commit.
P2: single-device fan-out — setWebPushChannelForUser replaces the
previous subscription silently. Per-device fan-out is a schema change
deferred to follow-up. Fix for now: surface the replacement in
settings UI copy ('Enabling here replaces any previously registered
browser.') so users who expect multi-device see the warning.
P2: 24h push TTL floods offline devices on reconnect. Event-type-aware:
brief_ready: 12h (daily editorial — still interesting)
quiet_hours_batch: 6h (by definition queued-on-wake)
everything else: 30m (transient alerts: noise after 30min)
REGRESSION test: VAPID_PUBLIC_KEY must be '' when env var is unset.
If a committed default is reintroduced, the test fails loudly.
11/11 web-push tests pass. Both tsconfigs typecheck clean.
* fix(notifications): deliver channel_welcome push for web_push connects (#3173 P2)
The settings UI queues a channel_welcome event on first web_push
subscribe (api/notification-channels.ts:240 via publishWelcome), but
processWelcome() in the relay only branched on slack/discord/email —
no web_push arm. The welcome event was consumed off the queue and
then silently dropped, leaving first-time subscribers with no
'connection confirmed' signal.
Fix: add a web_push branch to processWelcome. Calls sendWebPush with
eventType='channel_welcome' which maps to the 30-minute TTL tier in
the push-delivery switch — a welcome that arrives >30 min after
subscribe is noise, not confirmation.
Short body (under 80 chars) so Chrome/Firefox/Safari notification
shelves don't clip past ellipsis.
11/11 web-push tests pass.
* fix(notifications): address two P1 review findings on #3173
P1-A: SSRF via user-supplied web_push endpoint.
The set-web-push edge handler accepted any https:// URL and wrote
it to Convex. The relay's sendWebPush() later POSTs to whatever
endpoint sits in that row, giving any Pro user a server-side-request
primitive bounded only by the relay's network egress.
Fix: isAllowedPushEndpointHost() allow-list in api/notification-
channels.ts. Only the four known browser push-service hosts pass:
fcm.googleapis.com (Chrome / Edge / Brave)
updates.push.services.mozilla.com (Firefox)
web.push.apple.com (Safari, macOS 13+)
*.notify.windows.com (Windows Notification Service)
Fail-closed: unknown hosts rejected with 400 before the row ever
reaches Convex. If a future browser ships a new push service we'll
need to widen this list (guarded by the SSRF regression tests).
P1-B: cross-account endpoint reuse on shared devices.
The browser's PushSubscription is bound to the origin, NOT to the
Clerk session. User A subscribes on device X, signs out, user B
signs in on X and subscribes — the browser hands out the SAME
endpoint/p256dh/auth triple. The previous setWebPushChannelForUser
upsert keyed only by (userId, channelType), so BOTH rows now carry
the same endpoint. Every push the relay fans out for user A also
lands on device X which is now showing user B's session.
Fix: setWebPushChannelForUser scans all web_push rows and deletes
any that match the new endpoint BEFORE upserting. Effectively
transfers ownership of the subscription to the current caller.
The previous user will need to re-subscribe on that device if they
sign in again.
No endpoint-based index on notificationChannels — the scan happens
at <10k rows and is well-bounded to the one write-path per user
per connect. If volume grows, add an + migration.
Regression tests (tests/brief-web-push.test.mjs, 3 new cases):
- allow-list defines all four browser hosts + fail-closed return
- allow-list is invoked BEFORE convexRelay() in the handler
- setWebPushChannelForUser compares + deletes rows by endpoint
14/14 web-push tests pass. Both tsconfigs typecheck clean.
|
||
|
|
ef3967d991 |
feat(pro): realign /pro copy with shipped product — AI-native positioning (#3151)
* feat(pro): realign /pro page copy with shipped product reality Marketing was selling 2024 positioning (equity research first) while the app ships a 2026 AI-native product (MCP on Pro, WM Analyst chat, Custom Widget Builder, 30-item AI digest with multi-channel push). Realigns hero, capabilities grid, FAQ, and showcase copy to honestly reflect what Pro actually delivers. Highlights: - Hero tagline: "The intelligence geopolitical AI layer — ask it, subscribe to it, build on it." - New Pillars block (Ask it / Subscribe to it / Build on it) below hero. - New Delivery Desk section reframing the 30-min digest as a personal analyst. - whyUpgrade quadrants rewritten as outcomes (Know first / Ask anything / Build anything / Wake up informed). - twoPath capabilities grid: WM Analyst, Custom Widget Builder, MCP connectors, flight search, market watchlist, AES-256 alerts; cuts WSB Ticker / Telegram Intel / OREF noise. - proShowcase: orbital surveillance moved to roadmap with inline Soon badge; morningBriefs reframed as Personal Intelligence Desk. - FAQ q8 corrected (MCP is Pro, not Enterprise-only); adds q9-q13 covering Custom Widget Builder, MCP, digest personalization, refresh rate, AES-256. - Pricing table proF4: "Early access pricing" replaced with roadmap-flagged "Priority data refresh (Soon)"; canonical price keys added for downstream use (PricingSection already reads tiers.json which has the real numbers). - Page spine reordered: Hero, Pillars, WhyUpgrade, TwoPath, ProShowcase, DeliveryDesk, AudiencePersonas, SocialProof, LivePreview, Pricing, FAQ. Plan: docs/plans/2026-04-17-001-copy-pro-page-rewrite-plan.md English-only this PR; other locales ship stale by design (tracked as follow-up). * chore(pro): rebuild /pro bundle for copy rewrite Regenerates public/pro/* artifacts so the AI-native copy realignment (see preceding commit) actually ships through the Vercel deploy. Per commit |
||
|
|
7693a4fa4f |
feat(pro): cut /pro over from waitlist → Pro-launched messaging (#3149)
* feat(pro): cut over /pro from waitlist mode to Pro-launched messaging Pro is live. The landing page and the dashboard's header banner still treated it as a coming-soon waitlist — hero was an email capture form behind Turnstile, nav CTA said "Reserve Your Early Access", and the dashboard banner said "Pro is coming / Reserve your spot". All of that funneled traffic into a waitlist instead of the now-working pricing + checkout + Clerk sign-in flow. Dashboard (/) — src/components/ProBanner.ts: - "Pro is coming" → "Pro is launched" - "Reserve your spot →" (href /pro) → "Upgrade to Pro →" (href /pro#pricing) - Re-enable dismiss button (commented out during launch-promotion period) Landing page (/pro) — pro-test/src/App.tsx + main.tsx + index.html: - Navbar: single "Reserve Your Early Access" → two CTAs: "Sign In" (opens Clerk) + "Upgrade to Pro" (anchors #pricing) - Hero: drop the email + Turnstile + Mailcheck waitlist form, the "Launching March 2026" line, and the referral-invite banner. Replace with "Choose Your Plan" (→ #pricing) + "Sign In" buttons. - Two-path split Pro card: "Reserve Your Early Access" → "Choose Your Plan" (already anchors #pricing) - Footer: drop the second waitlist form + finalCta block + #waitlist anchor. Keep the nav/legal strip only. - Rip dead code: submitWaitlist, showReferralSuccess, Mailcheck import and React hook state. Keep Turnstile infrastructure because the enterprise contact form (#enterprise-contact) still uses it. - JSON-LD Offer: "Pro (Waitlist)" @ $0 → "Pro" @ $39.99. Rebuild the /pro bundle (same committed-artifact dance as PR #3148): grep for the old gate + waitlist strings in the new bundle returns 0. i18n: added nav.signIn, nav.upgradeToPro, hero.choosePlan, hero.signIn, twoPath.choosePlan. Stale waitlist keys left in place — other locales fall back to English until the full sweep. * i18n(pro): translate 5 new Pro-launched keys across all 20 locales Resolves the followup from the previous cutover commit: no more silent English fallback for signIn / upgradeToPro / choosePlan strings in non-English /pro pages. Touched keys per locale: nav.signIn, nav.upgradeToPro (both added — nav block existed) hero.signIn, hero.choosePlan (both added — hero block existed) twoPath.choosePlan (added — most locales had no twoPath block at all, so it's created with just this one key; remaining twoPath strings still fall back to English, which is the pre-existing state) Covers: ar, bg, cs, de, el, es, fr, it, ja, ko, nl, pl, pt, ro, ru, sv, th, tr, vi, zh. Rebuilt the /pro bundle so the per-locale lazy chunks under public/pro/assets/{lang}-*.js ship the new strings. Verified via grep for translated strings (e.g. "升级到 Pro", "Passer à Pro", "الترقية إلى") in the rebuilt locale chunks. * fix(pro-banner): version the dismiss key so launch banner isn't pre-suppressed Reviewer on #3149 flagged that re-enabling dismissal with the same wm-pro-banner-dismissed key carries forward any dismissal from the pre-launch "Pro is coming / Reserve your spot" banner. Anyone whose localStorage still holds that key (e.g. dismissed during the brief March 9 to March 11 window before #1422 disabled dismissal) would silently suppress the new "Pro is launched / Upgrade to Pro" banner and never see the launch CTA. Rename DISMISS_KEY to wm-pro-banner-launched-dismissed (semantically scoped to the launched-state variant) and clear the legacy key on every isDismissed() call so stale entries don't accumulate. Dark / light rendering and the 7-day TTL behavior are unchanged. * fix(pro): remove stale 96px footer padding + redundant inner border Greptile on #3149 flagged that dropping the finalCta block left the footer with pt-24 (96px) top padding above the legal strip, rendering a large blank band. Pare the outer padding down to pt-8 and drop the inner pt-8 + border-wm-border/50 divider (which only existed to separate the legal strip from the removed finalCta content above). Rebuilt /pro bundle accordingly. |
||
|
|
05b8f66ec1 |
fix(pro): rebuild /pro bundle so ungated pricing section actually ships (#3148)
PR #3115 removed the isProUser() localStorage gate from pro-test/src/App.tsx that hid PricingSection + PricingTable behind `wm-widget-key` / `wm-pro-key`, but the compiled bundle under public/pro/assets/ (the static bundle served at worldmonitor.app/pro) was never rebuilt. Visitors without legacy API keys in localStorage therefore still see no prices and no path into the normal Clerk sign-in that the PricingSection CTAs trigger. Rebuilding the bundle drops the gate check (grep count goes from 1 to 0) and rewires the index.html asset hashes. |
||
|
|
8b44bd2d1c |
fix(pro): bake Clerk publishable key into /pro static bundle (#2800)
* fix(pro): bake Clerk publishable key into /pro static bundle The pro-test Vite app builds to public/pro/ as committed static files. VITE_CLERK_PUBLISHABLE_KEY was set in Vercel but never available during the local Vite build, so the JS bundle shipped without it, causing "VITE_CLERK_PUBLISHABLE_KEY not set" at runtime on Get Started click. Added .env.production with the publishable key and rebuilt the bundle. * fix(pro): track .env.production so build is reproducible, drop unrelated api.d.ts Un-ignore pro-test/.env.production (publishable key is public by design) so a clean checkout + npm run build:pro produces a working bundle. Reverted unrelated convex/_generated/api.d.ts churn from the first commit. |
||
|
|
5b2cc93560 |
fix(catalog): update prices to match Dodo catalog via API (#2678)
* fix(catalog): update API Starter fallback prices to match Dodo API Starter Monthly: $59.99 → $99.99 API Starter Annual: $490 → $999 * fix(catalog): log and expose priceSource (dodo/partial/fallback) Console warns when fallback prices are used for individual products. Response includes priceSource field: 'dodo' (all from API), 'partial' (some failed), or 'fallback' (all failed). Makes silent failures visible in Vercel logs and API response. * fix(catalog): priceSource counts only public priced products, remove playground priceSource was counting all CATALOG entries (including hidden api_business and enterprise with no Dodo price), making it report 'partial' even when all visible prices came from Dodo. Now counts only products rendered by buildTiers. Removed playground-pricing.html from git. |
||
|
|
62c043e8fd |
feat(checkout): in-page checkout on /pro + dashboard migration to edge endpoint (#2668)
* feat(checkout): add /relay/create-checkout + internalCreateCheckout Shared _createCheckoutSession helper used by both public createCheckout (Convex auth) and internalCreateCheckout (trusted relay with userId). Relay route in http.ts follows notification-channels pattern. * feat(checkout): add /api/create-checkout edge gateway Thin auth proxy: validates Clerk JWT, relays to Convex /relay/create-checkout. CORS, POST only, no-store, 15s timeout. Same CONVEX_SITE_URL fallback as notification-channels. * feat(pro): add checkout service with Clerk + Dodo overlay Lazy Clerk init, token retry, auto-resume after sign-in, in-flight lock, doCheckout returns boolean for intent preservation. Added @clerk/clerk-js + dodopayments-checkout deps. * feat(pro): in-page checkout via Clerk sign-in + Dodo overlay PricingSection CTA buttons call startCheckout() directly instead of redirecting to dashboard. Dodo overlay initialized at App startup with success banner + redirect to dashboard after payment. * feat(checkout): migrate dashboard to /api/create-checkout edge endpoint Replace ConvexClient.action(createCheckout) with fetch to edge endpoint. Removes getConvexClient/getConvexApi/waitForConvexAuth dependency from checkout. Returns Promise<boolean>. resumePendingCheckout only clears intent on success. Token retry, in-flight lock, Sentry capture. * fix(checkout): restore customer prefill + use origin returnUrl P2-1: Extract email/name from Clerk JWT in validateBearerToken. Edge gateway forwards them to Convex relay. Dodo checkout prefilled again. P2-2: Dashboard returnUrl uses window.location.origin instead of hardcoded canonical URL. Respects variant hosts (app.worldmonitor.app). * fix(pro): guard ensureClerk concurrency, catch initOverlay import error - ensureClerk: promise guard prevents duplicate Clerk instances on concurrent calls - initOverlay: .catch() logs Dodo SDK import failures instead of unhandled rejection * test(auth): add JWT customer prefill extraction tests Verifies email/name are extracted from Clerk JWT payload for checkout prefill. Tests both present and absent cases. |
||
|
|
5bff9a17b0 |
feat(catalog): live Dodo prices with Redis cache + static fallback (#2653)
* feat(catalog): fetch prices from Dodo API with Redis cache, static fallback /pro page fetches live prices from /api/product-catalog. Endpoint hits Dodo Products API, caches in Redis (1h TTL). PricingSection shows static fallback while fetching, then swaps to live prices. DELETE with RELAY_SHARED_SECRET purges cache. * fix(catalog): parallel Dodo fetches + fallback prices for partial failures P1: If one Dodo product fetch fails, the tier now uses a fallback price from FALLBACK_PRICES instead of rendering $undefined. P2: Replaced serial for-loop with Promise.allSettled so all 6 Dodo fetches run in parallel (5s max instead of 30s worst case). * fix(catalog): generate fallback prices from catalog, add freshness test FALLBACK_PRICES in the edge endpoint is now auto-generated by generate-product-config.mjs (api/_product-fallback-prices.js) instead of hardcoded. Freshness test verifies all self-serve products have fallback entries. No more manual price duplication. * fix(catalog): add cachedUntil to response matching jsdoc contract |
||
|
|
8fb8714ba6 |
refactor(payments): product catalog single source of truth + API annual + live IDs (#2649)
* fix(payments): add API Starter annual product, simplify /pro page
- Added API Starter Annual product ID (pdt_0Nbu2lawHYE3dv2THgSEV)
- API card now supports annual toggle matching Pro card
- Removed Enterprise and API Business from /pro page (not self-serve)
- Added api_starter_annual to entitlements and seedProductPlans
- Rebuilt /pro bundle
Requires npx convex deploy + seedProductPlans after merge.
* fix(pro): restore Enterprise card, fix API to 1,000 requests/day
Enterprise card back with "Contact Sales" mailto (no Dodo link, no
price). API Starter feature list corrected from 10,000 to 1,000
requests/day.
* refactor(payments): single source of truth product catalog
Canonical catalog in convex/config/productCatalog.ts owns all product
IDs, prices, features, and marketing copy. Build script generates
src/config/products.generated.ts and pro-test/src/generated/tiers.json.
To update prices: edit catalog, run generator, rebuild /pro, deploy Convex + seed.
* test(catalog): add bidirectional reverse assertions
- every currentForCheckout catalog entry must appear in products.generated.ts
- every publicVisible tier group must appear in tiers.json
* fix(payments): restore .unique() for productPlans lookup
Accidentally changed to .first() when adding legacy fallback. Duplicates
should fail loudly, not silently pick one row.
* fix(catalog): restore billing-distinct displayNames (Pro Monthly, API Starter Monthly)
displayName is used by seedProductPlans → billing UI. Collapsing to
marketing names ("Pro") lost the monthly/annual distinction. Tests
expect "Pro Monthly". Marketing /pro page unaffected (generator uses
tier group names).
|
||
|
|
2544bad3f1 |
fix(payments): update Dodo product IDs to live mode (#2648)
* fix(payments): update all Dodo product IDs to live mode Checkout was failing with 422 "Product does not exist" because product IDs were from test mode. Updated all 5 product IDs across products.ts, seedProductPlans.ts, and PricingSection.tsx. Requires npx convex deploy after merge. * fix(payments): rebuild pro-test bundle with live product IDs Built bundle in public/pro/assets/ still had old test-mode product IDs. Rebuilt via npm run build in pro-test/. |
||
|
|
9893bb1cf2 |
feat: Dodo Payments integration + entitlement engine & webhook pipeline (#2024)
* feat(14-01): install @dodopayments/convex and register component - Install @dodopayments/convex@0.2.8 with peer deps satisfied - Create convex/convex.config.ts with defineApp() and dodopayments component - Add TODO for betterAuth registration when PR #1812 merges Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(14-01): extend schema with 6 payment tables for Dodo integration - Add subscriptions table with status enum, indexes, and raw payload - Add entitlements table (one record per user) with features blob - Add customers table keyed by userId with optional dodoCustomerId - Add webhookEvents table for full audit trail (retained forever) - Add paymentEvents table for billing history (charge/refund) - Add productPlans table for product-to-plan mapping in DB - All existing tables (registrations, contactMessages, counters) unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(14-01): add auth stub, env helper, and Dodo env var docs - Create convex/lib/auth.ts with resolveUserId (returns test-user-001 in dev) and requireUserId (throws on unauthenticated) as sole auth entry points - Create convex/lib/env.ts with requireEnv for runtime env var validation - Append DODO_API_KEY, DODO_WEBHOOK_SECRET, DODO_PAYMENTS_WEBHOOK_SECRET, and DODO_BUSINESS_ID to .env.example with setup instructions - Document dual webhook secret naming (library vs app convention) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(14-02): add seed mutation for product-to-plan mappings - Idempotent upsert mutation for 5 Dodo product-to-plan mappings - Placeholder product IDs to be replaced after Dodo dashboard setup - listProductPlans query for verification and downstream use Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(14-02): populate seed mutation with real Dodo product IDs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(15-02): add plan-to-features entitlements config map - Define PlanFeatures type with 5 feature dimensions - Add PLAN_FEATURES config for 6 tiers (free through enterprise) - Export getFeaturesForPlan helper with free-tier fallback - Export FREE_FEATURES constant for default entitlements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(15-01): add webhook HTTP endpoint with signature verification - Custom httpAction verifying Dodo webhook signatures via @dodopayments/core - Returns 400 for missing headers, 401 for invalid signature, 500 for processing errors - HTTP router at /dodopayments-webhook dispatches POST to webhook handler - Synchronous processing before 200 response (within Dodo 15s timeout) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(15-02): add subscription lifecycle handlers and entitlement upsert - Add upsertEntitlements helper (creates/updates per userId, no duplicates) - Add isNewerEvent guard for out-of-order webhook rejection - Add handleSubscriptionActive (creates subscription + entitlements) - Add handleSubscriptionRenewed (extends period + entitlements) - Add handleSubscriptionOnHold (pauses without revoking entitlements) - Add handleSubscriptionCancelled (preserves entitlements until period end) - Add handleSubscriptionPlanChanged (updates plan + recomputes entitlements) - Add handlePaymentEvent (records charge events for succeeded/failed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(15-01): add idempotent webhook event processor with dispatch skeleton - processWebhookEvent internalMutation with idempotency via by_webhookId index - Switch dispatch for 7 event types: 5 subscription + 2 payment events - Stub handlers log TODO for each event type (to be implemented in Plan 03) - Error handling marks failed events and re-throws for HTTP 500 + Dodo retry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(15-01): complete webhook endpoint plan - Update auto-generated api.d.ts with new payment module types - SUMMARY, STATE, and ROADMAP updated (.planning/ gitignored) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(15-03): wire subscription handlers into webhook dispatch - Replace 6 stub handler functions with imports from subscriptionHelpers - All 7 event types (5 subscription + 2 payment) dispatch to real handlers - Error handling preserves failed event status in webhookEvents table - Complete end-to-end pipeline: HTTP action -> mutation -> handler functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(15-04): install convex-test, vitest, and edge-runtime; configure vitest - Add convex-test, vitest, @edge-runtime/vm as dev dependencies - Create vitest.config.mts scoped to convex/__tests__/ with edge-runtime environment - Add test:convex and test:convex:watch npm scripts * test(15-04): add 10 contract tests for webhook event processing pipeline - Test all 5 subscription lifecycle events (active, renewed, on_hold, cancelled, plan_changed) - Test both payment events (succeeded, failed) - Test deduplication by webhook-id (same id processed only once) - Test out-of-order event rejection (older timestamp skipped) - Test subscription reactivation (cancelled -> active on same subscription_id) - Verify entitlements created/updated with correct plan features * fix(15-04): exclude __tests__ from convex typecheck convex-test uses Vite-specific import.meta.glob and has generic type mismatches with tsc. Tests run correctly via vitest; excluding from convex typecheck avoids false positives. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(16-01): add tier levels to PLAN_FEATURES and create entitlement query - Add tier: number to PlanFeatures type (0=free, 1=pro, 2=api, 3=enterprise) - Add tier values to all plan entries in PLAN_FEATURES config - Create convex/entitlements.ts with getEntitlementsForUser public query - Free-tier fallback for missing or expired entitlements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(16-01): create Redis cache sync action and wire upsertEntitlements - Create convex/payments/cacheActions.ts with syncEntitlementCache internal action - Wire upsertEntitlements to schedule cache sync via ctx.scheduler.runAfter(0, ...) - Add deleteRedisKey() to server/_shared/redis.ts for explicit cache invalidation - Redis keys use raw format (entitlements:{userId}) with 1-hour TTL - Cache write failures logged but do not break webhook pipeline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(16-02): add entitlement enforcement to API gateway - Create entitlement-check middleware with Redis cache + Convex fallback - Replace PREMIUM_RPC_PATHS boolean Set with ENDPOINT_ENTITLEMENTS tier map - Wire checkEntitlement into gateway between API key and rate limiting - Add raw parameter to setCachedJson for user-scoped entitlement keys - Fail-open on missing auth/cache failures for graceful degradation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(16-03): create frontend entitlement service with reactive ConvexClient subscription - Add VITE_CONVEX_URL to .env.example for frontend Convex access - Create src/services/entitlements.ts with lazy-loaded ConvexClient - Export initEntitlementSubscription, onEntitlementChange, getEntitlementState, hasFeature, hasTier, isEntitled - ConvexClient only loaded when userId available and VITE_CONVEX_URL configured - Graceful degradation: log warning and skip when Convex unavailable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(16-04): add 6 contract tests for Convex entitlement query - Free-tier defaults for unknown userId - Active entitlements for subscribed user - Free-tier fallback for expired entitlements - Correct tier mapping for api_starter and enterprise plans - getFeaturesForPlan fallback for unknown plan keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(16-04): add 6 unit tests for gateway entitlement enforcement - getRequiredTier: gated vs ungated endpoint tier lookup - checkEntitlement: ungated pass-through, missing userId graceful degradation - checkEntitlement: 403 for insufficient tier, null for sufficient tier - Dependency injection pattern (_testCheckEntitlement) for clean testability - vitest.config.mts include expanded to server/__tests__/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-01): create Convex checkout session action - DodoPayments component wraps checkout with server-side API key - Accepts productId, returnUrl, discountCode, referralCode args - Always enables discount code input (PROMO-01) - Forwards affiliate referral as checkout metadata (PROMO-02) - Dark theme customization for checkout overlay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-03): create PricingSection component with tier cards and billing toggle - 4 tiers: Free, Pro, API, Enterprise with feature comparison - Monthly/annual toggle with "Save 17%" badge for Pro - Checkout buttons using Dodo static payment links - Pro tier visually highlighted with green border and "Most Popular" badge - Staggered entrance animations via motion - Referral code forwarding via refCode prop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(17-01): extract shared ConvexClient singleton, refactor entitlements - Create src/services/convex-client.ts with getConvexClient() and getConvexApi() - Lazy-load ConvexClient via dynamic import to preserve bundle size - Refactor entitlements.ts to use shared client instead of inline creation - Both checkout and entitlement services will share one WebSocket connection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-03): integrate PricingSection into App.tsx with referral code forwarding - Import and render PricingSection between EnterpriseShowcase and PricingTable - Pass refCode from getRefCode() URL param to PricingSection for checkout link forwarding - Update navbar CTA and TwoPathSplit Pro CTA to anchor to #pricing section - Keep existing waitlist form in Footer for users not ready to buy - Build succeeds with no new errors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update generated files after main merge and pro-test build Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prefix unused ctx param in auth stub to pass typecheck Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(17-04): add 4 E2E contract tests for checkout-to-entitlement flow - Test product plan seeding and querying (5 plans verified) - Test pro_monthly checkout -> webhook -> entitlements (tier=1, no API) - Test api_starter checkout -> webhook -> entitlements (tier=2, apiAccess) - Test expired entitlements fall back to free tier (tier=0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-02): install dodopayments-checkout SDK and create checkout overlay service - Install dodopayments-checkout@1.8.0 overlay SDK - Create src/services/checkout.ts with initCheckoutOverlay, openCheckout, startCheckout, showCheckoutSuccess - Dark theme config matching dashboard aesthetic (green accent, dark bg) - Lazy SDK initialization on first use - Fallback to /pro page when Convex is unavailable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-02): wire locked panel CTAs and post-checkout return handling - Create src/services/checkout-return.ts for URL param detection and cleanup - Update Panel.ts showLocked() CTA to trigger Dodo overlay checkout (web path) - Keep Tauri desktop path opening URL externally - Add handleCheckoutReturn() call in PanelLayoutManager constructor - Initialize checkout overlay with success banner callback - Dynamic import of checkout module to avoid loading until user clicks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(17-02): add Upgrade to Pro section in UnifiedSettings modal - Add upgrade section at bottom of settings tab with value proposition - Wire CTA button to open Dodo checkout overlay via dynamic import - Close settings modal before opening checkout overlay - Tauri desktop fallback to external URL - Conditionally show "You're on Pro" when user has active entitlement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused imports in entitlement-check test to pass typecheck:api Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(17-01): guard missing DODO_PAYMENTS_API_KEY with warning instead of silent undefined Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(17-01): use DODO_API_KEY env var name matching Convex dashboard config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(17-02): add Dodo checkout domains to CSP frame-src directive Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(17-03): use test checkout domain for test-mode Dodo products Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(18-01): shared DodoPayments config and customer upsert in webhook - Create convex/lib/dodo.ts centralizing DodoPayments instance and API exports - Refactor checkout.ts to import from shared config (remove inline instantiation) - Add customer record upsert in handleSubscriptionActive for portal session support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(18-01): billing queries and actions for subscription management - Add getSubscriptionForUser query (plan status, display name, renewal date) - Add getCustomerByUserId and getActiveSubscription internal queries - Add getCustomerPortalUrl action (creates Dodo portal session via SDK) - Add changePlan action (upgrade/downgrade with proration via Dodo SDK) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(18-02): add frontend billing service with reactive subscription watch - SubscriptionInfo interface for plan status display - initSubscriptionWatch() with ConvexClient onUpdate subscription - onSubscriptionChange() listener pattern with immediate fire for late subscribers - openBillingPortal() with Dodo Customer Portal fallback - changePlan() with prorated_immediately proration mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(18-02): add subscription status display and Manage Billing button to settings - Settings modal shows plan name, status badge, and renewal date for entitled users - Status-aware colors: green (active), yellow (on_hold), red (cancelled/expired) - Manage Billing button opens Dodo Customer Portal via billing service - initSubscriptionWatch called at dashboard boot alongside entitlements - Import initPaymentFailureBanner for Task 3 wiring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(18-02): add persistent payment failure banner for on_hold subscriptions - Red fixed-position banner at top of dashboard when subscription is on_hold - Update Payment button opens Dodo billing portal - Dismiss button with sessionStorage persistence (avoids nagging in same session) - Auto-removes when subscription returns to active (reactive via Convex) - Event listeners attached directly to DOM (not via debounced setContent) - Wired into panel-layout constructor alongside subscription watch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review — identity bridge, entitlement gating, fail-closed, env hygiene P0: Checkout-to-user identity bridge - Pass userId as metadata.wm_user_id in checkout sessions - Webhook resolveUserId: try metadata first, then customer table, then dev-only fallback - Fail closed in production when no user identity can be resolved P0: Unify premium gating to read Dodo entitlements - data-loader.ts: hasPremiumAccess() checks isEntitled() || API key - panels.ts: isPanelEntitled checks isEntitled() before API key fallback - panel-layout.ts: reload on entitlement change to unlock panels P1: Fail closed on unknown product IDs - resolvePlanKey throws on unmapped product (webhook retries) - getFeaturesForPlan throws on unknown planKey P1: Env var hygiene - Canonical DODO_API_KEY (no dual-name fallback in dodo.ts) - console.error on missing key instead of silent empty string P1: Fix test suite scheduled function errors - Guard scheduler.runAfter with UPSTASH_REDIS_REST_URL check - Tests skip Redis cache sync, eliminating convex-test write errors P2: Webhook rollback durability - webhookMutations: return error instead of rethrow (preserves audit row) - webhookHandlers: check mutation return for error indicator P2: Product ID consolidation - New src/config/products.ts as single source of truth - Panel.ts and UnifiedSettings.ts import from shared config P2: ConvexHttpClient singleton in entitlement-check.ts P2: Concrete features validator in schema (replaces v.any()) P2: Tests seed real customer mapping (not fallback user) P3: Narrow eslint-disable to typed interfaces in subscriptionHelpers P3: Real ConvexClient type in convex-client.ts P3: Better dev detection in auth.ts (CONVEX_IS_DEV) P3: Add VITE_DODO_ENVIRONMENT to .env.example Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(payments): security audit hardening — auth gates, webhook retry, transaction safety - Gate all public billing/checkout endpoints with resolveUserId(ctx) auth check - Fix webhook retry: record events after processing, not before; delete failed events on retry - Fix transaction atomicity: let errors propagate so Convex rolls back partial writes - Fix isDevDeployment to use CONVEX_IS_DEV (same as lib/auth.ts) - Add missing handlers: subscription.expired, refund.*, dispute.* - Fix toEpochMs silent fallback — now warns on missing billing dates - Use validated payload directly instead of double-parsing webhook body - Fix multi-sub query to prioritize active > on_hold > cancelled > expired - Change .unique() to .first() on customer lookups (defensive against duplicates) - Update handleSubscriptionActive to patch planKey/dodoProductId on existing subs - Frontend: portal URL validation, getProWidgetKey(), subscription cleanup on destroy - Make seedProductPlans internalMutation, console.log → console.warn for ops signals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review — identity bridge, entitlement gating, fail-safe dev detection P0: convex/lib/auth.ts + subscriptionHelpers.ts — remove CONVEX_CLOUD_URL heuristic that could treat production as dev. Now uses ctx.auth.getUserIdentity() as primary auth with CONVEX_IS_DEV-only dev fallback. P0: server/gateway.ts + auth-session.ts — add bearer token (Clerk JWT) support for tier-gated endpoints. Authenticated users bypass API key requirement; userId flows into x-user-id header for entitlement check. Activated by setting CLERK_JWT_ISSUER_DOMAIN env var. P1: src/services/user-identity.ts — centralized getUserId() replacing scattered getProWidgetKey() calls in checkout.ts, billing.ts, panel-layout.ts. P2: src/App.ts — premium panel prime/refresh now checks isEntitled() alongside WORLDMONITOR_API_KEY so Dodo-entitled web users get data loading. P2: convex/lib/dodo.ts + billing.ts — move Dodo SDK config from module scope into lazy/action-scoped init. Missing DODO_API_KEY now throws at action boundary instead of silently capturing empty string. Tests: webhook test payloads now include wm_user_id metadata (production path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address P0 access control + P1 identity bridge + P1 entitlement reload loop P0: Remove userId from public billing function args (getSubscriptionForUser, getCustomerPortalUrl, changePlan) — use requireUserId(ctx) with no fallback to prevent unauthenticated callers from accessing arbitrary user data. P1: Add stable anonymous ID (wm-anon-id) in user-identity.ts so createCheckout always passes wm_user_id in metadata. Breaks the infinite webhook retry loop for brand-new purchasers with no auth/localStorage identity. P1: Skip initial entitlement snapshot in onEntitlementChange to prevent reload loop for existing premium users whose shouldUnlockPremium() is already true from legacy signals (API key / wm-pro-key). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address P1 billing flow + P1 anon ID claim path + P2 daily-market-brief P1 — Billing functions wired end-to-end for browser sessions: - getSubscriptionForUser, getCustomerPortalUrl, changePlan now accept userId from args (matching entitlements.ts pattern) with auth-first fallback. Once Clerk JWT is wired into ConvexClient.setAuth(), the auth path will take precedence automatically. - Frontend billing.ts passes userId from getUserId() on all calls. - Subscription watch, portal URL, and plan change all work for browser users with anon IDs. P1 — Anonymous ID → account claim path: - Added claimSubscription(anonId) mutation to billing.ts — reassigns subscriptions, entitlements, customers, and payment events from an anonymous browser ID to the authenticated user. - Documented the anon ID limitation in user-identity.ts with the migration plan (call claimSubscription on first Clerk session). - Created follow-up issue #2078 for the full claim/migration flow. P2 — daily-market-brief added to hasPremiumAccess() block in data-loader.ts loadAllData() so it loads on general data refresh paths (was only in primeVisiblePanelData startup path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: P0 lock down billing write actions + P2 fix claimSubscription logic P0 — Billing access control locked down: - getCustomerPortalUrl and changePlan converted to internalAction — not callable from the browser, closing the IDOR hole on write paths. - getSubscriptionForUser stays as a public query with userId arg (read-only, matching the entitlements.ts pattern — low risk). - Frontend billing.ts: portal opens generic Dodo URL, changePlan returns "not available" stub. Both will be promoted once Clerk auth is wired into ConvexClient.setAuth(). P2 — claimSubscription merge logic fixed: - Entitlement comparison now uses features.tier first, breaks ties with validUntil (was comparing only validUntil which could downgrade tiers). - Added Redis cache invalidation after claim: schedules deleteEntitlementCache for the stale anon ID and syncEntitlementCache for the real user ID. - Added deleteEntitlementCache internal action to cacheActions.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(billing): strip Dodo vendor IDs from public query response Remove dodoSubscriptionId and dodoProductId from getSubscriptionForUser return — these vendor-level identifiers aren't used client-side and shouldn't be exposed over an unauthenticated fallback path. Addresses koala73's Round 5 P1 review on #2024. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(billing): address koala review cleanup items (P2/P3) - Remove stale dodoSubscriptionId/dodoProductId from SubscriptionInfo interface (server no longer returns them) - Remove dead `?? crypto.randomUUID()` fallback in checkout.ts (getUserId() always returns a string via getOrCreateAnonId()) - Remove unused "failed" status variant and errorMessage from webhookEvents schema (no code path writes them) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing isProUser import and allow trusted origins for tier-gated endpoints The typecheck failed because isProUser was used in App.ts but never imported. The unit test failed because the gateway forced API key validation for tier-gated endpoints even from trusted browser origins (worldmonitor.app), where the client-side isProUser() gate controls access instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(gateway): require credentials for premium endpoints regardless of origin Origin header is spoofable — it cannot be a security boundary. Premium endpoints now always require either an API key or a valid bearer token (via Clerk session). Authenticated users (sessionUserId present) bypass the API key check; unauthenticated requests to tier-gated endpoints get 401. Updated test to assert browserNoKey → 401 instead of 200. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tests): add catch-all route to legacy endpoint allowlist The [[...path]].js Vercel catch-all route (domain gateway entry point) was missing from ALLOWED_LEGACY_ENDPOINTS in the edge function tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert: remove [[...path]].js from legacy endpoint allowlist This file is Vercel-generated and gitignored — it only exists locally, not in the repo. Adding it to the allowlist caused CI to fail with "stale entry" since the file doesn't exist in CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(payment): apply design system to payment UI (light/dark mode) - checkout.ts: add light themeConfig for Dodo overlay + pass theme flag based on current document.dataset.theme; previously only dark was configured so light-mode users got Dodo's default white UI - UnifiedSettings: replace hardcoded dark hex values (#1a1a1a, #323232, #fff, #909090) in upgrade section with CSS var-driven classes so the panel respects both light and dark themes - main.css: add .upgrade-pro-section / .upgrade-pro-cta / .manage-billing-btn classes using var(--green), var(--bg), var(--surface), var(--border), etc. * fix(checkout): remove invalid theme prop from CheckoutOptions * fix: regenerate package-lock.json with npm 10 (matches CI Node 22) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(gateway): enforce pro role check for authenticated free users on premium paths Free bearer token holders with a valid session bypassed PREMIUM_RPC_PATHS because sessionUserId being set caused forceKey=false, skipping the role check entirely. Now explicitly checks bearer role after API key gate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused getSecretState import from data-loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: gate Dodo Payments init behind isProUser() (same as Clerk) Entitlement subscription, subscription watch, checkout overlay, and payment banners now only initialize for isProUser() — matching the Clerk auth gate so only wm-pro-key / wm-widget-key holders see it. Also consolidates inline isEntitled()||getSecretState()||role checks in App.ts to use the centralized hasPremiumAccess() from panel-gating. Both gates (Clerk + Dodo) to be removed when ready for all users. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: retrigger workflows * chore: retrigger CI * fix(redis): use POST method in deleteRedisKey for consistency All other write helpers use POST; DEL was implicitly using GET via fetch default. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(quick-5): resolve all P1 security issues from koala73 review P1-1: Fail-closed entitlement gate (403 when no userId or lookup fails) P1-2: Checkout requires auth (removed client-supplied userId fallback) P1-3: Removed dual auth (PREMIUM_RPC_PATHS) from gateway, single entitlement path P1-4: Typed features validator in cacheActions (v.object instead of v.any) P1-5: Typed ConvexClient API ref (typeof api instead of Record<string,any>) P1-6: Cache stampede mitigation via request coalescing (_inFlight map) Round8-A: getUserId() returns Clerk user.id, hasUserIdentity() checks real identity Round8-B: JWT verification pinned to algorithms: ['RS256'] - Updated entitlement tests for fail-closed behavior (7 tests pass) - Removed userId arg from checkout client call - Added env-aware Redis key prefix (live/test) - Reduced cache TTL from 3600 to 900 seconds - Added 5s timeout to Redis fetch calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(quick-5): resolve all P2 review issues from koala73 review P2-1: dispute.lost now revokes entitlements (downgrades to free tier) P2-2: rawPayload v.any() documented with JSDoc (intentional: external schema) P2-3: Redis keys prefixed with live/test env (already in P1 commit) P2-4: 5s timeout on Redis fetch calls (already in P1 commit) P2-5: Cache TTL reduced from 3600 to 900 seconds (already in P1 commit) P2-6: CONVEX_IS_DEV warning logged at module load time (once, not per-call) P2-7: claimSubscription uses .first() instead of .unique() (race safety) P2-8: toEpochMs fallback accepts eventTimestamp (all callers updated) P2-9: hasUserIdentity() checks real identity (already in P1 commit) P2-10: Duplicate JWT verification removed (already in P1 commit) P2-11: Subscription queries bounded with .take(10) P2-12: Entitlement subscription exposes destroyEntitlementSubscription() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(convex): resolve TS errors in http.ts after merge Non-null assertions for anyApi dynamic module references and safe array element access in timing-safe comparison. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tests): update gateway tests for fail-closed entitlement system Tests now reflect the new behavior where: - API key + no auth session → 403 (entitlement check requires userId) - Valid bearer + no entitlement data → 403 (fail-closed) - Free bearer → 403 (entitlements unavailable) - Invalid bearer → 401 (no session, forceKey kicks in) - Public routes → 200 (unchanged) The old tests asserted PREMIUM_RPC_PATHS + JWT role behavior which was removed per koala73's P1-3 review (dual auth elimination). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(review): resolve 4 P1 + 2 P2 issues from koala73 round-9 review P1 fixes: - API-key holders bypass entitlement check (were getting 403) - Browser checkout passes userId for identity bridge (ConvexClient has no setAuth yet, so createCheckout accepts optional userId arg) - /pro pricing page embeds wm_user_id in Dodo checkout URL metadata so webhook can resolve identity for first-time purchasers - Remove isProUser() gate from entitlement/billing init — all users now subscribe to entitlement changes so upgrades take effect immediately without manual page reload P2 fixes: - destroyEntitlementSubscription() called on teardown to clear stale premium state across SPA sessions (sign-out / identity change) - Convex queries prefer resolveUserId(ctx) over client-supplied userId; documented as temporary until Clerk JWT wired into ConvexClient.setAuth() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(review): resolve 4 P1 + 1 P2 from koala73 round-10 review P1 — Checkout identity no longer client-controlled: - createCheckout HMAC-signs userId with DODO_PAYMENTS_WEBHOOK_SECRET - Webhook resolveUserId only trusts metadata when HMAC signature is valid; unsigned/tampered metadata is rejected - /pro raw URLs no longer embed wm_user_id (eliminated URL tampering) - Purchases without signed metadata get synthetic "dodo:{customerId}" userId, claimable later via claimSubscription() P1 — IDOR on Convex queries addressed: - Both getEntitlementsForUser and getSubscriptionForUser now reject mismatched userId when the caller IS authenticated (authedUserId != args.userId → return defaults/null) - Created internal getEntitlementsByUserId for future gateway use - Pre-auth fallback to args.userId documented with TODO(clerk-auth) P1 — Clerk identity bridge fixed: - user-identity.ts now uses getCurrentClerkUser() from clerk.ts instead of reading window.Clerk?.user (which was never assigned) - Signed-in Clerk users now correctly resolve to their Clerk user ID P1 — Auth modal available for anonymous users: - Removed isProUser() gate from setupAuthWidget() in App.ts - Anonymous users can now click premium CTAs → sign-in modal opens P2 — .take(10) subscription cap: - Bumped to .take(50) in both getSubscriptionForUser and getActiveSubscription to avoid missing active subscriptions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(review): resolve remaining P2/P3 feedback from koala73 + greptile on PR #2024 JWKS: consolidate duplicate singletons — server/_shared/auth-session.ts now imports the shared JWKS from server/auth-session.ts (eliminates redundant cold-start fetch). Webhook: remove unreachable retry branch — webhookEvents.status is always "processed" (inserted only after success, rolled back on throw). Dead else removed. YAGNI: remove changePlan action + frontend stub (no callers — plan changes use Customer Portal). Remove unused by_status index on subscriptions table. DRY: consolidate identical pro_monthly/pro_annual into shared PRO_FEATURES constant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): read CLERK_JWT_ISSUER_DOMAIN lazily in getJWKS() — fixes CI bearer token tests The shared getJWKS() was reading the env var from a module-scope const, which freezes at import time. Tests set the env var in before() hooks after import, so getJWKS() returned null and bearer tokens were never verified — causing 401 instead of 403 on entitlement checks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(payments): resolve all blocking issues from review rounds 1-2 Data integrity: - Use .first() instead of .unique() on entitlements.by_userId to survive concurrent webhook retries without permanently crashing reads - Add typed paymentEventStatus union to schema; replace dynamic dispute status string construction with explicit lookup map - Document accepted 15-min Redis cache sync staleness bound - Document webhook endpoint URL in .env.example Frontend lifecycle: - Add listeners.clear() to destroyEntitlementSubscription (prevents stale closure reload loop on destroy/re-init cycles) - Add destroyCheckoutOverlay() to reset initialized flag so new layouts can register their success callbacks - Complete PanelLayoutManager.destroy() — add teardown for checkout overlay, payment failure banner, and entitlement change listener - Preserve currentState across destroy; add resetEntitlementState() for explicit reset (e.g. logout) without clearing on every cycle Code quality: - Export DEV_USER_ID and isDev from lib/auth.ts; remove duplicates from subscriptionHelpers.ts (single source of truth) - Remove DODO_PAYMENTS_API_KEY fallback from billing.ts - Document why two Dodo SDK packages coexist (component vs REST) * fix(payments): address round-3 review findings — HMAC key separation, auth wiring, shape guards - Introduce DODO_IDENTITY_SIGNING_SECRET separate from webhook secret (todo 087) Rotating the webhook secret no longer silently breaks userId identity signing - Wire claimSubscription to Clerk sign-in in App.ts (todo 088) Paying anonymous users now have their entitlements auto-migrated on first sign-in - Promote getCustomerPortalUrl to public action + wire openBillingPortal (todo 089) Manage Billing button now opens personalized portal instead of generic URL - Add rate limit on claimSubscription (todo 090) - Add webhook rawPayload shape guard before handler dispatch (todo 096) Malformed payloads return 200 with log instead of crashing the handler - Remove dead exports: resetEntitlementState, customerPortal wrapper (todo 091) - Fix let payload; implicit any in webhookHandlers.ts (todo 092) - Fix test: use internal.* for internalMutation seedProductPlans (todo 095) Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com> * fix(payments): address round-4 P1 findings — input validation, anon-id cleanup - claimSubscription: replace broken rate-limit (bypassed for new users, false positives on renewals) with UUID v4 format guard + self-claim guard; prevents cross-user subscription theft via localStorage injection - App.ts: always remove wm-anon-id after non-throwing claim completion (not only when subscriptions > 0); adds optional chaining on result.claimed; prevents cold Convex init on every sign-in for non-purchasers Resolves todos 097, 098, 099, 100 * fix(payments): address round-5 review findings — regex hoist, optional chaining - Hoist ANON_ID_REGEX to module scope (was re-allocated on every call) - Remove /i flag — crypto.randomUUID() always produces lowercase - result.claimed accessed directly (non-optional) — mutation return is typed - Revert removeItem from !client || !api branch — preserve anon-id on infrastructure failure; .catch path handles transient errors * fix(billing): wire setAuth, rebind watches, revoke on dispute.lost, defer JWT, bound collect - convex-client.ts: wire client.setAuth(getClerkToken) so claimSubscription and getCustomerPortalUrl no longer throw 'Authentication required' in production - clerk.ts: expose clearClerkTokenCache() for force-refresh handling - App.ts: rebind entitlement + subscription watches to real Clerk userId on every sign-in (destroy + reinit), fixing stale anon-UUID watches post-claim - subscriptionHelpers.ts: revoke entitlement to 'free' on dispute.lost + sync Redis cache; previously only logged a warning leaving pro access intact after chargeback - gateway.ts: compute isTierGated before resolveSessionUserId; defer JWKS+RS256 verification to inside if (isTierGated) — eliminates JWT work on ~136 non-gated endpoints - billing.ts: .take(1000) on paymentEvents collect — safety bound preventing runaway memory on pathological anonymous sessions before sign-in Closes P1: setAuth never wired (claimSubscription always throws in prod) Closes P2: watch rebind, dispute.lost revocation, gateway perf, unbounded collect * fix(security): address P1 review findings from round-6 audit - Remove _debug block from validateApiKey that contained all valid API keys in envVarRaw/parsedKeys fields (latent full-key disclosure risk) - Replace {db: any} with QueryCtx in getEntitlementsHandler (Convex type safety) - Add pre-insert re-check in upsertEntitlements with OCC race documentation - Fix dispute.lost handler to use eventTimestamp instead of Date.now() for validUntil/updatedAt (preserves isNewerEvent out-of-order replay protection) - Extract getFeaturesForPlan("free") to const in dispute.lost (3x → 1x call) Closes todos #103, #106, #107, #108 * fix(payments): address round-6 open items — throw on shape guard, sign-out cleanup, stale TODOs P2-4: Webhook shape guards now throw instead of returning silently, so Dodo retries on malformed payloads instead of losing events. P3-2: Sign-out branch now destroys entitlement and subscription watches for the previous userId. P3: Removed stale TODO(clerk-auth) comments — setAuth() is wired. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(payments): address codex review — preserve listeners, honest TODOs, retry comment - destroyEntitlementSubscription/destroySubscriptionWatch no longer clear listeners — PanelLayout registers them once and they must survive auth transitions (sign-out → sign-in would lose the premium-unlock reload) - Restore TODO(auth) on entitlements/billing public queries — the userId fallback is a real trust gap, not just a cold-start race - Add comment on webhook shape guards acknowledging Dodo retry budget tradeoff vs silent event loss Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(types): restore non-null assertions in convex/http.ts after merge Main removed ! and as-any casts, but our branch's generated types make anyApi properties possibly undefined. Re-added to fix CI typecheck. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(types): resolve TS errors from rebase — cast internal refs, remove unused imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: regenerate package-lock.json — add missing uqr@0.1.2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(payments): resolve P2/P3 review findings for PR #2024 - Bound and parallelize claimSubscription reads with Promise.all (4x queries -> single round trip; .collect() -> .take() to cap memory) - Add returnUrl allowlist validation in createCheckout to prevent open redirect - Make openBillingPortal return Promise<string | null> for agent-native callers - Extend isCallerPremium with Dodo entitlement tier check (tier >= 1 is premium, unifying Clerk role:pro and Dodo subscriber as two signals for the same gate) - Call resetEntitlementState() on sign-out to prevent entitlement state leakage across sessions (destroyEntitlementSubscription preserves state for reconnects; resetEntitlementState is the explicit sign-out nullifier) - Merge handlePaymentEvent + handleRefundEvent -> handlePaymentOrRefundEvent (type inferred from event prefix; eliminates duplicate resolveUserId call) - Remove _testCheckEntitlement DI export from entitlement-check.ts; inline _checkEntitlementCore into checkEntitlement; tests now mock getCachedJson - Collapse 4 duplicate dispute status tests into test.each - Fix stale entitlement variable name in claimSubscription return value * fix(payments): harden auth and checkout ownership * fix(gateway): tighten auth env handling * fix(gateway): use convex site url fallback * fix(app): avoid redundant checkout resume * fix(convex): cast alertRules internal refs for PR-branch generated types --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Elie Habib <elie.habib@gmail.com> |
||
|
|
36edf4404a |
fix: update contact email to elie@worldmonitor.app (#2623)
Replace personal Gmail with branded domain email in the contact form fallback and security.txt. |
||
|
|
c8fe1a88a5 |
seo: fix H1, meta descriptions, schema author, and alternateName (#2537)
* seo: fix meta descriptions, H1, schema author, and alternateName * seo: apply P2 review suggestions from Greptile |
||
|
|
51f7b7cf6d |
feat(mcp): full OAuth 2.1 compliance — Authorization Code + PKCE + DCR (#2432)
* feat(mcp): full OAuth 2.1 compliance — Authorization Code + PKCE + DCR Adds the complete OAuth 2.1 flow required by claude.ai (and any MCP 2025-03-26 client) on top of the existing client_credentials path. New endpoints: - POST /oauth/register — Dynamic Client Registration (RFC 7591) Strict allowlist: claude.ai/claude.com callbacks + localhost only. 90-day sliding TTL on client records (no unbounded Redis growth). - GET/POST /oauth/authorize — Consent page + authorization code issuance CSRF nonce binding, X-Frame-Options: DENY, HTML-escapes all metadata, shows exact redirect hostname, Origin validation (must be our domain). Stores full SHA-256 of API key (not fingerprint) in auth code. - authorization_code + refresh_token grants in /oauth/token Both use GETDEL for atomic single-use consumption (no race condition). Refresh tokens carry family_id for future family invalidation. Returns 401 invalid_client when DCR client is expired (triggers re-reg). Security improvements: - verifyPkceS256() validates code_verifier format before any SHA-256 work; returns null=invalid_request vs false=invalid_grant. - Full SHA-256 (64 hex) stored for new OAuth tokens; legacy client_credentials keeps 16-char fingerprint (backward compat). - Discovery doc: only authorization_code + refresh_token advertised. - Protected resource metadata: /.well-known/oauth-protected-resource - WWW-Authenticate headers include resource_metadata param. - HEAD /mcp returns 200 (Anthropic probe compatibility). - Origin validation on POST /mcp: claude.ai/claude.com + absent allowed. - ping method + tool annotations (readOnlyHint, openWorldHint). - api/oauth/ subdir added to edge-function module isolation scan. * fix(oauth): distinguish Redis unavailable from key-miss; fix retry nonce; extend client TTL on token use P1: redisGetDel and redisGet in token.js and authorize.js now throw on transport/HTTP errors instead of swallowing them as null. Callers catch and return 503 so clients know to retry, not discard valid codes/tokens. Key-miss (result=null from Redis) still returns null as before. P2a: Invalid API key retry path now generates and stores a fresh nonce before re-rendering the consent form. Previously the new nonce was never persisted, causing the next submit to fail with "Session Expired" and forcing the user to restart the entire OAuth flow on a single typo. P2b: token.js now extends the client TTL (EXPIRE, fire-and-forget) after a successful client lookup in both authorization_code and refresh_token paths. CLIENT_TTL_SECONDS was defined but unused — clients that only refresh tokens would expire after 90 days despite continuous use. * fix(oauth): atomic nonce consumption via GETDEL; fail closed on nonce storage failure P2a: Nonce storage result is now checked before rendering the consent page (both initial GET and invalid-key retry path). If redisSet returns false (storage unavailable), we return a 503-style error page instead of rendering a form the user cannot submit successfully. P2b: CSRF nonce is now consumed atomically via GETDEL instead of a read-then-fire-and-forget-delete. Two concurrent POST submits can no longer both pass validation before the delete lands, and the delete is no longer vulnerable to edge runtime isolate teardown. |
||
|
|
25941af905 |
fix(oauth): set issuer to api.worldmonitor.app to match MCP connector URL (#2427)
Discovery doc issuer must match the domain the MCP is accessed from (RFC 8414). api.worldmonitor.app proxies to Vercel via CF — MCP and OAuth token endpoint both work there. Update issuer + token_endpoint so claude.ai handshake passes. |
||
|
|
0e4fbfb2b9 |
fix(oauth): use www.worldmonitor.app in discovery doc to match actual MCP URL (#2426)
RFC 8414 requires issuer to match the URL the discovery doc is served from. worldmonitor.app 307-redirects to www.worldmonitor.app, so the MCP connector URL must be https://www.worldmonitor.app/mcp. Update issuer + token_endpoint to match so claude.ai does not reject the OAuth handshake. |
||
|
|
14a31c4283 |
feat(mcp): OAuth 2.0 Authorization Server for claude.ai connector (#2418)
* feat(mcp): add OAuth 2.0 Authorization Server for claude.ai connector Implements spec-compliant MCP authentication so claude.ai's remote connector (which requires OAuth Client ID + Secret, no custom headers) can authenticate. - public/.well-known/oauth-authorization-server: RFC 8414 discovery document - api/oauth/token.js: client_credentials grant, issues UUID Bearer token in Redis TTL 3600s - api/_oauth-token.js: resolveApiKeyFromBearer() looks up token in Redis - api/mcp.ts: 3-tier auth (Bearer OAuth first, then ?key=, then X-WorldMonitor-Key); switch to getPublicCorsHeaders; surface error messages in catch - vercel.json: rewrite /oauth/token, exclude oauth from SPA, CORS headers - tests: update SPA no-cache pattern Supersedes PR #2417. Usage: URL=worldmonitor.app/mcp, Client ID=worldmonitor, Client Secret=<API key> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: fix markdown lint in OAuth plan (blank lines around lists) * fix(oauth): address all P1+P2 code review findings for MCP OAuth endpoint - Add per-IP rate limiting (10 req/min) to /oauth/token via Upstash slidingWindow - Return HTTP 401 + WWW-Authenticate header when Bearer token is invalid/expired - Add Cache-Control: no-store + Pragma: no-cache to token response (RFC 6749 §5.1) - Simplify _oauth-token.js to delegate to readJsonFromUpstash (removes duplicated Redis boilerplate) - Remove dead code from token.js: parseBasicAuth, JSON body path, clientId/issuedAt fields - Add Content-Type: application/json header for /.well-known/oauth-authorization-server - Remove response_types_supported (only applies to authorization endpoint, not client_credentials) Closes: todos 075, 076, 077, 078, 079 🤖 Generated with claude-sonnet-4-6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0 Co-Authored-By: claude-sonnet-4-6 (200K context) <noreply@anthropic.com> * chore(review): fresh review findings — todos 081-086, mark 075/077/078/079 complete * fix(mcp): remove ?key= URL param auth + mask internal errors - Remove ?key= query param auth path — API keys in URLs appear in Vercel/CF access logs, browser history, Referer headers. OAuth client_credentials (same PR) already covers clients that cannot set custom headers. Only two auth paths remain: Bearer OAuth and X-WorldMonitor-Key header. - Revert err.message disclosure: catch block was accidentally exposing internal service URLs/IPs via err.message. Restore original hardcoded string, add console.error for server-side visibility. Resolves: todos 081, 082 * fix(oauth): resolve all P2/P3 review findings (todos 076, 080, 083-086) - 076: no-credentials path in mcp.ts now returns HTTP 401 + WWW-Authenticate instead of rpcError (200) - 080: store key fingerprint (sha256 first 16 hex chars) in Redis, not plaintext key - 083: replace Array.includes() with timingSafeIncludes() (constant-time HMAC comparison) in token.js and mcp.ts - 084: resolveApiKeyFromBearer uses direct fetch that throws on Redis errors (500 not 401 on infra failure) - 085: token.js imports getClientIp, getPublicCorsHeaders, jsonResponse from shared helpers; removes local duplicates - 086: mcp.ts auth chain restructured to check Bearer header first, passes token string to resolveApiKeyFromBearer (eliminates double header read + unconditional await) * test(mcp): update auth test to expect HTTP 401 for missing credentials Align with todo 076 fix: no-credentials path now returns 401 + WWW-Authenticate instead of JSON-RPC 200 response. Also asserts WWW-Authenticate header presence. * chore: mark todos 076, 080, 083-086 complete * fix(mcp): harden OAuth error paths and fix rate limit cross-user collision - Wrap resolveApiKeyFromBearer() in try/catch in mcp.ts; Redis/network errors now return 503 + Retry-After: 5 instead of crashing the handler - Wrap storeToken() fetch in try/catch in oauth/token.js; network errors return false so the existing if (!stored) path returns 500 cleanly - Re-key token endpoint rate limit by sha256(clientSecret).slice(0,8) instead of IP; prevents cross-user 429s when callers share Anthropic's shared outbound IPs (Claude remote MCP connector) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
93c28cf4e6 |
fix(widgets): fix CSP violations in pro widget iframe (#2362)
* fix(widgets): fix CSP violations in pro widget iframe by using sandbox page srcdoc iframes inherit the parent page's Content-Security-Policy response headers. The parent's hash-based script-src blocks inline scripts and cdn.jsdelivr.net (Chart.js), making pro widgets silently broken. Fix: replace srcdoc with a dedicated /wm-widget-sandbox.html page that has its own permissive CSP via vercel.json route headers. Widget HTML is passed via postMessage after the sandbox page loads. - Add public/wm-widget-sandbox.html: minimal relay page that receives HTML via postMessage and renders it with document.open/write/close. Validates message origin against known worldmonitor.app domains. - vercel.json: add CSP override route for sandbox page (unsafe-inline + cdn.jsdelivr.net), exclude from SPA rewrite and no-cache rules. - widget-sanitizer.ts: switch wrapProWidgetHtml to src + data-wm-id, store widget bodies in module-level Map, auto-mount via MutationObserver. Fix race condition (always use load event, not readyState check). Delete store entries after mount to prevent memory leak. - tests: update 4 tests to reflect new postMessage architecture. * test(deploy): update deploy-config test for wm-widget-sandbox.html exclusion |
||
|
|
0169245f45 |
feat(seo): BlogPosting schema, FAQPage JSON-LD, extensible author system (#2284)
* feat(seo): BlogPosting schema, FAQPage JSON-LD, author system, AI crawler welcome Blog structured data: - Change @type Article to BlogPosting for all blog posts - Author: Organization to Person with extensible default (Elie Habib) - Add per-post author/authorUrl/authorBio/modifiedDate frontmatter fields - Auto-extract FAQPage JSON-LD from FAQ sections in all 17 posts - Show Updated date when modifiedDate differs from pubDate - Add author bio section with GitHub avatar and fallback Main app: - Add commodity variant to middleware VARIANT_HOST_MAP and VARIANT_OG - Add commodity.worldmonitor.app to sitemap.xml - Shorten index.html meta description to 136 chars (was 161) - Remove worksFor block from index.html author JSON-LD - Welcome all bots in robots.txt (removed per-bot blocks, global allows) - Update llms.txt: five variants listed, all 17 blog post URLs added * fix(seo): scope FAQ regex to section boundary, use author-aware avatar - extractFaqLd now slices only to the next ## heading (was: to end of body) preventing bold text in post-FAQ sections from being mistakenly extracted - Avatar src now derived from DEFAULT_AUTHOR_GITHUB constant (koala73) only when using the default author; custom authors fall back to favicon so multi-author posts show a correct image instead of the wrong profile |
||
|
|
53581978a8 |
fix(contact): send admin notifications to elie.habib@gmail.com (#1970)
Change hardcoded fallback recipient in api/contact.js from sales@worldmonitor.app to elie.habib@gmail.com. Update security.txt contact address to match. Note: if CONTACT_NOTIFY_EMAIL is set in Vercel env vars to elie@worldmonitor.app, update that too. |
||
|
|
32ca22d69f |
feat(analytics): add Umami analytics via self-hosted instance (#1914)
* feat(analytics): add Umami analytics via self-hosted instance Adds Umami analytics script from abacus.worldmonitor.app and updates CSP headers in both index.html and vercel.json to allow the script. * feat(analytics): complete Umami integration with event tracking - Add data-domains to index.html script to exclude dev traffic - Add Umami script to /pro page and blog (Base.astro) - Add TypeScript Window.umami shim to vite-env.d.ts - Wire analytics.ts facade to Umami (replaces PostHog no-ops): search, country clicks, map layers, panels, LLM usage, theme, language, variant switch, webcam, download, findings, deeplinks - Add direct callsite tracking for: settings-open, mcp-connect-attempt, mcp-connect-success, mcp-panel-add, widget-ai-open/generate/success, news-summarize, news-sort-toggle, live-news-fullscreen, webcam-fullscreen, search-open (desktop/mobile/fab) * fix(analytics): add Tauri CSP allowlist for Umami + skip programmatic layer events - Add abacus.worldmonitor.app to Tauri CSP script-src and connect-src so Umami loads in the desktop WebView (analytics exception to the no-cloud-data rule — needed to know if desktop is used) - Filter trackMapLayerToggle to user-initiated events only to avoid inflating counts with programmatic toggles on page load |
||
|
|
84ce00d026 |
fix: add India boundary override from Natural Earth 50m data (#1796)
Adds India (IN) to country-boundary-overrides.geojson using the same Natural Earth 50m Admin 0 Countries dataset already used for Pakistan. The override system automatically replaces the base geometry on app load, providing a higher-resolution MultiPolygon boundary (1518 points). Renames the fetch script to reflect its broader purpose and updates doc references in CONTRIBUTING.md and maps-and-geocoding.mdx. Fixes koala73/worldmonitor#1721 Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Elie Habib <elie.habib@gmail.com> |
||
|
|
048a509e67 |
fix(seo): add all blog posts to sitemap.xml, fix /pro trailing slash (#1841)
* fix(seo): add all blog post URLs to sitemap.xml, fix /pro trailing slash * fix(seo): align /pro sitemap entry with page canonical (no trailing slash) public/pro/index.html declares https://www.worldmonitor.app/pro as the canonical and hreflang URL. The sitemap had /pro/ which sent conflicting signals to crawlers. Reverted to /pro to match the page metadata. |
||
|
|
65194a1c58 |
fix(seo): add IndexNow key, sitemap lastmod dates for crawl recovery (#1833)
- Add IndexNow verification key file (public/a7f3e9d1b2c44e8f9a0b1c2d3e4f5a6b.txt) - Update sitemap.xml lastmod to 2026-03-19 to signal freshness to crawlers - Add lastmod dates to blog sitemap via @astrojs/sitemap serialize() - Add scripts/seo-indexnow-submit.mjs to resubmit all 23 URLs to IndexNow (run after deploy: node scripts/seo-indexnow-submit.mjs) |
||
|
|
222dd99f75 |
fix(data): restore bootstrap and cache test coverage (#1649)
* fix(data): restore bootstrap and cache test coverage * fix: resolve linting and test failures - Remove dead writeSeedMeta/estimateRecordCount functions from redis.ts (intentionally removed from cachedFetchJson; seed-meta now written only by explicit seed flows, not generic cache reads) - Fix globe dayNight test to match actual code (forces dayNight: false + hideLayerToggle, not catalog-based exclusion) - Fix country-geometry test mock URL from CDN to /data/countries.geojson (source changed to use local bundled file) * fix(lint): remove duplicate llm-health key in redis-caching test Duplicate object key '../../../_shared/llm-health' caused the stub to be overwritten by the real module. Removed the second entry so the test correctly uses the stub. |