Files
worldmonitor/api/api-route-exceptions.json
Elie Habib 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.
2026-04-22 23:39:32 +04:00

406 lines
17 KiB
JSON

{
"$comment": "Single source of truth for non-proto /api/ endpoints. All new JSON data APIs MUST use sebuf (proto → buf generate → handler). This manifest is the only escape hatch, and every entry is reviewed by @SebastienMelki (see .github/CODEOWNERS). Categories: external-protocol (MCP / OAuth — shape dictated by external spec), non-json (binary/HTML/image responses), upstream-proxy (raw pass-through of an external feed), ops-admin (health/cron/version — operator plumbing, not a product API), internal-helper (dashboard-internal bundle, not user-facing), deferred (should migrate eventually — must have a removal_issue), migration-pending (actively being migrated in an open PR — removed as its commit lands). See docs/adding-endpoints.mdx.",
"schema_version": 1,
"exceptions": [
{
"path": "api/mcp.ts",
"category": "external-protocol",
"reason": "MCP streamable-HTTP transport — JSON-RPC 2.0 envelope dictated by the Model Context Protocol spec, not something we can or should redefine as proto.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/mcp-proxy.js",
"category": "external-protocol",
"reason": "Proxy for the MCP transport — same shape constraint as api/mcp.ts.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/oauth/authorize.js",
"category": "external-protocol",
"reason": "OAuth 2.0 authorization endpoint. Response is an HTML consent page and 302 redirect; request/response shape is dictated by RFC 6749.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/oauth/register.js",
"category": "external-protocol",
"reason": "OAuth 2.0 dynamic client registration (RFC 7591). Shape fixed by the spec.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/oauth/token.js",
"category": "external-protocol",
"reason": "OAuth 2.0 token endpoint (RFC 6749). application/x-www-form-urlencoded request body; shape fixed by the spec.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/discord/oauth/callback.ts",
"category": "external-protocol",
"reason": "Discord OAuth redirect target — response is an HTML popup-closer page, query-param shape fixed by Discord.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/discord/oauth/start.ts",
"category": "external-protocol",
"reason": "Discord OAuth initiator — issues 302 to Discord's authorize URL. Not a JSON API.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/slack/oauth/callback.ts",
"category": "external-protocol",
"reason": "Slack OAuth redirect target — HTML response with postMessage + window.close, query-param shape fixed by Slack.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/slack/oauth/start.ts",
"category": "external-protocol",
"reason": "Slack OAuth initiator — 302 to Slack's authorize URL. Not a JSON API.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/download.js",
"category": "non-json",
"reason": "Binary file download (zip/csv/xlsx). Content-Type is not application/json.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/og-story.js",
"category": "non-json",
"reason": "Open Graph preview image (PNG via @vercel/og). Binary response.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/story.js",
"category": "non-json",
"reason": "Rendered HTML story page for social embeds — response is text/html, not JSON.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/youtube/embed.js",
"category": "non-json",
"reason": "YouTube oEmbed passthrough — shape dictated by YouTube's oEmbed response, served as-is.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/youtube/live.js",
"category": "non-json",
"reason": "Streams YouTube live metadata — chunked text response, not a typed JSON payload.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/brief/carousel/[userId]/[issueDate]/[page].ts",
"category": "non-json",
"reason": "Rendered carousel page image for brief social posts. Binary image response, dynamic path segments.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/opensky.js",
"category": "upstream-proxy",
"reason": "Transparent proxy to OpenSky Network API. Shape is OpenSky's; we don't remodel it.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/polymarket.js",
"category": "upstream-proxy",
"reason": "Transparent proxy to Polymarket gamma API. Shape is Polymarket's.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/gpsjam.js",
"category": "upstream-proxy",
"reason": "Transparent proxy to gpsjam.org tile/feed. Shape is upstream's.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/oref-alerts.js",
"category": "upstream-proxy",
"reason": "Transparent proxy to Pikud HaOref (IDF Home Front Command) alert feed. Shape is upstream's.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/rss-proxy.js",
"category": "upstream-proxy",
"reason": "Generic RSS/Atom XML proxy for CORS-blocked feeds. Response is XML, not JSON.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/telegram-feed.js",
"category": "upstream-proxy",
"reason": "Telegram channel feed proxy — passes upstream MTProto-derived shape through.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/supply-chain/hormuz-tracker.js",
"category": "upstream-proxy",
"reason": "Transparent proxy to Hormuz strait AIS tracker feed. Shape is upstream's.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/health.js",
"category": "ops-admin",
"reason": "Liveness probe hit by uptime monitor and load balancer. Not a product API; plain-text OK response.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/seed-health.js",
"category": "ops-admin",
"reason": "Cron-triggered data-freshness check for feed seeds. Operator tool, not a user-facing API.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/version.js",
"category": "ops-admin",
"reason": "Build-version probe for the desktop auto-updater. Tiny plain-JSON operator plumbing.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/cache-purge.js",
"category": "ops-admin",
"reason": "Admin-gated cache invalidation endpoint. Internal operator action, not a product API.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/seed-contract-probe.ts",
"category": "ops-admin",
"reason": "Cron probe that verifies seed contract shapes against upstreams. Operator telemetry.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/invalidate-user-api-key-cache.ts",
"category": "ops-admin",
"reason": "Admin-gated cache bust for user API key lookups. Internal operator action.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/fwdstart.js",
"category": "ops-admin",
"reason": "Tauri desktop updater bootstrap — starts the sidecar forwarding flow. Operator plumbing, not JSON.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/notify.ts",
"category": "ops-admin",
"reason": "Outbound notification dispatch (Slack/Discord/email) driven by cron. Internal, not a typed user API.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/bootstrap.js",
"category": "internal-helper",
"reason": "Dashboard-internal config bundle assembled at request time. Exposing as a user-facing API would implicitly commit us to its shape; keep it deliberately unversioned and out of the API surface.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/geo.js",
"category": "internal-helper",
"reason": "Lightweight IP-to-geo lookup wrapping Vercel's request.geo. Dashboard-internal helper; not worth a service of its own.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/reverse-geocode.js",
"category": "internal-helper",
"reason": "Reverse-geocode helper used only by the map layer for label rendering. Wraps an upstream provider; shape tracks upstream, not a versioned product contract.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/internal/brief-why-matters.ts",
"category": "internal-helper",
"reason": "Internal brief-pipeline helper — auth'd by RELAY_SHARED_SECRET (Railway cron only), not a user-facing API. Generated on merge of #3248 from main without a manifest entry; filed here to keep the lint green.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/data/city-coords.ts",
"category": "internal-helper",
"reason": "Static city-coordinates payload served from the deploy artifact. Returns a fixed reference table, not a queryable service.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/me/entitlement.ts",
"category": "internal-helper",
"reason": "Thin wrapper over the canonical server/_shared/premium-check.ts isCallerPremium helper — returns { isPro: boolean } for the /pro marketing bundle so it can swap upgrade CTAs for a dashboard link when the visitor is already a paying Pro user. Not a product data API: the authoritative gates live in panel-gating.ts (frontend), isCallerPremium (per-handler), and gateway.ts PREMIUM_RPC_PATHS (Bearer gate). This endpoint exists purely to let the separate pro-test bundle (no Convex client, no gateway client) ask the same question without reimplementing the two-signal check. Shape is unversioned by design — flip a boolean and ship.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/latest-brief.ts",
"category": "deferred",
"reason": "Returns the current user's latest brief. Auth-gated and Clerk-coupled; migrating requires modeling Brief in proto and auth context in handler. Deferred to brief/v1 service.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/brief/share-url.ts",
"category": "deferred",
"reason": "Creates a shareable public URL for a brief. Part of the brief/v1 service work.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/brief/public/[hash].ts",
"category": "deferred",
"reason": "Resolves a share hash to public-safe brief JSON. Part of the brief/v1 service work; dynamic path segment needs proto path-param modeling.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/brief/[userId]/[issueDate].ts",
"category": "deferred",
"reason": "Fetches a specific brief by user + issue date. Part of the brief/v1 service work.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/notification-channels.ts",
"category": "deferred",
"reason": "Lists / configures user notification channels. Auth-gated; migrating requires user/v1 or notifications/v1 service. Deferred until Clerk migration settles.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/user-prefs.ts",
"category": "deferred",
"reason": "Reads / writes user dashboard preferences. Auth-gated; part of user/v1 service work pending Clerk migration.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/create-checkout.ts",
"category": "deferred",
"reason": "Creates a Dodo Payments checkout session. Payments domain is still stabilizing (Clerk + Dodo integration); migrate once shape is frozen.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/customer-portal.ts",
"category": "deferred",
"reason": "Issues a Dodo customer-portal redirect URL. Paired with create-checkout.ts; migrate together.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/product-catalog.js",
"category": "deferred",
"reason": "Returns Dodo product catalog (pricing tiers). Migrate alongside the rest of the payments surface.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/referral/me.ts",
"category": "deferred",
"reason": "Returns the signed-in user's referral state. Auth-gated; part of user/v1 service.",
"owner": "@SebastienMelki",
"removal_issue": "TBD"
},
{
"path": "api/scenario/v1/run.ts",
"category": "deferred",
"reason": "URL-compat alias for POST /api/scenario/v1/run-scenario. Thin gateway wrapper that rewrites the documented pre-#3207 v1 URL to the canonical sebuf RPC path. Not a new endpoint — preserves the partner-documented wire contract. Retires at the next v1→v2 break.",
"owner": "@SebastienMelki",
"removal_issue": "#3282"
},
{
"path": "api/scenario/v1/status.ts",
"category": "deferred",
"reason": "URL-compat alias for GET /api/scenario/v1/get-scenario-status. See api/scenario/v1/run.ts for the same rationale.",
"owner": "@SebastienMelki",
"removal_issue": "#3282"
},
{
"path": "api/scenario/v1/templates.ts",
"category": "deferred",
"reason": "URL-compat alias for GET /api/scenario/v1/list-scenario-templates. See api/scenario/v1/run.ts for the same rationale.",
"owner": "@SebastienMelki",
"removal_issue": "#3282"
},
{
"path": "api/supply-chain/v1/country-products.ts",
"category": "deferred",
"reason": "URL-compat alias for GET /api/supply-chain/v1/get-country-products. See api/scenario/v1/run.ts for the same rationale.",
"owner": "@SebastienMelki",
"removal_issue": "#3282"
},
{
"path": "api/supply-chain/v1/multi-sector-cost-shock.ts",
"category": "deferred",
"reason": "URL-compat alias for GET /api/supply-chain/v1/get-multi-sector-cost-shock. See api/scenario/v1/run.ts for the same rationale.",
"owner": "@SebastienMelki",
"removal_issue": "#3282"
},
{
"path": "api/v2/shipping/webhooks/[subscriberId].ts",
"category": "migration-pending",
"reason": "Partner-facing path-parameter endpoint (GET status by subscriber id). Cannot migrate to sebuf yet — no path-param support in the annotation layer. Paired with the typed ShippingV2Service on the same base URL; tracked for eventual migration once sebuf path params are available.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/v2/shipping/webhooks/[subscriberId]/[action].ts",
"category": "migration-pending",
"reason": "Partner-facing path-parameter endpoints (POST rotate-secret, POST reactivate). Cannot migrate to sebuf yet — no path-param support. Paired with the typed ShippingV2Service; tracked for eventual migration once sebuf path params are available.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/chat-analyst.ts",
"category": "migration-pending",
"reason": "SSE streaming endpoint. Migrating to analyst/v1.ChatAnalyst (streaming RPC) in commit 9 of #3207. Blocked on sebuf#150 (TS-server SSE codegen).",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/widget-agent.ts",
"category": "migration-pending",
"reason": "Migrating to analyst/v1.WidgetComplete in commit 9 of #3207. Blocked on sebuf#150.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
},
{
"path": "api/skills/fetch-agentskills.ts",
"category": "migration-pending",
"reason": "Migrating to analyst/v1.ListAgentSkills in commit 9 of #3207. Blocked on sebuf#150.",
"owner": "@SebastienMelki",
"removal_issue": "#3207"
}
]
}