Commit Graph

27 Commits

Author SHA1 Message Date
Elie Habib
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.
2026-04-23 22:21:25 +04:00
Elie Habib
26d426369f feat(agent-readiness): RFC 8288 Link headers on homepage (#3353)
* feat(agent-readiness): RFC 8288 Link headers on homepage

Closes #3308, part of epic #3306.

Emit Link response headers on / and /index.html advertising every
live agent-discoverable target. All rels use IANA-registered values
(api-catalog, service-desc, service-doc, status) or the full IANA
URI form for OAuth metadata rels (per RFC 9728).

The mcp-server-card rel carries anchor="/mcp" to scope it to the
MCP endpoint rather than the homepage, since the server card
describes /mcp specifically.

New guardrail block in tests/deploy-config.test.mjs asserts every
required rel is present, targets are root-relative, and the MCP
anchor remains in place.

* test(agent-readiness): lockstep / + /index.html Link + exact target count

Adds two test-only guards on the homepage Link-headers suite:

- exact-count assertion on link targets (was `>= requiredRels.length`),
  catches accidental duplicate rels in vercel.json
- equality guard between `/` and `/index.html` Link headers, catches
  silent drift when one entry gets edited and the other doesn't

No production behavior change.
2026-04-23 21:50:25 +04:00
Elie Habib
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>
2026-04-23 21:17:32 +04:00
Elie Habib
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).
2026-04-23 19:42:13 +04:00
Elie Habib
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.
2026-04-23 18:46:35 +04:00
Elie Habib
4853645d53 fix(brief): switch carousel to @vercel/og on edge runtime (#3210)
* fix(brief): switch carousel to @vercel/og on edge runtime

Every attempt to ship the Phase 8 Telegram carousel on Vercel's
Node serverless runtime has failed at cold start:

- PR #3174 direct satori + @resvg/resvg-wasm: Vercel edge bundler
  refused the `?url` asset import required by resvg-wasm.
- PR #3174 (fix) direct satori + @resvg/resvg-js native binding:
  Node runtime accepted it, but Vercel's nft tracer does not follow
  @resvg/resvg-js/js-binding.js's conditional
  `require('@resvg/resvg-js-<platform>-<arch>-<libc>')` pattern,
  so the linux-x64-gnu peer package was never bundled. Cold start
  threw MODULE_NOT_FOUND, isolate crashed,
  FUNCTION_INVOCATION_FAILED on every request including OPTIONS,
  and Telegram reported WEBPAGE_CURL_FAILED with no other signal.
- PR #3204 added `vercel.json` `functions.includeFiles` to force
  the binding in, but (a) the initial key was a literal path that
  Vercel micromatch read as a character class (PR #3206 fixed),
  (b) even with the corrected `api/brief/carousel/**` wildcard, the
  function still 500'd across the board. The `functions.includeFiles`
  path appears honored in the deployment manifest but not at runtime
  for this particular native-binding pattern.

Fix: swap the renderer to @vercel/og's ImageResponse, which is
Vercel's first-party wrapper around satori + resvg-wasm with
Vercel-native bundling. Runs on Edge runtime — matches every other
API route in the project. No native binding, no includeFiles, no
nft tracing surprises. Cold start ~300ms, warm ~30ms.

Changes:
- server/_shared/brief-carousel-render.ts: replace renderCarouselPng
  (Uint8Array) with renderCarouselImageResponse (ImageResponse).
  Drop ensureLibs + satori + @resvg/resvg-js dynamic-import dance.
  Keep layout builders (buildCover/buildThreads/buildStory) and
  font loading unchanged — the Satori object trees are
  wire-compatible with ImageResponse.
- api/brief/carousel/[userId]/[issueDate]/[page].ts: flip
  `runtime: 'nodejs'` -> `runtime: 'edge'`. Delegate rendering to
  the renderer's ImageResponse and return it directly; error path
  still 503 no-store so CDN + Telegram don't pin a bad render.
- vercel.json: drop the now-useless `functions.includeFiles` block.
- package.json: drop direct `@resvg/resvg-js` and `satori` deps
  (both now bundled inside @vercel/og).
- tests/deploy-config.test.mjs: replace the native-binding
  regression guards with an assertion that no `functions` block
  exists (with a comment pointing at the skill documenting the
  micromatch gotcha for future routes).
- tests/brief-carousel.test.mjs: updated comment references.

Verified:
- typecheck + typecheck:api clean
- test:data 5814/5814 pass
- node -e test: @vercel/og imports cleanly in Node (tests that
  reach through the renderer file no longer depend on native
  bindings)

Post-deploy validation:
  curl -I -H "User-Agent: TelegramBot (like TwitterBot)" \
    "https://www.worldmonitor.app/api/brief/carousel/<uid>/<slot>/0"
  # Expect: HTTP/2 403 (no token) or 200 (valid token)
  # NOT:    HTTP/2 500 FUNCTION_INVOCATION_FAILED

Then tail Railway digest logs on the next tick; the
`[digest] Telegram carousel 400 ... WEBPAGE_CURL_FAILED` line
should stop appearing, and the 3-image preview should actually land
on Telegram.

* Add renderer smoke test + fix Cache-Control duplication

Reviewer flagged residual risk: no dedicated carousel-route smoke
test for the @vercel/og path. Adds one, and catches a real bug in
the process.

Findings during test-writing:

1. @vercel/og's ImageResponse runs CLEANLY in Node via tsx — the
   comment in brief-carousel.test.mjs saying "we can't test the
   render in Node" was true for direct satori + @resvg/resvg-wasm
   but no longer holds after PR #3210. Pure Node render works
   end-to-end: satori tree-parse, jsdelivr font fetch, resvg-wasm
   init, PNG output. ~850ms first call, ~20ms warm.

2. ImageResponse sets its own default
   `Cache-Control: public, immutable, no-transform, max-age=31536000`.
   Passing Cache-Control via the constructor's headers option
   APPENDS rather than overrides, producing a duplicated
   comma-joined value like
   `public, immutable, no-transform, max-age=31536000, public, max-age=60`
   on the Response. The route handler was doing exactly this via
   extraHeaders. Fix: drop our Cache-Control override and rely on
   @vercel/og's 1-year immutable default — envelope is only
   immutable for its 7d Redis TTL so the effective ceiling is 7d
   anyway (after that the route 404s before render).

Changes:

- tests/brief-carousel.test.mjs: 6 new assertions under
  `renderCarouselImageResponse`:
    * renders cover / threads / story pages, each returning a
      valid PNG (magic bytes + size range)
    * rejects a structurally empty envelope
    * threads non-cache extraHeaders onto the Response
    * pins @vercel/og's Cache-Control default so it survives
      caller-supplied Cache-Control overrides (regression guard
      for the bug fixed in this commit)
- api/brief/carousel/[userId]/[issueDate]/[page].ts: remove the
  stacked Cache-Control; lean on @vercel/og default. Drop the now-
  unused `PAGE_CACHE_TTL` constant. Comment explains why.

Verified:
- test:data 5820/5820 pass (was 5814, +6 smoke)
- typecheck + typecheck:api clean
- Render smoke: cover 825ms / threads 23ms / story 16ms first run
  (wasm init dominates first render)
2026-04-19 15:18:12 +04:00
Elie Habib
56054bfbc1 fix(brief): use wildcard glob in vercel.json functions key (PR #3204 follow-up) (#3206)
* fix(brief): use wildcard glob in vercel.json functions key

PR #3204 shipped the right `includeFiles` value but the WRONG key:

  "api/brief/carousel/[userId]/[issueDate]/[page].ts"

Vercel's `functions` config keys are micromatch globs, not literal
paths. Bracketed segments like `[userId]` are parsed as character
classes (match any ONE character from {u,s,e,r,I,d}), so my rule
matched zero files and `includeFiles` was silently ignored. Post-
merge probe still returned HTTP 500 FUNCTION_INVOCATION_FAILED on
every request. Build log shows zero mentions of `carousel` or
`resvg` — corroborates the key never applied.

Fix: wildcard path segments.

  "api/brief/carousel/**"

Matches any file under the carousel route dir. Since the only
deployed file there is the dynamic-segment handler, the effective
scope is identical to what I originally intended.

Added a second regression test that sweeps every functions key and
fails loudly if any bracketed segment slips back in. Guards against
future reverts AND against anyone copy-pasting the literal route
path without realising Vercel reads it as a glob.

23/23 deploy-config tests pass (was 22, +1 new guard).

* Address Greptile P2: widen bracket-literal guard regex

Greptile spotted that `/\[[A-Za-z]+\]/` only matches purely-alphabetic
segment names. Real-world Next.js routes often use `[user_id]`,
`[issue_date]`, `[page1]`, `[slug2024]` — none flagged by the old
regex, so the guard would silently pass on the exact kind of
regression it was written to catch.

Widened to `/\[[A-Za-z][A-Za-z0-9_]*\]/`:
  - requires a leading letter (so legit char classes like `[0-9]`
    and `[!abc]` don't false-positive)
  - allows letters, digits, underscores after the first char
  - covers every Next.js-style dynamic-segment name convention

Also added a self-test that pins positive cases (userId, user_id,
issue_date, page1, slug2024) and negative cases (the actual `**`
glob, `[0-9]`, `[!abc]`) so any future narrowing of the regex
breaks CI immediately instead of silently re-opening PR #3206.

24/24 deploy-config tests pass (was 23, +1 new self-test).
2026-04-19 14:02:30 +04:00
Elie Habib
27849fee1e fix(brief): bundle resvg linux-x64-gnu native binding with carousel fn (#3204)
* fix(brief): bundle resvg linux-x64-gnu native binding with carousel fn

Real root cause of every Telegram carousel WEBPAGE_CURL_FAILED
since PR #3174 merged. Not middleware (last PR fixed that
theoretical path but not the observed failure). The Vercel
function itself crashes HTTP 500 FUNCTION_INVOCATION_FAILED on
every request including OPTIONS - the isolate can't initialise.

The handler imports brief-carousel-render which lazy-imports
@resvg/resvg-js. That package's js-binding.js does runtime
require(@resvg/resvg-js-<platform>-<arch>-<libc>). On Vercel
Lambda (Amazon Linux 2 glibc) that resolves to
@resvg/resvg-js-linux-x64-gnu. Vercel nft tracing does NOT
follow this conditional require so the optional peer package
isnt bundled. Cold start throws MODULE_NOT_FOUND, isolate
crashes, Vercel returns FUNCTION_INVOCATION_FAILED, Telegram
reports WEBPAGE_CURL_FAILED.

Fix: vercel.json functions.includeFiles forces linux-x64-gnu
binding into the carousel functions bundle. Only this route
needs it; every other api route is unaffected.

Verified:
- deploy-config tests 21/21 pass
- JSON valid
- Reproduced 500 via curl on all methods and UAs
- resvg-js/js-binding.js confirms linux-x64-gnu is the runtime
  binary on Amazon Linux 2 glibc

Post-merge: curl with TelegramBot UA should return 200 image/png
instead of 500; next cron tick should clear the Railway
[digest] Telegram carousel 400 line.

* Address Greptile P2s: regression guard + arch-assumption reasoning

Two P2 findings on PR #3204:

P2 #1 (inline on vercel.json:6): Platform architecture assumption
undocumented. If Vercel migrates to Graviton/arm64 Lambda the
cold-start crash silently returns. vercel.json is strict JSON so
comments aren't possible inline.

P2 #2 (tests/deploy-config.test.mjs:17): No regression guard for
the carousel includeFiles rule. A future vercel.json tidy-up
could silently revert the fix with no CI signal.

Fixed both in a single block:

- New describe() in deploy-config.test.mjs asserts the carousel
  route's functions entry exists AND its includeFiles points at
  @resvg/resvg-js-linux-x64-gnu. Any drift fails the build.
- The block comment above it documents the Amazon Linux 2 x86_64
  glibc assumption that would have lived next to the includeFiles
  entry if JSON supported comments. Includes the Graviton/arm64
  migration pointer.

tests 22/22 pass (was 21, +1 new).
2026-04-19 13:36:17 +04:00
Elie Habib
8684e5a398 fix(brief): per-route CSP override so magazine swipe/arrow nav runs (#3165)
* fix(brief): per-route CSP override so magazine swipe/arrow nav runs

The global CSP at /((?!docs).*) allow-lists only four SHA-256 hashes
for inline scripts (the app's own index.html scripts). brief-render.js
emits its swipe/arrow/wheel/touch nav as a deterministic inline IIFE
with a different hash, so the browser silently blocked it. The deck
rendered, pages were present, dots were drawn — but nothing advanced.

Fix mirrors the existing /api/slack/oauth/callback and
/api/discord/oauth/callback precedent: a per-route Content-Security-
Policy header for /api/brief/(.*) that relaxes script-src to
'unsafe-inline'. Everything else is tight:
- default-src 'self'
- connect-src 'self' (no outbound network)
- object-src 'none', form-action 'none'
- frame-ancestors pinned to worldmonitor domains
- style-src keeps Google Fonts; font-src keeps gstatic
- script-src keeps Cloudflare Insights beacon (auto-injected)

'unsafe-inline' is safe here because server/_shared/brief-render.js
HTML-escapes all Redis-sourced content via escapeHtml over [&<>"'].
No user-controlled string reaches the DOM unescaped.

Verified: all 17 tests/deploy-config.test.mjs security-header
assertions still pass (they target the catch-all route, untouched).

* fix(brief): un-block Cloudflare Insights beacon + add CSP test coverage

Two P2 follow-ups from Greptile review on #3165.

1. connect-src was 'self' only — the Cloudflare Insights beacon script
   loaded (script-src allowed static.cloudflareinsights.com) but its
   outbound POST to https://cloudflareinsights.com/cdn-cgi/rum was
   silently blocked. Analytics for brief-page traffic was dropped with
   no console error. Added https://cloudflareinsights.com to
   connect-src so the beacon can ship its payload.

2. tests/deploy-config.test.mjs had 17 assertions for the catch-all
   CSP but nothing for the new /api/brief/(.*) override. Any future
   edit — or accidental deletion — of the rule would land without a
   red test. Added a 4-test suite covering:
   - rule exists with a CSP header
   - script-src allows 'unsafe-inline' (the whole point)
   - connect-src allows cloudflareinsights.com (this fix)
   - tight non-script defaults still present (default-src 'self',
     object-src 'none', form-action 'none', base-uri 'self')

21/21 deploy-config assertions pass locally.
2026-04-18 15:20:01 +04:00
Elie Habib
aa01033dbd fix(csp): add Stripe to payment Permissions-Policy (#2807)
* fix(csp): add Stripe domains to payment Permissions-Policy

Stripe 3D Secure fingerprint iframe needs the payment permission.
Without hooks.stripe.com and js.stripe.com in the payment directive,
browsers block the Payment Request API inside Stripe's iframe.

* test: update deploy-config test for new Stripe payment permission
2026-04-08 00:33:51 +04:00
Elie Habib
e7e7601c13 fix(csp): allow Turnstile picture-in-picture and xr-spatial-tracking (#2792)
Cloudflare Turnstile probes these permissions for bot detection
fingerprinting, causing console violations on /pro. Grant them to
challenges.cloudflare.com to silence the noise.
2026-04-07 21:14:11 +04:00
Elie Habib
6148d4ca75 fix(csp): allow Dodo payment frames + Google Pay permission (#2789)
- frame-src: added *.hs.dodopayments.com, *.custom.hs.dodopayments.com,
  pay.google.com (Dodo payment iframe and Google Pay)
- Permissions-Policy: payment now allowed for checkout.dodopayments.com
  and pay.google.com (was denied entirely)
2026-04-07 20:26:50 +04:00
Elie Habib
b162b3e84e fix(csp): drop phantom HTTPS connect-src violations from listener (#2602)
* fix(csp): drop phantom HTTPS connect-src violations from listener

The CSP already allows `https:` in connect-src, so any HTTPS
connect-src violation is a phantom report from dual-CSP interaction,
not a real block (the fetch succeeds with HTTP 200).

Changes:
- Check disposition with fallback (e.disposition && !== 'enforce')
- For connect-src, skip ALL HTTPS blocked URIs via new URL() check
- Remove redundant per-host filters (worldmonitor.app, sentry.io)
  that were chasing individual symptoms of the same root cause

* fix(csp): policy-aware HTTPS suppression with testable filter predicate

Addresses review feedback on #2602:

1. Extract shouldSuppressCspViolation() as a testable pure function
2. HTTPS connect-src suppression is now policy-aware: only skips when
   the page CSP actually contains https: in connect-src (checked once
   at init from the meta tag). If CSP is later tightened to an explicit
   allowlist, HTTPS violations will surface again.
3. Add 31-test suite (tests/csp-filter.test.mjs) covering disposition
   gating, policy-aware HTTPS suppression, extension filters, special
   values, third-party noise, and real-violation passthrough.

* fix(csp): guard against header/meta connect-src drift

The listener reads the meta tag to decide if https: is allowed in
connect-src, but browsers enforce both header and meta CSPs. If the
header is tightened (https: removed) while the meta keeps it, the
listener would still suppress real violations.

Fix: return false (don't suppress) when no meta tag exists. Add
deploy-config test that verifies both CSPs have matching https:
scheme in connect-src, so tightening one without the other fails CI.
2026-04-01 22:58:04 +04:00
Elie Habib
9772372a6d fix(csp): break Sentry CSP feedback loop causing 446K daily errors (#2600)
* fix(csp): break Sentry CSP feedback loop causing 446K daily errors

Root cause: the securitypolicyviolation listener reports violations to
Sentry, but the Sentry ingest endpoint itself was blocked by CSP. This
triggered a new violation, which tried to report, which got blocked,
creating an infinite cascade. The existing sentry.io filter regex
didn't match because browsers append :443 to the blocked URI.

Three fixes:
1. Fix Sentry filter regex to handle optional :443 port in blocked URI
2. Add Sentry ingest domains to connect-src in both CSPs
3. Sync script-src hashes between vercel.json header and index.html
   meta tag (removed 4 stale hashes, added 1 missing OAuth hash)

When both HTTP header and meta tag CSP exist, the browser enforces
BOTH independently. Mismatched script-src hashes between them caused
legitimate scripts to be blocked by whichever policy lacked their hash.

* fix(csp): suppress non-actionable CSP violations flooding Sentry (446K/day)

Root cause: the securitypolicyviolation listener (PR #2365) reports ALL
violations to Sentry. Dual CSP (header + meta tag) fires violations for
first-party API calls (api.worldmonitor.app) that actually succeed with
HTTP 200. These are not real blocks.

Fixes:
1. Skip report-only disposition (e.disposition !== 'enforce')
2. Skip first-party origins (*.worldmonitor.app) — dual-CSP quirk
   fires violations for requests the other policy allows
3. Host-based Sentry filter (sentry.io anywhere, not just /api/ path)
   to handle origin-only blocked URIs (e.g., sentry.io:443)
4. Sync script-src hashes between vercel.json and index.html meta tag
   (removed 4 stale hashes, added 1 missing OAuth hash)
5. Add Sentry ingest to connect-src (defense-in-depth, not primary fix)
6. Add test: CSP script-src hash parity between header and meta tag

The explicit Sentry ingest additions to connect-src are redundant with
the existing https: scheme-source but serve as documentation and
defense-in-depth.
2026-04-01 22:20:19 +04:00
Elie Habib
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>
2026-03-28 14:53:32 +04:00
Elie Habib
1635ad3898 feat(mcp): expose /mcp as clean public endpoint (#2411)
Adds a /mcp rewrite in vercel.json pointing to /api/mcp, so MCP clients
(Claude Desktop, etc.) can connect to https://api.worldmonitor.app/mcp
instead of /api/mcp.

Benefits: cleaner URL for docs/config, CF bot-blocking rules target /api/*
only so /mcp bypasses them without needing extra CF rule exceptions.

Also adds CORS headers for /mcp and excludes it from the SPA rewrite and
no-cache catch-all patterns. Updates deploy-config test to match new pattern.
2026-03-28 12:06:18 +04:00
Elie Habib
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
2026-03-27 14:27:55 +04:00
Elie Habib
a969a9e3a3 feat(auth): integrate clerk.dev (#1812)
* feat(auth): integrate better-auth with @better-auth/infra dash plugin

Wire up better-auth server config with the dash() plugin from
@better-auth/infra, and the matching sentinelClient() on the
client side. Adds BETTER_AUTH_API_KEY to .env.example.

* feat(auth): swap @better-auth/infra for @convex-dev/better-auth

[10-01 task 1] Install @convex-dev/better-auth@0.11.2, remove
@better-auth/infra, delete old server/auth.ts skeleton, rewrite
auth-client.ts to use crossDomainClient + convexClient plugins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): create Convex auth component files

[10-01 task 2] Add convex.config.ts (register betterAuth component),
auth.config.ts (JWT/JWKS provider), auth.ts (better-auth server with
Convex adapter, crossDomain + convex plugins), http.ts (mount auth
routes with CORS). Uses better-auth/minimal for lighter bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add admin, organization, and dash plugins

[10-01] Re-install @better-auth/infra for dash() plugin to enable
dash.better-auth.com admin dashboard. Add admin() and organization()
plugins from better-auth/plugins for user and org management.
Update both server (convex/auth.ts) and client (auth-client.ts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): drop @better-auth/infra (Node.js deps incompatible with Convex V8)

Keep admin() and organization() from better-auth/plugins (V8-safe).
@better-auth/infra's dash() transitively imports SAML/SSO with
node:crypto, fs, zlib — can't run in Convex's serverless runtime.
Dashboard features available via admin plugin endpoints instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(11-01): create auth-state.ts with OTT handler and session subscription

- Add initAuthState() for OAuth one-time token verification on page load
- Add subscribeAuthState() reactive wrapper around useSession nanostore atom
- Add getAuthState() synchronous snapshot getter
- Export AuthUser and AuthSession types for UI consumption

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(11-01): add Google OAuth provider and wire initAuthState into App.ts

- Add socialProviders.google with GOOGLE_CLIENT_ID/SECRET to convex/auth.ts
- Add all variant subdomains to trustedOrigins for cross-subdomain CORS
- Call initAuthState() in App.init() before panelLayout.init()
- Add authModal field to AppContext interface (prepares for Plan 02)
- Add authModal: null to App constructor state initialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(11-02): create AuthModal with Sign In/Sign Up tabs and Google OAuth

- Sign In tab: email/password form calling authClient.signIn.email()
- Sign Up tab: name/email/password form calling authClient.signUp.email()
- Google OAuth button calling authClient.signIn.social({ provider: 'google', callbackURL: '/' })
- Auto-close on successful auth via subscribeAuthState() subscription
- Escape key, overlay click, and X button close the modal
- Loading states, error display, and client-side validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(11-02): add AuthHeaderWidget, mount in header, add auth CSS

- AuthHeaderWidget: reactive header widget showing Sign In button (anonymous) or avatar + dropdown (authenticated)
- User dropdown: name, email, Free tier badge, Sign Out button calling authClient.signOut()
- setupAuthWidget() in EventHandlerManager creates modal + widget, mounts at authWidgetMount span
- authWidgetMount added to panel-layout.ts header-right, positioned before download wrapper
- setupAuthWidget() called from App.ts after setupUnifiedSettings()
- Full auth CSS: modal styles, tabs, forms, Google button, header widget, avatar, dropdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(11-02): add localhost:3000 to trustedOrigins for local dev CORS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): remove admin/organization plugins that break Convex adapter validator

The admin() plugin adds banned/role fields to user creation data, but the
@convex-dev/better-auth adapter validator doesn't include them. These plugins
are Phase 12 work — will re-add with additionalFields config when needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-01): add Resend email transport, verification + reset callbacks, role field

- Install resend SDK for transactional email
- Add emailVerification with sendOnSignUp:true and fire-and-forget Resend callbacks
- Add sendResetPassword callback with 1-hour token expiry
- Add user.additionalFields.role (free/pro, input:false, defaultValue:free)
- Create userRoles fallback table in schema with by_userId index
- Create getUserRole query and setUserRole mutation in convex/userRoles.ts
- Lazy-init Resend client to avoid Convex module analysis error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-01): enhance auth-state with emailVerified and role fields

- Add emailVerified (boolean) and role ('free' | 'pro') to AuthUser interface
- Fetch role from Convex userRoles table via HTTP query after session hydration
- Cache role per userId to avoid redundant fetches
- Re-notify subscribers asynchronously when role is fetched for a new user
- Map emailVerified from core better-auth user field (default false)
- Derive Convex cloud URL from VITE_CONVEX_SITE_URL env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(12-01): add Convex generated files from deployment

- Track convex/_generated/ files produced by npx convex dev --once

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-03): create panel-gating service with auth-aware showGatedCta

- Add PanelGateReason enum (NONE/ANONYMOUS/UNVERIFIED/FREE_TIER)
- Add getPanelGateReason() computing gating from AuthSession + premium flag
- Add Panel.showGatedCta() rendering auth-aware CTA overlays
- Add Panel.unlockPanel() to reverse locked state
- Extract lockSvg to module-level const shared by showLocked/showGatedCta
- Add i18n keys: signInToUnlock, signIn, verifyEmailToUnlock, resendVerification, upgradeDesc, upgradeToPro

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-02): add forgot password flow, password reset form, and token detection

- Widen authModal interface in app-context.ts to support reset-password mode and setResetToken
- AuthModal refactored with 4 views: signin, signup, forgot-password, reset-password
- Forgot password view sends reset email via authClient.requestPasswordReset
- Reset password form validates matching passwords and calls authClient.resetPassword
- auth-state.ts detects ?token= param from email links, stores as pendingResetToken
- App.ts routes pending reset token to auth modal after UI initialization
- CSS for forgot-link, back-link, and success message elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-02): add email verification banner to AuthHeaderWidget and tier badge

- Show non-blocking verification banner below header for unverified users
- Banner has "Resend" button calling authClient.sendVerificationEmail
- Banner is dismissible (stored in sessionStorage, reappears next session)
- Tier badge dynamically shows Free/Pro based on user.role
- Pro badge has gradient styling distinct from Free badge
- Dropdown shows unverified status indicator with yellow dot
- Banner uses fixed positioning, does not push content down
- CSS for banner, pro badge, and verification status indicators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(12-03): wire reactive auth-based gating into panel-layout

- Add WEB_PREMIUM_PANELS Set (stock-analysis, stock-backtest, daily-market-brief)
- Subscribe to auth state changes in PanelLayoutManager.init()
- Add updatePanelGating() iterating panels with getPanelGateReason()
- Add getGateAction() returning CTA callbacks per gate reason
- Remove inline showLocked() calls for web premium panels
- Preserve desktop _lockPanels for forecast, oref-sirens, telegram-intel
- Clean up auth subscription in destroy()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(13-01): create auth-token utility and inject Bearer header in web fetch redirect

- Add src/services/auth-token.ts with getSessionBearerToken() that reads session token from localStorage
- Add WEB_PREMIUM_API_PATHS Set for the 4 premium market API paths
- Inject Authorization: Bearer header in installWebApiRedirect() for premium paths when session exists
- Desktop installRuntimeFetchPatch() left unchanged (API key only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(13-01): create server-side session validation module

- Add server/auth-session.ts with validateBearerToken() for Vercel edge gateway
- Validates tokens via Convex /api/auth/get-session with Better-Auth-Cookie header
- Falls back to userRoles:getUserRole Convex query for role resolution
- In-memory cache with 60s TTL and 100-entry cap
- Network errors not cached to allow retry on next request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(13-02): add bearer token fallback auth for premium API endpoints

- Dynamic import of auth-session.ts when premium endpoint + API key fails
- Valid pro session tokens fall through to route handler
- Non-pro authenticated users get 403 'Pro subscription required'
- Invalid/expired tokens get 401 'Invalid or expired session'
- Non-premium endpoints and static API key flow unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): sign-in button invisible in dark theme — white on white

--accent is #fff in dark theme, so background: var(--accent) + color: #fff
was invisible. Changed to transparent background with var(--text) color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): add premium panel keys to full and finance variant configs

stock-analysis, stock-backtest, and daily-market-brief were defined in
the shared panels.ts but missing from variant DEFAULT_PANELS, causing
shouldCreatePanel() to return false and panel gating CTAs to never render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(auth): add Playwright smoke tests for auth UI (phases 12-13)

6 tests covering: Sign In button visibility, auth modal opening,
modal views (Sign In/Sign Up/Forgot Password), premium panel gating
for anonymous users, and auth token absence when logged out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): remove role additionalField that breaks Convex component validator

The betterAuth Convex component has a strict input validator for the
user model that doesn't include custom fields. The role additionalField
caused ArgumentValidationError on sign-up. Roles are already stored in
the separate userRoles table — no data loss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): use Authorization Bearer header for Convex session validation

Better-Auth-Cookie header returned null — the crossDomain plugin's
get-session endpoint expects Authorization: Bearer format instead.
Confirmed via curl against live Convex deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): use verified worldmonitor.app domain for auth emails

Was using noreply@resend.dev (testing domain) which can't send to
external recipients. Switched to noreply@worldmonitor.app matching
existing waitlist/contact emails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): await Resend email sends — Convex kills dangling promises

void (fire-and-forget) causes Convex to terminate the fetch before
Resend receives it. Await ensures emails actually get sent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update Convex generated auth files after config changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): guard against undefined VITE_CONVEX_SITE_URL in auth-state

The Convex cloud URL derivation crashed the entire app when
VITE_CONVEX_SITE_URL wasn't set in the build environment (Vercel
preview). Now gracefully defaults to empty string and skips role
fetching when the URL is unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add dash + organization plugins, remove Google OAuth, fix dark mode button

- Add @better-auth/infra dash plugin for hosted admin dashboard
- Add organization plugin for org management in dashboard
- Add dash.better-auth.com to trustedOrigins
- Remove Google OAuth (socialProviders, button, divider, CSS)
- Fix auth submit button invisible in dark mode (var(--accent) → #3b82f6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): replace dash plugin with admin — @better-auth/infra incompatible with Convex V8

@better-auth/infra imports SSO/SAML libraries requiring Node.js built-ins
(crypto, fs, stream) which Convex's V8 runtime doesn't support.
Replaced with admin plugin from better-auth/plugins which provides
user management endpoints (set-role, list-users, ban, etc.) natively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove stale Convex generated files after plugin update

Convex dev regenerated _generated/ — the per-module JS files
(auth.js, http.js, schema.js, etc.) are no longer emitted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(auth): remove organization plugin — will add in subsequent PR

Organization support (team accounts, invitations, member management)
is not wired into any frontend flow yet. Removing to keep the auth
PR focused on email/password + admin endpoints. Will add back when
building the org/team feature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add authentication & panel gating guide

Documents the auth stack, panel gating configuration, server-side
session enforcement, environment variables, and user roles.
Includes step-by-step guide for adding new premium panels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): stub panel-gating in RuntimeConfigPanel test harness

Panel.ts now imports @/services/panel-gating, which wasn't stubbed —
causing the real runtime.ts (with window.location) to be bundled,
breaking Node.js tests with "ReferenceError: location is not defined".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): allow Vercel preview origins in Convex trustedOrigins

* fix(auth): broaden Convex trustedOrigins to cover *.worldmonitor.app previews

* fix(auth): use hostonly wildcard pattern for *.worldmonitor.app in trustedOrigins

* fix(auth): add Convex site origins to trustedOrigins

* fix(ci): add convex/ to vercel-ignore watched paths

* fix(auth): remove admin() plugin — adds banned/role fields rejected by Convex validator

* fix(auth): remove admin() plugin — injects banned/role fields rejected by Convex betterAuth validator

* feat(auth): replace email/password with email OTP passwordless flow

- Replace emailAndPassword + emailVerification with emailOTP plugin
- Rewrite AuthModal: email entry -> OTP code verification (no passwords)
- Remove admin() plugin (caused Convex schema validation errors)
- Remove email verification banner and UNVERIFIED gate reason (OTP
  inherently verifies email)
- Remove password reset flow (forgot/reset password views, token handling)
- Clean up unused CSS (tabs, verification banner, success messages)
- Update docs to reflect new passwordless auth stack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(quick-2): harden Convex userRoles and add role cache TTL

- P0: Convert setUserRole from mutation to internalMutation (not callable from client)
- P2: Add 5-minute TTL to role cache in auth-state.ts
- P2: Add localStorage shape warning on auth-token.ts
- P3: Document getUserRole public query trade-off
- P3: Fix misleading cache comment in auth-session.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(quick-2): auth widget teardown, E2E test rewrite, gateway comment

- P2: Store authHeaderWidget on AppContext, destroy in EventHandlerManager.destroy()
- P2: Also destroy authModal in destroy() to prevent leaked subscriptions
- P1: Rewrite E2E tests for 2-view OTP modal (email input + submit button)
- P1: Remove stale "Sign Up" and "Forgot Password" test assertions
- P2: Replace flaky waitForTimeout(5000) with Playwright auto-retry assertion
- P3: Add clarifying comment on premium bearer-token fallback in gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(header): restructure header/footer, add profile editing, pro-gate playback/export

- Remove version, @eliehabib, GitHub link, and download button from header
- Move version + @eliehabib credit to footer brand line; download link to footer nav
- Move auth widget (profile avatar) to far right of header (after settings gear)
- Add default generic SVG avatar for users with no image and no name
- Add profile editing in auth dropdown: display name + avatar URL with Save/Cancel
- Add Settings shortcut in auth dropdown (opens UnifiedSettings)
- Gate Historical Playback and Export controls behind pro role (hidden for free users)
- Reactive pro-gate: subscribes to auth state changes, stores unsub in proGateUnsubscribers[]
- Clean up proGateUnsubscribers on EventHandlerManager.destroy() to prevent leaks
- Fix: render Settings button unconditionally (hidden via style), stable DOM structure
- Fix: typed updateUser call with runtime existence check instead of (any) cast
- Make initFooterDownload() private to match class conventions

* feat(analytics): add Umami auth integration and event tracking

- Wire analytics.ts facade to Umami (port from main #1914):
  search, country, map layers, panels, LLM, theme, language,
  variant switch, webcam, download, findings, deeplinks
- Add Window.umami shim to vite-env.d.ts
- Add initAuthAnalytics() that subscribes to auth state and calls
  identifyUser(id, role) / clearIdentity() on sign-in/sign-out
- Add trackSignIn, trackSignUp, trackSignOut, trackGateHit exports
- Call initAuthAnalytics() from App.ts after initAuthState()
- Track sign-in/sign-up (via isNewUser flag) in AuthModal OTP verify
- Track sign-out in AuthHeaderWidget before authClient.signOut()
- Track gate-hit for export, playback (event-handlers) and pro-banner

* feat(auth): professional avatar widget with colored initials and clean profile edit

- Replace white-circle avatar with deterministic colored initials (Gmail/Linear style)
- Avatar color derived from email hash across 8-color palette
- Dropdown redesigned: row layout with large avatar + name/email/tier info
- Profile edit form: name-only (removed avatar URL field)
- Remove Settings button from dropdown (gear icon in header is sufficient)
- Discord community widget: single CTA link, no redundant text label
- Add all missing CSS for dropdown interior, profile edit form, menu items

* fix(auth): lock down billing tier visibility and fix TOCTOU race

P1: getUserRole converted to internalQuery — billing tier no longer
accessible via any public Convex client API. Exposed only through
the new authenticated /api/user-role HTTP action which validates
the session Bearer token before returning the role.

P1: subscribeAuthState generation counter + AbortController prevents
rapid sign-in/sign-out from delivering stale role for wrong user.

P2: typed RawSessionUser/RawSessionValue interfaces replace any casts
at the better-auth nanostore boundary. fetchUserRole drops userId
param — server derives identity from Bearer token only.

P2: isNewUser heuristic removed from OTP verify — better-auth emailOTP
has no reliable isNewUser signal. All verifications tracked as
trackSignIn. OTP resend gets 30s client-side cooldown.

P2: auth-token.ts version pin comment added (better-auth@1.5.5 +
@convex-dev/better-auth@0.11.2). Gateway inner PREMIUM_RPC_PATHS
comment clarified to explain why it is not redundant.

Adds tests/auth-session.test.mts: 11 tests covering role fallback
endpoint selection, fail-closed behavior, and CORS origin matching.

* feat(quick-4): replace better-auth with Clerk JS -- packages, Convex config, browser auth layer

- Remove better-auth, @convex-dev/better-auth, @better-auth/infra, resend from dependencies
- Add @clerk/clerk-js and jose to dependencies
- Rewrite convex/auth.config.ts for Clerk issuer domain
- Simplify convex/convex.config.ts (remove betterAuth component)
- Delete convex/auth.ts, convex/http.ts, convex/userRoles.ts
- Remove userRoles table from convex/schema.ts
- Create src/services/clerk.ts with Clerk JS init, sign-in, sign-out, token, user metadata, UserButton
- Rewrite src/services/auth-state.ts backed by Clerk (same AuthUser/AuthSession interface)
- Delete src/services/auth-client.ts (better-auth client)
- Delete src/services/auth-token.ts (localStorage token scraping)
- Update .env.example with Clerk env vars, remove BETTER_AUTH_API_KEY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(quick-4): UI components, runtime fetch, server-side JWT, CSP, and tests

- Delete AuthModal.ts, create AuthLauncher.ts (thin Clerk.openSignIn wrapper)
- Rewrite AuthHeaderWidget.ts to use Clerk UserButton + openSignIn
- Update event-handlers.ts to use AuthLauncher instead of AuthModal
- Rewrite runtime.ts enrichInitForPremium to use async getClerkToken()
- Rewrite server/auth-session.ts for jose-based JWT verification with cached JWKS
- Update vercel.json CSP: add *.clerk.accounts.dev to script-src and frame-src
- Add Clerk CSP tests to deploy-config.test.mjs
- Rewrite e2e/auth-ui.spec.ts for Clerk UI
- Rewrite auth-session.test.mts for jose-based validation
- Use dynamic import for @clerk/clerk-js to avoid Node.js test breakage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): allow Clerk Pro users to load premium data on web

The data-loader gated premium panel loading (stock-analysis, stock-backtest,
daily-market-brief) on WORLDMONITOR_API_KEY only, which is desktop-only.
Web users with Clerk Pro auth were seeing unlocked panels stuck on "Loading..."
because the requests were never made.

Added hasPremiumAccess() helper that checks for EITHER desktop API key OR
Clerk Pro role, matching the migration plan Phase 7 requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR #1812 review — all 4 merge blockers + 3 gaps

Blockers:
1. Remove stale Convex artifacts (http.js, userRoles.js, betterAuth
   component) from convex/_generated/api.d.ts
2. isProUser() now checks getAuthState().user?.role === 'pro' alongside
   legacy localStorage keys
3. Finance premium refresh scheduling now fires for Clerk Pro web users
   (not just API key holders)
4. JWT verification now validates audience: 'convex' to reject tokens
   scoped to other Clerk templates

Gaps:
5. auth-session tests: 10 new cases (valid pro/free, expired, wrong
   key/audience/issuer, missing sub/plan, JWKS reuse) using self-signed
   keys + local JWKS server
6. premium-stock-gateway tests: 4 new bearer token cases (pro→200,
   free→403, invalid→401, public unaffected)
7. docs/authentication.mdx rewritten for Clerk (removed all better-auth
   references, updated stack/files/env vars/roles sections)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address P1 reactive Pro UI + P2 daily-market-brief + P3 stale env vars

P1 — In-session Pro UI changes no longer require a full reload:
- setupExportPanel: removed early isProUser() return, always creates
  and relies on reactive subscribeAuthState show/hide
- setupPlaybackControl: same pattern — always creates, reactive gate
- Custom widget panels: always loaded regardless of Pro status
- Pro add-panel and MCP add-panel blocks: always rendered, shown/hidden
  reactively via subscribeAuthState callback
- Flight search wiring: always wired, checks Pro status inside callback
  so mid-session sign-ins work immediately

P2 — daily-market-brief added to hasPremiumAccess() block in loadAllData()
so Clerk Pro web users get initial data load (was only primed in
primeVisiblePanelData, missing from the general reload path)

P3 — Removed stale CONVEX_SITE_URL and VITE_CONVEX_SITE_URL from
docs/authentication.mdx env vars table (neither is referenced in codebase)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add isProUser import, populate PREMIUM_RPC_PATHS, and fix bearer token auth flow

- Added missing isProUser import in App.ts (fixes typecheck)
- Populated PREMIUM_RPC_PATHS with stock analysis endpoints
- Restructured gateway auth: trusted browser origins bypass API key for
  premium endpoints (client-side isProUser gate), while bearer token
  validation runs as a separate step for premium paths when present

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gateway): require credentials for premium paths + defer free-tier enforcement until auth ready

P0: Removed trusted-origin bypass for premium endpoints — Origin header
is spoofable and cannot be a security boundary. Premium paths now always
require either an API key or valid bearer token.

P1: Deferred panel/source free-tier enforcement until auth state resolves.
Previously ran in the constructor before initAuthState(), causing Clerk Pro
users to have their panels/sources trimmed on every startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): apply WorldMonitor design system to Clerk modal

Theme-aware appearance config passed to clerk.load(), openSignIn(),
and mountUserButton(). Dark mode: dark bg (#111), green primary
(#44ff88), monospace font. Light mode: white bg, green-600 primary
(#16a34a). Reads document.documentElement.dataset.theme at call time
so theme switches are respected.

* fix(auth): gate Clerk init and auth widget behind BETA_MODE

Clerk auth initialization and the Sign In header widget are now only
activated when localStorage `worldmonitor-beta-mode` is set to "true",
allowing silent deployment for internal testing before public rollout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): gate Clerk init and auth widget behind isProUser()

Clerk auth initialization and the Sign In header widget are now only
activated when the user has wm-widget-key or wm-pro-key in localStorage
(i.e. isProUser() returns true), allowing silent deployment for internal
testing before public rollout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(data-loader): replace stale isProUser() with hasPremiumAccess()

loadMarketImplications() still referenced the removed isProUser import,
causing a TS2304 build error. Align with the rest of data-loader.ts
which uses hasPremiumAccess() (checks both API key and Clerk auth).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): address PR #1812 review — P1 security fixes + P2 improvements

P1 fixes:
- Add algorithms: ['RS256'] allowlist to jwtVerify (prevents alg:none bypass)
- Reset loadPromise on Clerk init failure (allows retry instead of permanent breakage)

P2 fixes:
- Extract PREMIUM_RPC_PATHS to shared module (eliminates server/client divergence risk)
- Add fail-fast guard in convex/auth.config.ts for missing CLERK_JWT_ISSUER_DOMAIN
- Add 50s token cache with in-flight dedup to getClerkToken() (prevents concurrent races)
- Sync Clerk CSP entries to index.html and tauri.conf.json (previously only in vercel.json)
- Type clerkInstance as Clerk instead of any

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): clear cached token on signOut()

Prevents stale token from being returned during the ≤50s cache window
after a user signs out.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Sebastien Melki <sebastien@anghami.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sebastien Melki <sebastienmelki@gmail.com>
2026-03-26 13:47:22 +02:00
Elie Habib
a4e9e5e607 fix(docs): exclude /docs from CSP that blocks Mintlify (#1750)
* fix(docs): exclude /docs from CSP header that blocks Mintlify scripts

The catch-all /(.*) header rule applied Content-Security-Policy with
SHA-based script-src to all routes including /docs/*. Mintlify generates
dozens of inline scripts that don't match those hashes, causing 71 CSP
errors and a completely blank docs page.

Fix: change catch-all to /((?!docs).*) so /docs paths inherit only
their own lightweight headers (nosniff, HSTS, referrer-policy).

* fix(tests): update deploy-config test for docs CSP exclusion

Test was looking for exact source '/(.*)', updated to match the new
'/((?!docs).*)' pattern that excludes /docs from the strict CSP.
2026-03-17 11:26:25 +04:00
Elie Habib
d101c03009 fix: unblock geolocation and fix stale CSP hash (#1709)
* fix: unblock geolocation and fix stale CSP hash for SW nuke script

Permissions-Policy had geolocation=() which blocked navigator.geolocation
used by user-location.ts. Changed to geolocation=(self).

CSP script-src had a stale SHA-256 hash (903UI9my...) that didn't match the
current SW nuke script content. The script was silently blocked in production,
preventing recovery from stale service workers after deploys. Replaced with
the correct hash (4Z2xtr1B...) in both vercel.json and index.html meta tag.

* test: update permissions-policy test for geolocation=(self)

Move geolocation from "disabled" list to "delegated" assertions since
it now allows self-origin access for user-location.ts.
2026-03-16 08:37:40 +04:00
Elie Habib
bcccb3fb9c test: cover runtime env guardrails (#1650)
* fix(data): restore bootstrap and cache test coverage

* test: cover runtime env guardrails

* fix(test): align security header tests with current vercel.json

Update catch-all source pattern, geolocation policy value, and
picture-in-picture origins to match current production config.
2026-03-15 16:54:42 +04:00
Elie Habib
39cf56dd4d perf: reduce ~14M uncached API calls/day via client caches + workbox fix + USNI Railway migration (#1605)
* perf: reduce uncached API calls via client-side circuit breaker caches

Add client-side circuit breaker caches with IndexedDB persistence to the
top 3 uncached API endpoints (CF analytics: 10.5M uncached requests/day):

- classify-events (5.37M/day): 6hr cache per normalized title, shouldCache
  guards against caching null/transient failures
- get-population-exposure (3.45M/day): 6hr cache per coordinate key
  (toFixed(4) for ~11m precision), 64-entry LRU
- summarize-article (1.68M/day): 2hr cache per headline-set hash via
  buildSummaryCacheKey, eliminates both cache-check and summarize RPCs

Fix workbox-*.js getting no-cache headers (3.62M/day): exclude from SPA
catch-all regex in vercel.json, add explicit immutable cache rule for
content-hashed workbox files.

Migrate USNI fleet fetch from Vercel edge to Railway relay (gold standard):
- Add seedUSNIFleet() loop to ais-relay.cjs (6hr interval, gzip support)
- Make server handler Redis-read-only (435 lines reduced to 38)
- Move usniFleet from ON_DEMAND to BOOTSTRAP_KEYS in health.js
- Add persistCache + shouldCache to client breaker

Estimated reduction: ~14.3M uncached requests/day.

* fix: address code review findings (P1 + P2)

P1: Include SummarizeOptions in summary cache key to prevent cross-option
cache pollution (e.g. cloud summary replayed after user disables cloud LLMs).

P2: Document that forceRefresh is intentionally ignored now that USNI
fetching moved to Railway relay (Vercel is Redis-read-only).

* fix: reject forceRefresh explicitly instead of silently ignoring it

Return an error response with explanation when forceRefresh=true is sent,
rather than silently returning cached data. Makes the behavior regression
visible to any caller instead of masking it.

* fix(build): set worker.format to 'es' for Vite 6 compatibility

Vite 6 defaults worker.format to 'iife' which fails with code-splitting
workers (analysis.worker.ts uses dynamic imports). Setting 'es' fixes
the Vercel production build.

* fix(test): update deploy-config test for workbox regex exclusion

The SPA catch-all regex test hard-coded the old pattern without the
workbox exclusion. Update to match the new vercel.json source pattern.
2026-03-15 00:52:10 +04:00
Elie Habib
fe67111dc9 feat: harness engineering P0 - linting, testing, architecture docs (#1587)
* feat: harness engineering P0 - linting, testing, architecture docs

Add foundational infrastructure for agent-first development:

- AGENTS.md: agent entry point with progressive disclosure to deeper docs
- ARCHITECTURE.md: 12-section system reference with source-file refs and ownership rule
- Biome 2.4.7 linter with project-tuned rules, CI workflow (lint-code.yml)
- Architectural boundary lint enforcing forward-only dependency direction (lint-boundaries.mjs)
- Unit test CI workflow (test.yml), all 1083 tests passing
- Fixed 9 pre-existing test failures (bootstrap sync, deploy-config headers, globe parity, redis mocks, geometry URL, import.meta.env null safety)
- Fixed 12 architectural boundary violations (types moved to proper layers)
- Added 3 missing cache tier entries in gateway.ts
- Synced cache-keys.ts with bootstrap.js
- Renamed docs/architecture.mdx to "Design Philosophy" with cross-references
- Deprecated legacy docs/Docs_To_Review/ARCHITECTURE.md
- Harness engineering roadmap tracking doc

* fix: address PR review feedback on harness-engineering-p0

- countries-geojson.test.mjs: skip gracefully when CDN unreachable
  instead of failing CI on network issues
- country-geometry-overrides.test.mts: relax timing assertion
  (250ms -> 2000ms) for constrained CI environments
- lint-boundaries.mjs: implement the documented api/ boundary check
  (was documented but missing, causing false green)

* fix(lint): scan api/ .ts files in boundary check

The api/ boundary check only scanned .js/.mjs files, missing the 25
sebuf RPC .ts edge functions. Now scans .ts files with correct rules:
- Legacy .js: fully self-contained (no server/ or src/ imports)
- RPC .ts: may import server/ and src/generated/ (bundled at deploy),
  but blocks imports from src/ application code

* fix(lint): detect import() type expressions in boundary lint

- Move AppContext back to app/app-context.ts (aggregate type that
  references components/services/utils belongs at the top, not types/)
- Move HappyContentCategory and TechHQ to types/ (simple enums/interfaces)
- Boundary lint now catches import('@/layer') expressions, not just
  from '@/layer' imports
- correlation-engine imports of AppContext marked boundary-ignore
  (type-only imports of top-level aggregate)
2026-03-14 21:29:21 +04:00
Elie Habib
31793ede03 harden: replace CSP unsafe-inline with script hashes and add trust signals (#781)
Remove 'unsafe-inline' from script-src in both index.html and vercel.json,
replacing it with SHA-256 hashes of the two static inline theme-detection
scripts. Add security.txt, sitemap.xml, and Sitemap directive in robots.txt
to improve scanner reputation. Fix stale variant-metadata test that was
reading vite.config.ts instead of the extracted variant-meta.ts module.
2026-03-02 19:07:37 +04:00
Elie Habib
e10c088229 harden: expand Permissions-Policy and tighten CSP connect-src (#779)
Expand Permissions-Policy from 3 to 19 directives (16 fully disabled,
3 delegated to YouTube origins for embed compatibility). Remove
unencrypted ws: and dev-only http://localhost:5173 from production CSP
connect-src. Add 5 guardrail tests to prevent regressions.
2026-03-02 18:36:01 +04:00
Lawyered
f2a1a2ccb5 fix(pwa): disable default navigateFallback in generated SW 2026-02-19 20:14:11 -05:00
Elie Habib
54f1a5d578 test: add coverage for finance/trending/reload and stabilize map harness 2026-02-17 19:22:55 +04:00