mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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>
This commit is contained in:
14
.env.example
14
.env.example
@@ -262,3 +262,17 @@ WORLDMONITOR_VALID_KEYS=
|
||||
# Convex deployment URL for email registration storage.
|
||||
# Set up at: https://dashboard.convex.dev/
|
||||
CONVEX_URL=
|
||||
|
||||
# ------ Auth (Clerk) ------
|
||||
|
||||
# Clerk publishable key (browser-side, safe to expose)
|
||||
# Get from: Clerk Dashboard -> API Keys
|
||||
VITE_CLERK_PUBLISHABLE_KEY=
|
||||
|
||||
# Clerk secret key (server-side only, never expose to browser)
|
||||
# Get from: Clerk Dashboard -> API Keys
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
# Clerk JWT issuer domain (for Convex auth config)
|
||||
# Format: https://your-clerk-app.clerk.accounts.dev
|
||||
CLERK_JWT_ISSUER_DOMAIN=
|
||||
|
||||
99
DEPLOYMENT-PLAN.md
Normal file
99
DEPLOYMENT-PLAN.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Deployment Plan — Clerk Auth + Dodo Payments
|
||||
|
||||
## Merge Order
|
||||
|
||||
**PR #1812 first, then PR #2024.** Dodo billing functions depend on Clerk auth being registered in Convex.
|
||||
|
||||
1. Merge `feat/better-auth` → `main` (PR #1812)
|
||||
2. Rebase `dodo_payments` on updated `main`, resolve conflicts
|
||||
3. Merge `dodo_payments` → `main` (PR #2024)
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Clerk Auth (PR #1812)
|
||||
|
||||
All values from **Clerk Dashboard → API Keys** ([dashboard.clerk.com](https://dashboard.clerk.com))
|
||||
|
||||
| Variable | Set in | Value |
|
||||
|----------|--------|-------|
|
||||
| `VITE_CLERK_PUBLISHABLE_KEY` | **Vercel** | Clerk Dashboard → API Keys → Publishable Key (`pk_live_...`) |
|
||||
| `CLERK_SECRET_KEY` | **Vercel** (secret) | Clerk Dashboard → API Keys → Secret Key (`sk_live_...`) |
|
||||
| `CLERK_JWT_ISSUER_DOMAIN` | **Vercel** | Your Clerk app domain, e.g. `https://worldmonitor.clerk.accounts.dev` |
|
||||
|
||||
#### Clerk Dashboard Setup
|
||||
|
||||
1. **JWT Template**: Create a template named **`convex`** with custom claim:
|
||||
```json
|
||||
{ "plan": "{{user.public_metadata.plan}}" }
|
||||
```
|
||||
2. **Pro users**: Set `public_metadata.plan` to `"pro"` on test users to verify premium access
|
||||
3. **Sign-in methods**: Configure email OTP (or whichever methods you want) under User & Authentication
|
||||
|
||||
---
|
||||
|
||||
### Dodo Payments (PR #2024)
|
||||
|
||||
API key + webhook secret from **Dodo Dashboard** ([app.dodopayments.com](https://app.dodopayments.com))
|
||||
|
||||
| Variable | Set in | Value |
|
||||
|----------|--------|-------|
|
||||
| `DODO_API_KEY` | **Convex Dashboard** | Dodo → Settings → API Keys |
|
||||
| `DODO_PAYMENTS_ENVIRONMENT` | **Convex Dashboard** | `test_mode` or `live_mode` |
|
||||
| `DODO_PAYMENTS_WEBHOOK_SECRET` | **Convex Dashboard** | Dodo → Developers → Webhooks → signing secret |
|
||||
| `DODO_WEBHOOK_SECRET` | **Convex Dashboard** | Same value as above |
|
||||
| `VITE_DODO_ENVIRONMENT` | **Vercel** | `test_mode` or `live_mode` (must match server-side) |
|
||||
| `VITE_CONVEX_URL` | **Vercel** | Convex Dashboard → Settings → Deployment URL (`https://xxx.convex.cloud`) |
|
||||
|
||||
#### Dodo Dashboard Setup
|
||||
|
||||
1. **Webhook endpoint**: Create a webhook pointing to `https://<convex-deployment>.convex.site/dodo/webhook`
|
||||
2. **Events to subscribe**: `subscription.active`, `subscription.renewed`, `subscription.on_hold`, `subscription.cancelled`, `subscription.expired`, `subscription.plan_changed`, `payment.succeeded`, `payment.failed`, `refund.succeeded`, `refund.failed`, `dispute.*`
|
||||
3. **Products**: Ensure product IDs match the seed data in `convex/payments/seedProductPlans.ts` — run `seedProductPlans` mutation after deploy
|
||||
|
||||
---
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### Step 1 — Merge PR #1812 (Clerk Auth)
|
||||
|
||||
```
|
||||
1. Set Clerk env vars on Vercel (all 3)
|
||||
2. Create Clerk JWT template named "convex"
|
||||
3. Merge feat/better-auth → main
|
||||
4. Deploy to Vercel
|
||||
5. Verify: Sign in works, Pro user sees premium panels, bearer tokens appear on premium API routes
|
||||
```
|
||||
|
||||
### Step 2 — Merge PR #2024 (Dodo Payments)
|
||||
|
||||
```
|
||||
1. Set Dodo env vars on Convex Dashboard (4 vars)
|
||||
2. Set Dodo + Convex env vars on Vercel (2 vars)
|
||||
3. Rebase dodo_payments on main, resolve conflicts
|
||||
4. Merge dodo_payments → main
|
||||
5. Deploy to Vercel + Convex
|
||||
6. Run seedProductPlans mutation in Convex Dashboard
|
||||
7. Create webhook endpoint in Dodo Dashboard
|
||||
8. Verify: Checkout flow → webhook → entitlements granted → panels unlock
|
||||
```
|
||||
|
||||
### Post-Deploy Verification
|
||||
|
||||
- [ ] Anonymous user sees locked premium panels
|
||||
- [ ] Clerk sign-in works (email OTP or configured method)
|
||||
- [ ] Pro user (`public_metadata.plan: "pro"`) sees unlocked panels + data loads
|
||||
- [ ] Dodo test checkout (`4242 4242 4242 4242`) creates subscription
|
||||
- [ ] Webhook fires → subscription + entitlements appear in Convex Dashboard
|
||||
- [ ] Billing portal opens from Settings
|
||||
- [ ] Desktop API key flow still works unchanged
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Where | Variables to set |
|
||||
|-------|-----------------|
|
||||
| **Vercel** | `VITE_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`, `CLERK_JWT_ISSUER_DOMAIN`, `VITE_DODO_ENVIRONMENT`, `VITE_CONVEX_URL` |
|
||||
| **Convex Dashboard** | `DODO_API_KEY`, `DODO_PAYMENTS_ENVIRONMENT`, `DODO_PAYMENTS_WEBHOOK_SECRET`, `DODO_WEBHOOK_SECRET` |
|
||||
@@ -1,117 +0,0 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
function hashCode(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
async function generateUniqueReferralCode(db, email) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const input = attempt === 0 ? email : `${email}:${attempt}`;
|
||||
const code = hashCode(input).toString(36).padStart(6, "0").slice(0, 8);
|
||||
const existing = await db
|
||||
.query("registrations")
|
||||
.withIndex("by_referral_code", (q) => q.eq("referralCode", code))
|
||||
.first();
|
||||
if (!existing)
|
||||
return code;
|
||||
}
|
||||
// Fallback: timestamp-based code (extremely unlikely path)
|
||||
return Date.now().toString(36).slice(-8);
|
||||
}
|
||||
async function getCounter(db, name) {
|
||||
const counter = await db
|
||||
.query("counters")
|
||||
.withIndex("by_name", (q) => q.eq("name", name))
|
||||
.first();
|
||||
return counter?.value ?? 0;
|
||||
}
|
||||
async function incrementCounter(db, name) {
|
||||
const counter = await db
|
||||
.query("counters")
|
||||
.withIndex("by_name", (q) => q.eq("name", name))
|
||||
.first();
|
||||
const newVal = (counter?.value ?? 0) + 1;
|
||||
if (counter) {
|
||||
await db.patch(counter._id, { value: newVal });
|
||||
}
|
||||
else {
|
||||
await db.insert("counters", { name, value: newVal });
|
||||
}
|
||||
return newVal;
|
||||
}
|
||||
export const register = mutation({
|
||||
args: {
|
||||
email: v.string(),
|
||||
source: v.optional(v.string()),
|
||||
appVersion: v.optional(v.string()),
|
||||
referredBy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedEmail = args.email.trim().toLowerCase();
|
||||
const existing = await ctx.db
|
||||
.query("registrations")
|
||||
.withIndex("by_normalized_email", (q) => q.eq("normalizedEmail", normalizedEmail))
|
||||
.first();
|
||||
if (existing) {
|
||||
let code = existing.referralCode;
|
||||
if (!code) {
|
||||
code = await generateUniqueReferralCode(ctx.db, normalizedEmail);
|
||||
await ctx.db.patch(existing._id, { referralCode: code });
|
||||
}
|
||||
return {
|
||||
status: "already_registered",
|
||||
referralCode: code,
|
||||
referralCount: existing.referralCount ?? 0,
|
||||
};
|
||||
}
|
||||
const referralCode = await generateUniqueReferralCode(ctx.db, normalizedEmail);
|
||||
// Credit the referrer
|
||||
if (args.referredBy) {
|
||||
const referrer = await ctx.db
|
||||
.query("registrations")
|
||||
.withIndex("by_referral_code", (q) => q.eq("referralCode", args.referredBy))
|
||||
.first();
|
||||
if (referrer) {
|
||||
await ctx.db.patch(referrer._id, {
|
||||
referralCount: (referrer.referralCount ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
const position = await incrementCounter(ctx.db, "registrations_total");
|
||||
await ctx.db.insert("registrations", {
|
||||
email: args.email.trim(),
|
||||
normalizedEmail,
|
||||
registeredAt: Date.now(),
|
||||
source: args.source ?? "unknown",
|
||||
appVersion: args.appVersion ?? "unknown",
|
||||
referralCode,
|
||||
referredBy: args.referredBy,
|
||||
referralCount: 0,
|
||||
});
|
||||
return {
|
||||
status: "registered",
|
||||
referralCode,
|
||||
referralCount: 0,
|
||||
position,
|
||||
};
|
||||
},
|
||||
});
|
||||
export const getPosition = query({
|
||||
args: { referralCode: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const reg = await ctx.db
|
||||
.query("registrations")
|
||||
.withIndex("by_referral_code", (q) => q.eq("referralCode", args.referralCode))
|
||||
.first();
|
||||
if (!reg)
|
||||
return null;
|
||||
const total = await getCounter(ctx.db, "registrations_total");
|
||||
return {
|
||||
referralCount: reg.referralCount ?? 0,
|
||||
total,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
export default defineSchema({
|
||||
registrations: defineTable({
|
||||
email: v.string(),
|
||||
normalizedEmail: v.string(),
|
||||
registeredAt: v.number(),
|
||||
source: v.optional(v.string()),
|
||||
appVersion: v.optional(v.string()),
|
||||
referralCode: v.optional(v.string()),
|
||||
referredBy: v.optional(v.string()),
|
||||
referralCount: v.optional(v.number()),
|
||||
})
|
||||
.index("by_normalized_email", ["normalizedEmail"])
|
||||
.index("by_referral_code", ["referralCode"]),
|
||||
counters: defineTable({
|
||||
name: v.string(),
|
||||
value: v.number(),
|
||||
}).index("by_name", ["name"]),
|
||||
});
|
||||
11
convex/auth.config.ts
Normal file
11
convex/auth.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const domain = process.env.CLERK_JWT_ISSUER_DOMAIN;
|
||||
if (!domain) throw new Error('CLERK_JWT_ISSUER_DOMAIN is not set');
|
||||
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain,
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
3
convex/convex.config.ts
Normal file
3
convex/convex.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineApp } from "convex/server";
|
||||
const app = defineApp();
|
||||
export default app;
|
||||
144
docs/authentication.mdx
Normal file
144
docs/authentication.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: "Authentication & Panel Gating"
|
||||
description: "How user authentication, premium panel gating, and server-side session enforcement work in WorldMonitor."
|
||||
---
|
||||
|
||||
WorldMonitor uses [Clerk](https://clerk.com) for authentication. The auth system gates premium panels behind sign-in and tier checks, and enforces session-based access on server-side API endpoints via local JWT verification.
|
||||
|
||||
---
|
||||
|
||||
## Auth Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| Auth provider | Clerk | Sign-in (email, social), session management, hosted UI |
|
||||
| JWT verification | jose + Clerk JWKS | Server-side bearer token validation (no round-trip) |
|
||||
| Convex integration | Clerk JWT template (`convex`) | Convex auth with `applicationID: "convex"` |
|
||||
| Auth state | `auth-state.ts` | Reactive browser auth state, role caching |
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `convex/auth.config.ts` | Convex auth provider config — Clerk JWT issuer + applicationID |
|
||||
| `src/services/clerk.ts` | Clerk instance init, `getClerkToken()` for Convex JWT template |
|
||||
| `src/services/auth-state.ts` | Reactive auth state, role fetching, session hydration |
|
||||
| `src/components/AuthHeaderWidget.ts` | Header sign-in button, Clerk UserButton |
|
||||
| `server/auth-session.ts` | Server-side JWT validation with jose + cached JWKS |
|
||||
|
||||
---
|
||||
|
||||
## Panel Gating
|
||||
|
||||
Premium panels show a CTA overlay instead of content until the user meets the access requirements.
|
||||
|
||||
### Gate Reasons
|
||||
|
||||
| Reason | What the user sees | Resolution |
|
||||
|--------|-------------------|------------|
|
||||
| `ANONYMOUS` | "Sign In to Unlock" | Sign in via Clerk |
|
||||
| `FREE_TIER` | "Upgrade to Pro" | Upgrade subscription |
|
||||
| `NONE` | Normal panel content | Already unlocked |
|
||||
|
||||
### How to Configure Which Panels Are Premium
|
||||
|
||||
Three files control gating. **All three must stay in sync** when adding or removing premium panels.
|
||||
|
||||
#### 1. Panel config — `src/config/panels.ts`
|
||||
|
||||
Add `premium: 'locked'` to the panel entry in the relevant variant:
|
||||
|
||||
```ts
|
||||
// In FULL_PANELS, FINANCE_PANELS, etc.
|
||||
'my-panel': { name: 'My Panel', enabled: true, premium: 'locked' }
|
||||
```
|
||||
|
||||
#### 2. Client-side gate set — `src/app/panel-layout.ts`
|
||||
|
||||
Add the panel key to `WEB_PREMIUM_PANELS`:
|
||||
|
||||
```ts
|
||||
const WEB_PREMIUM_PANELS = new Set([
|
||||
'stock-analysis',
|
||||
'stock-backtest',
|
||||
'daily-market-brief',
|
||||
'my-panel', // <-- add here
|
||||
]);
|
||||
```
|
||||
|
||||
This set drives the reactive UI gating — when auth state changes, panels in this set get checked and show/hide CTAs accordingly.
|
||||
|
||||
#### 3. Server-side API enforcement (if the panel calls premium APIs)
|
||||
|
||||
**Client token injection** — `src/services/runtime.ts` (`WEB_PREMIUM_API_PATHS`):
|
||||
|
||||
```ts
|
||||
const WEB_PREMIUM_API_PATHS = new Set([
|
||||
'/api/market/v1/analyze-stock',
|
||||
'/api/market/v1/get-stock-analysis-history',
|
||||
'/api/market/v1/backtest-stock',
|
||||
'/api/market/v1/list-stored-stock-backtests',
|
||||
'/api/my-domain/v1/my-endpoint', // <-- add here
|
||||
]);
|
||||
```
|
||||
|
||||
When a fetch request matches a path in this set and the user has a Clerk session, the client automatically injects `Authorization: Bearer <token>`.
|
||||
|
||||
**Server gateway** — `server/gateway.ts` (`PREMIUM_RPC_PATHS`):
|
||||
|
||||
```ts
|
||||
const PREMIUM_RPC_PATHS = new Set([
|
||||
'/api/market/v1/analyze-stock',
|
||||
'/api/market/v1/get-stock-analysis-history',
|
||||
'/api/market/v1/backtest-stock',
|
||||
'/api/market/v1/list-stored-stock-backtests',
|
||||
'/api/my-domain/v1/my-endpoint', // <-- add here
|
||||
]);
|
||||
```
|
||||
|
||||
The gateway validates the bearer token via local JWKS verification (jose) and checks `session.role === 'pro'`. Returns 403 if the user isn't pro.
|
||||
|
||||
### Currently Gated Panels
|
||||
|
||||
| Panel | Variants | Gate type |
|
||||
|-------|----------|-----------|
|
||||
| `stock-analysis` | full, finance | `locked` (web) |
|
||||
| `stock-backtest` | full, finance | `locked` (web) |
|
||||
| `daily-market-brief` | full, finance | `locked` (web) |
|
||||
|
||||
### Desktop Behavior
|
||||
|
||||
Desktop users with a valid `WORLDMONITOR_API_KEY` in the Tauri keychain bypass all panel gating. The existing API key flow is unaffected — bearer tokens are a **second auth path**, not a replacement.
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Session Enforcement
|
||||
|
||||
The Vercel API gateway accepts two forms of authentication for premium endpoints:
|
||||
|
||||
1. **Static API key** — `X-WorldMonitor-Key` header (existing flow, unchanged)
|
||||
2. **Bearer token** — `Authorization: Bearer <clerk_jwt>` (for web users)
|
||||
|
||||
The gateway tries the API key first. If that fails on a premium endpoint, it falls back to bearer token validation using local JWKS verification via `server/auth-session.ts`. The JWT is verified against:
|
||||
- **Issuer**: `CLERK_JWT_ISSUER_DOMAIN`
|
||||
- **Audience**: `convex` (matches the Clerk JWT template)
|
||||
- **Signature**: RSA256 via Clerk's published JWKS
|
||||
|
||||
Non-premium endpoints don't require any authentication from web origins.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Where | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `CLERK_JWT_ISSUER_DOMAIN` | Convex + Vercel | Clerk issuer domain for JWT verification |
|
||||
| `VITE_CLERK_PUBLISHABLE_KEY` | Vercel | Client-side Clerk publishable key |
|
||||
|
||||
---
|
||||
|
||||
## User Roles
|
||||
|
||||
User roles (`pro` / `free`) are stored as a `plan` claim in the Clerk JWT. The server extracts this from the verified token payload. Unknown or missing `plan` values default to `free` (fail closed — never pro).
|
||||
|
||||
On the client side, `getAuthState().user?.role` exposes the role. Both `isProUser()` and `hasPremiumAccess()` check this alongside legacy API key gates.
|
||||
@@ -113,6 +113,7 @@
|
||||
"group": "Developer Guide",
|
||||
"pages": [
|
||||
"contributing",
|
||||
"authentication",
|
||||
"adding-endpoints",
|
||||
"api-key-deployment",
|
||||
"release-packaging",
|
||||
|
||||
52
e2e/auth-ui.spec.ts
Normal file
52
e2e/auth-ui.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/** Click an element via JS to bypass overlay interception. */
|
||||
async function jsClick(page: Page, selector: string) {
|
||||
await page.evaluate((sel) => {
|
||||
(document.querySelector(sel) as HTMLElement)?.click();
|
||||
}, selector);
|
||||
}
|
||||
|
||||
test.describe('auth UI (anonymous state)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
// Dismiss the layer performance warning overlay
|
||||
localStorage.setItem('wm-layer-warning-dismissed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('Sign In button visible with readable text', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const signInBtn = page.locator('.auth-signin-btn');
|
||||
await signInBtn.waitFor({ timeout: 20000 });
|
||||
await expect(signInBtn).toBeVisible();
|
||||
await expect(signInBtn).toHaveText('Sign In');
|
||||
|
||||
const styles = await signInBtn.evaluate((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return { color: cs.color, background: cs.backgroundColor };
|
||||
});
|
||||
expect(styles.color).not.toBe(styles.background);
|
||||
});
|
||||
|
||||
test('Sign In click triggers Clerk modal or overlay', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('.auth-signin-btn').waitFor({ timeout: 20000 });
|
||||
await jsClick(page, '.auth-signin-btn');
|
||||
|
||||
// Clerk renders its modal into .cl-rootBox or an iframe.
|
||||
// When Clerk JS is not configured (no publishable key in test env),
|
||||
// the click simply invokes openSignIn() which is a no-op -- verify
|
||||
// no uncaught errors instead.
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.waitForTimeout(1000);
|
||||
expect(errors.filter((e) => e.includes('auth'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('premium panels gated for anonymous users', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.panel', { timeout: 20000 });
|
||||
await expect(page.locator('.panel-is-locked').first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data: https://abacus.worldmonitor.app; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-+SFBjfmi2XfnyAT3POBxf6JIKYDcNXtllPclOcaNBI0=' 'sha256-AhZAmdCW6h8iXMyBcvIrqN71FGNk4lwLD+lPxx43hxg=' 'sha256-PnEBZii+iFaNE2EyXaJhRq34g6bdjRJxpLfJALdXYt8=' 'sha256-cVhuR63Moy56DV5yG0caJCEyCugMTbYclkvkK6fSwXY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://abacus.worldmonitor.app; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://vercel.live https://*.vercel.app;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data: https://abacus.worldmonitor.app; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-+SFBjfmi2XfnyAT3POBxf6JIKYDcNXtllPclOcaNBI0=' 'sha256-AhZAmdCW6h8iXMyBcvIrqN71FGNk4lwLD+lPxx43hxg=' 'sha256-PnEBZii+iFaNE2EyXaJhRq34g6bdjRJxpLfJALdXYt8=' 'sha256-cVhuR63Moy56DV5yG0caJCEyCugMTbYclkvkK6fSwXY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://abacus.worldmonitor.app https://*.clerk.accounts.dev; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://vercel.live https://*.vercel.app https://*.clerk.accounts.dev;" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
|
||||
5135
package-lock.json
generated
5135
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.79.0",
|
||||
"@aws-sdk/client-s3": "^3.1009.0",
|
||||
"@clerk/clerk-js": "^5.56.0",
|
||||
"@deck.gl/aggregation-layers": "^9.2.6",
|
||||
"@deck.gl/core": "^9.2.6",
|
||||
"@deck.gl/geo-layers": "^9.2.6",
|
||||
@@ -109,6 +110,7 @@
|
||||
"fast-xml-parser": "^5.3.7",
|
||||
"globe.gl": "^2.45.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"jose": "^6.0.11",
|
||||
"i18next": "^25.8.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"maplibre-gl": "^5.16.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
if [ "$VERCEL_GIT_COMMIT_REF" = "main" ] && [ -n "$VERCEL_GIT_PREVIOUS_SHA" ]; then
|
||||
git cat-file -e "$VERCEL_GIT_PREVIOUS_SHA" 2>/dev/null && {
|
||||
WEB_CHANGES=$(git diff --name-only "$VERCEL_GIT_PREVIOUS_SHA" HEAD -- \
|
||||
'src/' 'api/' 'server/' 'shared/' 'public/' 'blog-site/' 'pro-test/' 'proto/' \
|
||||
'src/' 'api/' 'server/' 'shared/' 'public/' 'blog-site/' 'pro-test/' 'proto/' 'convex/' \
|
||||
'package.json' 'package-lock.json' 'vite.config.ts' 'tsconfig.json' \
|
||||
'tsconfig.api.json' 'vercel.json' 'middleware.ts' | head -1)
|
||||
[ -z "$WEB_CHANGES" ] && echo "Skipping: no web-relevant changes on main" && exit 0
|
||||
@@ -35,6 +35,7 @@ git diff --name-only "$COMPARE_SHA" HEAD -- \
|
||||
'blog-site/' \
|
||||
'pro-test/' \
|
||||
'proto/' \
|
||||
'convex/' \
|
||||
'package.json' \
|
||||
'package-lock.json' \
|
||||
'vite.config.ts' \
|
||||
|
||||
61
server/auth-session.ts
Normal file
61
server/auth-session.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Server-side session validation for the Vercel edge gateway.
|
||||
*
|
||||
* Validates Clerk-issued bearer tokens using local JWT verification
|
||||
* with jose + cached JWKS. No Convex round-trip needed.
|
||||
*
|
||||
* This module must NOT import anything from `src/` -- it runs in the
|
||||
* Vercel edge runtime, not the browser.
|
||||
*/
|
||||
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
// Clerk JWT issuer domain -- set in Vercel env vars
|
||||
const CLERK_JWT_ISSUER_DOMAIN = process.env.CLERK_JWT_ISSUER_DOMAIN ?? '';
|
||||
|
||||
// Module-scope JWKS resolver -- cached across warm invocations.
|
||||
// jose handles key rotation and caching internally.
|
||||
let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
function getJWKS() {
|
||||
if (!_jwks && CLERK_JWT_ISSUER_DOMAIN) {
|
||||
const jwksUrl = new URL('/.well-known/jwks.json', CLERK_JWT_ISSUER_DOMAIN);
|
||||
_jwks = createRemoteJWKSet(jwksUrl);
|
||||
}
|
||||
return _jwks;
|
||||
}
|
||||
|
||||
export interface SessionResult {
|
||||
valid: boolean;
|
||||
userId?: string;
|
||||
role?: 'free' | 'pro';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Clerk-issued bearer token using local JWKS verification.
|
||||
* Extracts `sub` (user ID) and `plan` (entitlement) from verified claims.
|
||||
* Fails closed: invalid/expired/unverifiable tokens return { valid: false }.
|
||||
*/
|
||||
export async function validateBearerToken(token: string): Promise<SessionResult> {
|
||||
const jwks = getJWKS();
|
||||
if (!jwks) return { valid: false };
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer: CLERK_JWT_ISSUER_DOMAIN,
|
||||
audience: 'convex',
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
|
||||
const userId = payload.sub;
|
||||
if (!userId) return { valid: false };
|
||||
|
||||
// Normalize plan claim -- unknown/missing = free (never pro)
|
||||
const rawPlan = (payload as Record<string, unknown>).plan;
|
||||
const role: 'free' | 'pro' = rawPlan === 'pro' ? 'pro' : 'free';
|
||||
|
||||
return { valid: true, userId, role };
|
||||
} catch {
|
||||
// Signature verification failed, expired, wrong issuer, etc.
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
@@ -189,14 +189,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/economic/v1/get-economic-calendar': 'slow',
|
||||
};
|
||||
|
||||
// TODO(payment-pr): PREMIUM_RPC_PATHS is intentionally empty until the payment/pro-user
|
||||
// system is implemented. The original set of stock analysis paths used forceKey=true,
|
||||
// which broke web pro users because isTrustedBrowserOrigin() is header-only (Origin can be
|
||||
// spoofed) and the web client has no mechanism to forward a server-validated entitlement.
|
||||
// When the payment PR lands, re-populate this set and have the web client send a
|
||||
// server-validated pro token (e.g. X-WorldMonitor-Key) so the entitlement check is
|
||||
// meaningful. Until then, access is gated client-side by isProUser() + WORLDMONITOR_API_KEY.
|
||||
const PREMIUM_RPC_PATHS = new Set<string>();
|
||||
import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths';
|
||||
|
||||
/**
|
||||
* Creates a Vercel Edge handler for a single domain's routes.
|
||||
@@ -234,15 +227,41 @@ export function createDomainGateway(
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// API key validation (origin-aware)
|
||||
// API key validation
|
||||
const keyCheck = validateApiKey(request, {
|
||||
forceKey: PREMIUM_RPC_PATHS.has(pathname),
|
||||
});
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
if (PREMIUM_RPC_PATHS.has(pathname)) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const { validateBearerToken } = await import('./auth-session');
|
||||
const session = await validateBearerToken(authHeader.slice(7));
|
||||
if (!session.valid) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired session' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
if (session.role !== 'pro') {
|
||||
return new Response(JSON.stringify({ error: 'Pro subscription required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
// Valid pro session — fall through to route handling
|
||||
} else {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return new Response(JSON.stringify({ error: keyCheck.error }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// IP-based rate limiting — two-phase: endpoint-specific first, then global fallback
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data: https://abacus.worldmonitor.app; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://abacus.worldmonitor.app; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com;"
|
||||
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data: https://abacus.worldmonitor.app; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://abacus.worldmonitor.app https://*.clerk.accounts.dev; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://*.clerk.accounts.dev;"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
123
src/App.ts
123
src/App.ts
@@ -16,6 +16,7 @@ import {
|
||||
import { sanitizeLayersForVariant } from '@/config/map-layer-definitions';
|
||||
import type { MapVariant } from '@/config/map-layer-definitions';
|
||||
import { initDB, cleanOldSnapshots, isAisConfigured, initAisStream, isOutagesConfigured, disconnectAisStream } from '@/services';
|
||||
import { isProUser } from '@/services/widget-store';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } from '@/services/ai-flow-settings';
|
||||
import { startLearning } from '@/services/country-instability';
|
||||
@@ -44,9 +45,9 @@ import type { EconomicCalendarPanel } from '@/components/EconomicCalendarPanel';
|
||||
import type { CotPositioningPanel } from '@/components/CotPositioningPanel';
|
||||
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
|
||||
import { getSecretState } from '@/services/runtime-config';
|
||||
import { isProUser } from '@/services/widget-store';
|
||||
import { getAuthState } from '@/services/auth-state';
|
||||
import { BETA_MODE } from '@/config/beta';
|
||||
import { trackEvent, trackDeeplinkOpened } from '@/services/analytics';
|
||||
import { trackEvent, trackDeeplinkOpened, initAuthAnalytics } from '@/services/analytics';
|
||||
import { preloadCountryGeometry, getCountryNameByCode } from '@/services/country-geometry';
|
||||
import { initI18n, t } from '@/services/i18n';
|
||||
|
||||
@@ -62,6 +63,7 @@ import { DataLoaderManager } from '@/app/data-loader';
|
||||
import { EventHandlerManager } from '@/app/event-handlers';
|
||||
import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location';
|
||||
import { showProBanner } from '@/components/ProBanner';
|
||||
import { initAuthState, subscribeAuthState } from '@/services/auth-state';
|
||||
import {
|
||||
CorrelationEngine,
|
||||
militaryAdapter,
|
||||
@@ -91,6 +93,7 @@ export class App {
|
||||
|
||||
private modules: { destroy(): void }[] = [];
|
||||
private unsubAiFlow: (() => void) | null = null;
|
||||
private unsubFreeTier: (() => void) | null = null;
|
||||
private visiblePanelPrimed = new Set<string>();
|
||||
private visiblePanelPrimeRaf: number | null = null;
|
||||
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
|
||||
@@ -330,7 +333,7 @@ export class App {
|
||||
primeTask('crossSourceSignals', () => this.dataLoader.loadCrossSourceSignals());
|
||||
}
|
||||
|
||||
const _wmAccess = getSecretState('WORLDMONITOR_API_KEY').present || isProUser();
|
||||
const _wmAccess = getSecretState('WORLDMONITOR_API_KEY').present || getAuthState().user?.role === 'pro';
|
||||
if (_wmAccess) {
|
||||
if (shouldPrime('stock-analysis')) {
|
||||
primeTask('stockAnalysis', () => this.dataLoader.loadStockAnalysis());
|
||||
@@ -565,31 +568,6 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce free-tier panel limit on every launch (handles legacy/downgraded users).
|
||||
if (!isProUser()) {
|
||||
// cw-* (custom widget) panels are not loaded for free users — disable them so they
|
||||
// don't silently consume quota slots that count against visible standard panels.
|
||||
let cwDisabled = false;
|
||||
for (const key of Object.keys(panelSettings)) {
|
||||
if (key.startsWith('cw-') && panelSettings[key]?.enabled) {
|
||||
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
|
||||
cwDisabled = true;
|
||||
}
|
||||
}
|
||||
const enabledKeys = Object.entries(panelSettings)
|
||||
.filter(([k, v]) => v.enabled && !k.startsWith('cw-'))
|
||||
.sort(([ka, a], [kb, b]) => (a.priority ?? 99) - (b.priority ?? 99) || ka.localeCompare(kb))
|
||||
.map(([k]) => k);
|
||||
const needsTrim = enabledKeys.length > FREE_MAX_PANELS;
|
||||
if (needsTrim) {
|
||||
for (const key of enabledKeys.slice(FREE_MAX_PANELS)) {
|
||||
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
|
||||
}
|
||||
console.log(`[App] Free tier: trimmed ${enabledKeys.length - FREE_MAX_PANELS} panel(s) to enforce ${FREE_MAX_PANELS}-panel limit`);
|
||||
}
|
||||
if (cwDisabled || needsTrim) saveToStorage(STORAGE_KEYS.panels, panelSettings);
|
||||
}
|
||||
|
||||
const initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers);
|
||||
if (initialUrlState.layers) {
|
||||
mapLayers = sanitizeLayersForVariant(initialUrlState.layers, currentVariant as MapVariant);
|
||||
@@ -625,26 +603,6 @@ export class App {
|
||||
|
||||
const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
|
||||
|
||||
// Enforce free-tier source limit on every launch (handles legacy/downgraded users).
|
||||
if (!isProUser()) {
|
||||
const allSourceNames = (() => {
|
||||
const s = new Set<string>();
|
||||
Object.values(FEEDS).forEach(feeds => feeds?.forEach(f => s.add(f.name)));
|
||||
INTEL_SOURCES.forEach(f => s.add(f.name));
|
||||
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
||||
})();
|
||||
const currentlyEnabled = allSourceNames.filter(n => !disabledSources.has(n));
|
||||
const enabledCount = currentlyEnabled.length;
|
||||
if (enabledCount > FREE_MAX_SOURCES) {
|
||||
const toDisable = enabledCount - FREE_MAX_SOURCES;
|
||||
for (const name of currentlyEnabled.slice(FREE_MAX_SOURCES)) {
|
||||
disabledSources.add(name);
|
||||
}
|
||||
saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabledSources));
|
||||
console.log(`[App] Free tier: disabled ${toDisable} source(s) to enforce ${FREE_MAX_SOURCES}-source limit`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build shared state object
|
||||
this.state = {
|
||||
map: null,
|
||||
@@ -688,6 +646,8 @@ export class App {
|
||||
digestPanel: null,
|
||||
speciesPanel: null,
|
||||
renewablePanel: null,
|
||||
authModal: null,
|
||||
authHeaderWidget: null,
|
||||
tvMode: null,
|
||||
happyAllItems: [],
|
||||
isDestroyed: false,
|
||||
@@ -811,6 +771,15 @@ export class App {
|
||||
await fetchBootstrapData();
|
||||
this.bootstrapHydrationState = getBootstrapHydrationState();
|
||||
|
||||
// Verify OAuth OTT and hydrate auth session BEFORE any UI subscribes to auth state
|
||||
if (isProUser()) {
|
||||
await initAuthState();
|
||||
initAuthAnalytics();
|
||||
}
|
||||
this.enforceFreeTierLimits();
|
||||
this.unsubFreeTier = subscribeAuthState(() => { this.enforceFreeTierLimits(); });
|
||||
|
||||
|
||||
const geoCoordsPromise: Promise<PreciseCoordinates | null> =
|
||||
this.state.isMobile && this.state.initialUrlState?.lat === undefined && this.state.initialUrlState?.lon === undefined
|
||||
? resolvePreciseUserCoordinates(5000)
|
||||
@@ -876,6 +845,7 @@ export class App {
|
||||
correlationEngine.registerAdapter(disasterAdapter);
|
||||
this.state.correlationEngine = correlationEngine;
|
||||
this.eventHandlers.setupUnifiedSettings();
|
||||
if (isProUser()) this.eventHandlers.setupAuthWidget();
|
||||
|
||||
// Phase 4: SearchManager, MapLayerHandlers, CountryIntel
|
||||
this.searchManager.init();
|
||||
@@ -958,6 +928,56 @@ export class App {
|
||||
this.eventHandlers.setupPanelViewTracking();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce free-tier panel and source limits.
|
||||
* Reads current values from storage, trims if necessary, and saves back.
|
||||
* Safe to call multiple times (idempotent) — e.g. on auth state changes.
|
||||
*/
|
||||
private enforceFreeTierLimits(): void {
|
||||
if (isProUser()) return;
|
||||
|
||||
// --- Panel limit ---
|
||||
const panelSettings = loadFromStorage<Record<string, PanelConfig>>(STORAGE_KEYS.panels, {});
|
||||
let cwDisabled = false;
|
||||
for (const key of Object.keys(panelSettings)) {
|
||||
if (key.startsWith('cw-') && panelSettings[key]?.enabled) {
|
||||
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
|
||||
cwDisabled = true;
|
||||
}
|
||||
}
|
||||
const enabledKeys = Object.entries(panelSettings)
|
||||
.filter(([k, v]) => v.enabled && !k.startsWith('cw-'))
|
||||
.sort(([ka, a], [kb, b]) => (a.priority ?? 99) - (b.priority ?? 99) || ka.localeCompare(kb))
|
||||
.map(([k]) => k);
|
||||
const needsTrim = enabledKeys.length > FREE_MAX_PANELS;
|
||||
if (needsTrim) {
|
||||
for (const key of enabledKeys.slice(FREE_MAX_PANELS)) {
|
||||
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
|
||||
}
|
||||
console.log(`[App] Free tier: trimmed ${enabledKeys.length - FREE_MAX_PANELS} panel(s) to enforce ${FREE_MAX_PANELS}-panel limit`);
|
||||
}
|
||||
if (cwDisabled || needsTrim) saveToStorage(STORAGE_KEYS.panels, panelSettings);
|
||||
|
||||
// --- Source limit ---
|
||||
const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
|
||||
const allSourceNames = (() => {
|
||||
const s = new Set<string>();
|
||||
Object.values(FEEDS).forEach(feeds => feeds?.forEach(f => s.add(f.name)));
|
||||
INTEL_SOURCES.forEach(f => s.add(f.name));
|
||||
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
||||
})();
|
||||
const currentlyEnabled = allSourceNames.filter(n => !disabledSources.has(n));
|
||||
const enabledCount = currentlyEnabled.length;
|
||||
if (enabledCount > FREE_MAX_SOURCES) {
|
||||
const toDisable = enabledCount - FREE_MAX_SOURCES;
|
||||
for (const name of currentlyEnabled.slice(FREE_MAX_SOURCES)) {
|
||||
disabledSources.add(name);
|
||||
}
|
||||
saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabledSources));
|
||||
console.log(`[App] Free tier: disabled ${toDisable} source(s) to enforce ${FREE_MAX_SOURCES}-source limit`);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.state.isDestroyed = true;
|
||||
window.removeEventListener('scroll', this.handleViewportPrime);
|
||||
@@ -976,6 +996,7 @@ export class App {
|
||||
|
||||
// Clean up subscriptions, map, AIS, and breaking news
|
||||
this.unsubAiFlow?.();
|
||||
this.unsubFreeTier?.();
|
||||
this.state.breakingBanner?.destroy();
|
||||
destroyBreakingNewsAlerts();
|
||||
this.cachedModeBannerEl?.remove();
|
||||
@@ -1074,19 +1095,19 @@ export class App {
|
||||
'stock-analysis',
|
||||
() => this.dataLoader.loadStockAnalysis(),
|
||||
REFRESH_INTERVALS.stockAnalysis,
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) && this.isPanelNearViewport('stock-analysis'),
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || getAuthState().user?.role === 'pro') && this.isPanelNearViewport('stock-analysis'),
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'daily-market-brief',
|
||||
() => this.dataLoader.loadDailyMarketBrief(),
|
||||
REFRESH_INTERVALS.dailyMarketBrief,
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) && this.isPanelNearViewport('daily-market-brief'),
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || getAuthState().user?.role === 'pro') && this.isPanelNearViewport('daily-market-brief'),
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'stock-backtest',
|
||||
() => this.dataLoader.loadStockBacktest(),
|
||||
REFRESH_INTERVALS.stockBacktest,
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) && this.isPanelNearViewport('stock-backtest'),
|
||||
() => (getSecretState('WORLDMONITOR_API_KEY').present || getAuthState().user?.role === 'pro') && this.isPanelNearViewport('stock-backtest'),
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'market-implications',
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface AppContext {
|
||||
digestPanel: import('@/components/GoodThingsDigestPanel').GoodThingsDigestPanel | null;
|
||||
speciesPanel: import('@/components/SpeciesComebackPanel').SpeciesComebackPanel | null;
|
||||
renewablePanel: import('@/components/RenewableEnergyPanel').RenewableEnergyPanel | null;
|
||||
authModal: { open(): void; close(): void; destroy(): void } | null;
|
||||
authHeaderWidget: import('@/components/AuthHeaderWidget').AuthHeaderWidget | null;
|
||||
tvMode: import('@/services/tv-mode').TvModeController | null;
|
||||
happyAllItems: NewsItem[];
|
||||
isDestroyed: boolean;
|
||||
|
||||
@@ -114,7 +114,14 @@ import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate
|
||||
import { enrichEventsWithExposure } from '@/services/population-exposure';
|
||||
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
|
||||
import { getSecretState, isFeatureAvailable, isFeatureEnabled } from '@/services/runtime-config';
|
||||
import { isProUser } from '@/services/widget-store';
|
||||
import { getAuthState } from '@/services/auth-state';
|
||||
|
||||
/** True when the user has premium data access — desktop API key OR web Clerk Pro. */
|
||||
function hasPremiumAccess(): boolean {
|
||||
if (getSecretState('WORLDMONITOR_API_KEY').present) return true;
|
||||
if (getAuthState().user?.role === 'pro') return true;
|
||||
return false;
|
||||
}
|
||||
import { isDesktopRuntime, toApiUrl } from '@/services/runtime';
|
||||
import { getAiFlowSettings } from '@/services/ai-flow-settings';
|
||||
import { t, getCurrentLanguage } from '@/services/i18n';
|
||||
@@ -255,7 +262,7 @@ export class DataLoaderManager implements AppModule {
|
||||
init(): void {
|
||||
this.boundMarketWatchlistHandler = () => {
|
||||
void this.loadMarkets().then(async () => {
|
||||
if (getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) {
|
||||
if (hasPremiumAccess()) {
|
||||
await this.loadStockAnalysis();
|
||||
await this.loadStockBacktest();
|
||||
await this.loadDailyMarketBrief(true);
|
||||
@@ -388,14 +395,15 @@ export class DataLoaderManager implements AppModule {
|
||||
if (shouldLoadAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens'])) {
|
||||
tasks.push({ name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) });
|
||||
}
|
||||
// TODO(payment-pr): isProUser() checks localStorage/cookie only — not server-validated.
|
||||
// Replace with a server-verified entitlement check once the payment system is in place.
|
||||
if ((getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) && shouldLoad('stock-analysis')) {
|
||||
if (hasPremiumAccess() && shouldLoad('stock-analysis')) {
|
||||
tasks.push({ name: 'stockAnalysis', task: runGuarded('stockAnalysis', () => this.loadStockAnalysis()) });
|
||||
}
|
||||
if ((getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) && shouldLoad('stock-backtest')) {
|
||||
if (hasPremiumAccess() && shouldLoad('stock-backtest')) {
|
||||
tasks.push({ name: 'stockBacktest', task: runGuarded('stockBacktest', () => this.loadStockBacktest()) });
|
||||
}
|
||||
if (hasPremiumAccess() && shouldLoad('daily-market-brief')) {
|
||||
tasks.push({ name: 'dailyMarketBrief', task: runGuarded('dailyMarketBrief', () => this.loadDailyMarketBrief()) });
|
||||
}
|
||||
if (shouldLoad('polymarket')) {
|
||||
tasks.push({ name: 'predictions', task: runGuarded('predictions', () => this.loadPredictions()) });
|
||||
}
|
||||
@@ -541,7 +549,7 @@ export class DataLoaderManager implements AppModule {
|
||||
|
||||
this.updateSearchIndex();
|
||||
|
||||
if (getSecretState('WORLDMONITOR_API_KEY').present || isProUser()) {
|
||||
if (hasPremiumAccess()) {
|
||||
await Promise.allSettled([
|
||||
this.loadDailyMarketBrief(),
|
||||
this.loadMarketImplications(),
|
||||
@@ -1387,7 +1395,7 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
|
||||
async loadDailyMarketBrief(force = false): Promise<void> {
|
||||
if (!getSecretState('WORLDMONITOR_API_KEY').present && !isProUser()) return;
|
||||
if (!hasPremiumAccess()) return;
|
||||
if (this.ctx.isDestroyed || this.ctx.inFlight.has('dailyMarketBrief')) return;
|
||||
|
||||
this.ctx.inFlight.add('dailyMarketBrief');
|
||||
@@ -1536,7 +1544,7 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
|
||||
async loadMarketImplications(): Promise<void> {
|
||||
if (!getSecretState('WORLDMONITOR_API_KEY').present && !isProUser()) return;
|
||||
if (!hasPremiumAccess()) return;
|
||||
if (this.ctx.isDestroyed || this.ctx.inFlight.has('marketImplications')) return;
|
||||
this.ctx.inFlight.add('marketImplications');
|
||||
try {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
trackMapLayerToggle,
|
||||
trackPanelToggled,
|
||||
trackDownloadClicked,
|
||||
trackGateHit,
|
||||
} from '@/services/analytics';
|
||||
import { detectPlatform, allButtons, buttonsForPlatform } from '@/components/DownloadBanner';
|
||||
import type { Platform } from '@/components/DownloadBanner';
|
||||
@@ -60,8 +61,11 @@ import { getCachedGpsInterference } from '@/services/gps-interference';
|
||||
import { dataFreshness } from '@/services/data-freshness';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { UnifiedSettings } from '@/components/UnifiedSettings';
|
||||
import { AuthLauncher } from '@/components/AuthLauncher';
|
||||
import { AuthHeaderWidget } from '@/components/AuthHeaderWidget';
|
||||
import { t } from '@/services/i18n';
|
||||
import { TvModeController } from '@/services/tv-mode';
|
||||
import { getAuthState, subscribeAuthState } from '@/services/auth-state';
|
||||
|
||||
export interface EventHandlerCallbacks {
|
||||
updateSearchIndex: () => void;
|
||||
@@ -101,6 +105,7 @@ export class EventHandlerManager implements AppModule {
|
||||
private boundPanelCloseHandler: ((e: Event) => void) | null = null;
|
||||
private boundWidgetModifyHandler: ((e: Event) => void) | null = null;
|
||||
private boundUndoHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private proGateUnsubscribers: Array<() => void> = [];
|
||||
private closedPanelStack: string[] = []; // max-items: 20
|
||||
private idleTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private snapshotIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -288,10 +293,16 @@ export class EventHandlerManager implements AppModule {
|
||||
document.removeEventListener('keydown', this.boundUndoHandler);
|
||||
this.boundUndoHandler = null;
|
||||
}
|
||||
for (const unsub of this.proGateUnsubscribers) unsub();
|
||||
this.proGateUnsubscribers = [];
|
||||
this.ctx.tvMode?.destroy();
|
||||
this.ctx.tvMode = null;
|
||||
this.ctx.unifiedSettings?.destroy();
|
||||
this.ctx.unifiedSettings = null;
|
||||
this.ctx.authHeaderWidget?.destroy();
|
||||
this.ctx.authHeaderWidget = null;
|
||||
this.ctx.authModal?.destroy();
|
||||
this.ctx.authModal = null;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
@@ -326,6 +337,7 @@ export class EventHandlerManager implements AppModule {
|
||||
});
|
||||
|
||||
this.initDownloadDropdown();
|
||||
this.initFooterDownload();
|
||||
|
||||
this.boundStorageHandler = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEYS.panels && e.newValue) {
|
||||
@@ -797,6 +809,28 @@ export class EventHandlerManager implements AppModule {
|
||||
document.addEventListener('keydown', this.boundDropdownKeydownHandler);
|
||||
}
|
||||
|
||||
private initFooterDownload(): void {
|
||||
const mount = document.getElementById('footerDownloadMount');
|
||||
if (!mount) return;
|
||||
const platform = detectPlatform();
|
||||
const primary = buttonsForPlatform(platform);
|
||||
const btn = primary[0];
|
||||
if (!btn) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = btn.href;
|
||||
a.textContent = t('header.downloadApp');
|
||||
a.className = 'site-footer-download-link';
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const plat = new URL(btn.href, location.origin).searchParams.get('platform') || 'unknown';
|
||||
trackDownloadClicked(plat);
|
||||
window.open(btn.href, '_blank');
|
||||
});
|
||||
mount.replaceWith(a);
|
||||
}
|
||||
|
||||
private setCopyLinkFeedback(button: HTMLElement | null, message: string): void {
|
||||
if (!button) return;
|
||||
const originalText = button.textContent ?? '';
|
||||
@@ -927,7 +961,7 @@ export class EventHandlerManager implements AppModule {
|
||||
}
|
||||
|
||||
setupExportPanel(): void {
|
||||
if (!isProUser()) return;
|
||||
// Always create — show/hide reactively via auth state subscription below.
|
||||
this.ctx.exportPanel = new ExportPanel(() => {
|
||||
const allCards = this.ctx.correlationEngine?.getAllCards() ?? [];
|
||||
const disabledCount = this.ctx.disabledSources.size;
|
||||
@@ -952,10 +986,18 @@ export class EventHandlerManager implements AppModule {
|
||||
};
|
||||
});
|
||||
|
||||
const el = this.ctx.exportPanel.getElement();
|
||||
const headerRight = this.ctx.container.querySelector('.header-right');
|
||||
if (headerRight) {
|
||||
headerRight.insertBefore(this.ctx.exportPanel.getElement(), headerRight.firstChild);
|
||||
headerRight.insertBefore(el, headerRight.firstChild);
|
||||
}
|
||||
|
||||
const applyProGate = (isPro: boolean, initial = false) => {
|
||||
el.style.display = isPro ? '' : 'none';
|
||||
if (initial && !isPro) trackGateHit('export');
|
||||
};
|
||||
applyProGate(getAuthState().user?.role === 'pro', true);
|
||||
this.proGateUnsubscribers.push(subscribeAuthState(state => applyProGate(state.user?.role === 'pro')));
|
||||
}
|
||||
|
||||
setupUnifiedSettings(): void {
|
||||
@@ -1040,8 +1082,23 @@ export class EventHandlerManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
setupAuthWidget(): void {
|
||||
const modal = new AuthLauncher();
|
||||
this.ctx.authModal = modal;
|
||||
|
||||
const widget = new AuthHeaderWidget(
|
||||
() => modal.open(),
|
||||
() => this.ctx.unifiedSettings?.open(),
|
||||
);
|
||||
this.ctx.authHeaderWidget = widget;
|
||||
const mount = document.getElementById('authWidgetMount');
|
||||
if (mount) {
|
||||
mount.appendChild(widget.getElement());
|
||||
}
|
||||
}
|
||||
|
||||
setupPlaybackControl(): void {
|
||||
if (!isProUser()) return;
|
||||
// Always create — show/hide reactively via auth state subscription below.
|
||||
this.ctx.playbackControl = new PlaybackControl();
|
||||
this.ctx.playbackControl.onSnapshot((snapshot) => {
|
||||
if (snapshot) {
|
||||
@@ -1053,10 +1110,18 @@ export class EventHandlerManager implements AppModule {
|
||||
}
|
||||
});
|
||||
|
||||
const el = this.ctx.playbackControl.getElement();
|
||||
const headerRight = this.ctx.container.querySelector('.header-right');
|
||||
if (headerRight) {
|
||||
headerRight.insertBefore(this.ctx.playbackControl.getElement(), headerRight.firstChild);
|
||||
headerRight.insertBefore(el, headerRight.firstChild);
|
||||
}
|
||||
|
||||
const applyProGate = (isPro: boolean, initial = false) => {
|
||||
el.style.display = isPro ? '' : 'none';
|
||||
if (initial && !isPro) trackGateHit('playback');
|
||||
};
|
||||
applyProGate(getAuthState().user?.role === 'pro', true);
|
||||
this.proGateUnsubscribers.push(subscribeAuthState(state => applyProGate(state.user?.role === 'pro')));
|
||||
}
|
||||
|
||||
setupSnapshotSaving(): void {
|
||||
|
||||
@@ -88,6 +88,17 @@ import { McpDataPanel } from '@/components/McpDataPanel';
|
||||
import { openMcpConnectModal } from '@/components/McpConnectModal';
|
||||
import { loadMcpPanels, saveMcpPanel } from '@/services/mcp-store';
|
||||
import type { McpPanelSpec } from '@/services/mcp-store';
|
||||
import { getAuthState, subscribeAuthState } from '@/services/auth-state';
|
||||
import type { AuthSession } from '@/services/auth-state';
|
||||
import { PanelGateReason, getPanelGateReason } from '@/services/panel-gating';
|
||||
import type { Panel } from '@/components/Panel';
|
||||
|
||||
/** Panels that require premium access on the web. Auth-based gating applies to these. */
|
||||
const WEB_PREMIUM_PANELS = new Set([
|
||||
'stock-analysis',
|
||||
'stock-backtest',
|
||||
'daily-market-brief',
|
||||
]);
|
||||
|
||||
export interface PanelLayoutManagerCallbacks {
|
||||
openCountryStory: (code: string, name: string) => void;
|
||||
@@ -106,6 +117,8 @@ export class PanelLayoutManager implements AppModule {
|
||||
private criticalBannerEl: HTMLElement | null = null;
|
||||
private aviationCommandBar: AviationCommandBar | null = null;
|
||||
private readonly applyTimeRangeFilterDebounced: (() => void) & { cancel(): void };
|
||||
private unsubscribeAuth: (() => void) | null = null;
|
||||
private proBlockUnsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(ctx: AppContext, callbacks: PanelLayoutManagerCallbacks) {
|
||||
this.ctx = ctx;
|
||||
@@ -117,12 +130,21 @@ export class PanelLayoutManager implements AppModule {
|
||||
|
||||
init(): void {
|
||||
this.renderLayout();
|
||||
|
||||
// Subscribe to auth state for reactive panel gating on web
|
||||
this.unsubscribeAuth = subscribeAuthState((state) => {
|
||||
this.updatePanelGating(state);
|
||||
});
|
||||
this.fetchGitHubStars();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearAllPendingCalls();
|
||||
this.applyTimeRangeFilterDebounced.cancel();
|
||||
this.unsubscribeAuth?.();
|
||||
this.unsubscribeAuth = null;
|
||||
this.proBlockUnsubscribe?.();
|
||||
this.proBlockUnsubscribe = null;
|
||||
this.panelDragCleanupHandlers.forEach((cleanup) => cleanup());
|
||||
this.panelDragCleanupHandlers = [];
|
||||
if (this.criticalBannerEl) {
|
||||
@@ -148,6 +170,35 @@ export class PanelLayoutManager implements AppModule {
|
||||
window.removeEventListener('resize', this.ensureCorrectZones);
|
||||
}
|
||||
|
||||
/** Reactively update premium panel gating based on auth state. */
|
||||
private updatePanelGating(state: AuthSession): void {
|
||||
for (const [key, panel] of Object.entries(this.ctx.panels)) {
|
||||
const isPremium = WEB_PREMIUM_PANELS.has(key);
|
||||
const reason = getPanelGateReason(state, isPremium);
|
||||
|
||||
if (reason === PanelGateReason.NONE) {
|
||||
// User has access -- unlock if previously locked
|
||||
(panel as Panel).unlockPanel();
|
||||
} else {
|
||||
// User does NOT have access -- show appropriate CTA
|
||||
const onAction = this.getGateAction(reason);
|
||||
(panel as Panel).showGatedCta(reason, onAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the action callback for a given gate reason. */
|
||||
private getGateAction(reason: PanelGateReason): () => void {
|
||||
switch (reason) {
|
||||
case PanelGateReason.ANONYMOUS:
|
||||
return () => this.ctx.authModal?.open();
|
||||
case PanelGateReason.FREE_TIER:
|
||||
return () => window.open('https://worldmonitor.app/pro', '_blank');
|
||||
default:
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchGitHubStars(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/koala73/worldmonitor');
|
||||
@@ -256,18 +307,12 @@ export class PanelLayoutManager implements AppModule {
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
${this.ctx.isDesktopApp ? '' : `<div class="download-wrapper" id="downloadWrapper">
|
||||
<button class="download-btn" id="downloadBtn" title="${t('header.downloadApp')}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span id="downloadBtnLabel">${t('header.downloadApp')}</span>
|
||||
</button>
|
||||
<div class="download-dropdown" id="downloadDropdown"></div>
|
||||
</div>`}
|
||||
<button class="search-btn" id="searchBtn"><kbd>⌘K</kbd> ${t('header.search')}</button>
|
||||
${this.ctx.isDesktopApp ? '' : `<button class="copy-link-btn" id="copyLinkBtn">${t('header.copyLink')}</button>`}
|
||||
${this.ctx.isDesktopApp ? '' : `<button class="fullscreen-btn" id="fullscreenBtn" title="${t('header.fullscreen')}">⛶</button>`}
|
||||
${SITE_VARIANT === 'happy' ? `<button class="tv-mode-btn" id="tvModeBtn" title="TV Mode (Shift+T)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></button>` : ''}
|
||||
<span id="unifiedSettingsMount"></span>
|
||||
<span id="authWidgetMount"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-menu-overlay" id="mobileMenuOverlay"></div>
|
||||
@@ -378,7 +423,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
<img src="/favico/favicon-32x32.png" alt="" width="28" height="28" class="site-footer-icon" />
|
||||
<div class="site-footer-brand-text">
|
||||
<span class="site-footer-name">WORLD MONITOR</span>
|
||||
<span class="site-footer-sub">by Someone.ceo</span>
|
||||
<span class="site-footer-sub">v${__APP_VERSION__} · <a href="https://x.com/eliehabib" target="_blank" rel="noopener" class="site-footer-credit">@eliehabib</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -389,6 +434,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
<a href="https://github.com/koala73/worldmonitor" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://discord.gg/re63kWKxaz" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="https://x.com/worldmonitorai" target="_blank" rel="noopener">X</a>
|
||||
${this.ctx.isDesktopApp ? '' : `<span id="footerDownloadMount"></span>`}
|
||||
</nav>
|
||||
<span class="site-footer-copy">© ${new Date().getFullYear()} World Monitor</span>
|
||||
</footer>
|
||||
@@ -583,22 +629,10 @@ export class PanelLayoutManager implements AppModule {
|
||||
|
||||
this.createPanel('heatmap', () => new HeatmapPanel());
|
||||
this.createPanel('markets', () => new MarketPanel());
|
||||
const stockAnalysisPanel = this.createPanel('stock-analysis', () => new StockAnalysisPanel());
|
||||
if (stockAnalysisPanel && !getSecretState('WORLDMONITOR_API_KEY').present && !isProUser()) {
|
||||
stockAnalysisPanel.showLocked([
|
||||
'AI stock briefs with technical + news synthesis',
|
||||
'Trend scoring from MA, MACD, RSI, and volume structure',
|
||||
'Actionable watchlist monitoring for your premium workspace',
|
||||
]);
|
||||
}
|
||||
const stockBacktestPanel = this.createPanel('stock-backtest', () => new StockBacktestPanel());
|
||||
if (stockBacktestPanel && !getSecretState('WORLDMONITOR_API_KEY').present && !isProUser()) {
|
||||
stockBacktestPanel.showLocked([
|
||||
'Historical replay of premium stock-analysis signals',
|
||||
'Win-rate, accuracy, and simulated-return metrics',
|
||||
'Recent evaluation samples for your tracked symbols',
|
||||
]);
|
||||
}
|
||||
this.createPanel('stock-analysis', () => new StockAnalysisPanel());
|
||||
this.createPanel('stock-backtest', () => new StockBacktestPanel());
|
||||
// Web premium gating for stock-analysis and stock-backtest is handled
|
||||
// reactively by updatePanelGating() via auth state subscription.
|
||||
|
||||
const monitorPanel = this.createPanel('monitors', () => new MonitorPanel(this.ctx.monitors));
|
||||
monitorPanel?.onChanged((monitors) => {
|
||||
@@ -794,9 +828,9 @@ export class PanelLayoutManager implements AppModule {
|
||||
|
||||
this.lazyPanel('daily-market-brief', () =>
|
||||
import('@/components/DailyMarketBriefPanel').then(m => new m.DailyMarketBriefPanel()),
|
||||
undefined,
|
||||
(!_wmKeyPresent && !isProUser()) ? ['Pre-market watchlist priorities', 'Action plan for the session', 'Risk watch tied to current finance headlines'] : undefined,
|
||||
);
|
||||
// Web premium gating for daily-market-brief is handled reactively
|
||||
// by updatePanelGating() via auth state subscription.
|
||||
|
||||
this.lazyPanel('market-implications', () =>
|
||||
import('@/components/MarketImplicationsPanel').then(m => new m.MarketImplicationsPanel()),
|
||||
@@ -991,13 +1025,12 @@ export class PanelLayoutManager implements AppModule {
|
||||
);
|
||||
}
|
||||
|
||||
if (isProUser()) {
|
||||
for (const spec of loadWidgets()) {
|
||||
const panel = new CustomWidgetPanel(spec);
|
||||
this.ctx.panels[spec.id] = panel;
|
||||
if (!this.ctx.panelSettings[spec.id]) {
|
||||
this.ctx.panelSettings[spec.id] = { name: spec.title, enabled: true, priority: 3 };
|
||||
}
|
||||
// Always load custom widgets — Pro gating is handled reactively by auth state.
|
||||
for (const spec of loadWidgets()) {
|
||||
const panel = new CustomWidgetPanel(spec);
|
||||
this.ctx.panels[spec.id] = panel;
|
||||
if (!this.ctx.panelSettings[spec.id]) {
|
||||
this.ctx.panelSettings[spec.id] = { name: spec.title, enabled: true, priority: 3 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1107,51 +1140,62 @@ export class PanelLayoutManager implements AppModule {
|
||||
});
|
||||
panelsGrid.appendChild(addPanelBlock);
|
||||
|
||||
if (isProUser()) {
|
||||
const proBlock = document.createElement('button');
|
||||
proBlock.className = 'add-panel-block ai-widget-block ai-widget-block-pro';
|
||||
proBlock.setAttribute('aria-label', t('widgets.createInteractive'));
|
||||
const proIcon = document.createElement('span');
|
||||
proIcon.className = 'add-panel-block-icon';
|
||||
proIcon.textContent = '\u26a1';
|
||||
const proLabel = document.createElement('span');
|
||||
proLabel.className = 'add-panel-block-label';
|
||||
proLabel.textContent = t('widgets.createInteractive');
|
||||
const proBadge = document.createElement('span');
|
||||
proBadge.className = 'widget-pro-badge';
|
||||
proBadge.textContent = t('widgets.proBadge');
|
||||
proBlock.appendChild(proIcon);
|
||||
proBlock.appendChild(proLabel);
|
||||
proBlock.appendChild(proBadge);
|
||||
proBlock.addEventListener('click', () => {
|
||||
openWidgetChatModal({
|
||||
mode: 'create',
|
||||
tier: 'pro',
|
||||
onComplete: (spec) => this.addCustomWidget(spec),
|
||||
});
|
||||
// Always create Pro and MCP add-panel blocks — show/hide reactively via auth state.
|
||||
const proBlock = document.createElement('button');
|
||||
proBlock.className = 'add-panel-block ai-widget-block ai-widget-block-pro';
|
||||
proBlock.setAttribute('aria-label', t('widgets.createInteractive'));
|
||||
const proIcon = document.createElement('span');
|
||||
proIcon.className = 'add-panel-block-icon';
|
||||
proIcon.textContent = '\u26a1';
|
||||
const proLabel = document.createElement('span');
|
||||
proLabel.className = 'add-panel-block-label';
|
||||
proLabel.textContent = t('widgets.createInteractive');
|
||||
const proBadge = document.createElement('span');
|
||||
proBadge.className = 'widget-pro-badge';
|
||||
proBadge.textContent = t('widgets.proBadge');
|
||||
proBlock.appendChild(proIcon);
|
||||
proBlock.appendChild(proLabel);
|
||||
proBlock.appendChild(proBadge);
|
||||
proBlock.addEventListener('click', () => {
|
||||
openWidgetChatModal({
|
||||
mode: 'create',
|
||||
tier: 'pro',
|
||||
onComplete: (spec) => this.addCustomWidget(spec),
|
||||
});
|
||||
panelsGrid.appendChild(proBlock);
|
||||
}
|
||||
});
|
||||
panelsGrid.appendChild(proBlock);
|
||||
|
||||
if (isProUser()) {
|
||||
const mcpBlock = document.createElement('button');
|
||||
mcpBlock.className = 'add-panel-block mcp-panel-block';
|
||||
mcpBlock.setAttribute('aria-label', t('mcp.connectPanel'));
|
||||
const mcpIcon = document.createElement('span');
|
||||
mcpIcon.className = 'add-panel-block-icon';
|
||||
mcpIcon.textContent = '\u26a1';
|
||||
const mcpLabel = document.createElement('span');
|
||||
mcpLabel.className = 'add-panel-block-label';
|
||||
mcpLabel.textContent = t('mcp.connectPanel');
|
||||
mcpBlock.appendChild(mcpIcon);
|
||||
mcpBlock.appendChild(mcpLabel);
|
||||
mcpBlock.addEventListener('click', () => {
|
||||
openMcpConnectModal({
|
||||
onComplete: (spec) => this.addMcpPanel(spec),
|
||||
});
|
||||
const mcpBlock = document.createElement('button');
|
||||
mcpBlock.className = 'add-panel-block mcp-panel-block';
|
||||
mcpBlock.setAttribute('aria-label', t('mcp.connectPanel'));
|
||||
const mcpIcon = document.createElement('span');
|
||||
mcpIcon.className = 'add-panel-block-icon';
|
||||
mcpIcon.textContent = '\u26a1';
|
||||
const mcpLabel = document.createElement('span');
|
||||
mcpLabel.className = 'add-panel-block-label';
|
||||
mcpLabel.textContent = t('mcp.connectPanel');
|
||||
mcpBlock.appendChild(mcpIcon);
|
||||
mcpBlock.appendChild(mcpLabel);
|
||||
mcpBlock.addEventListener('click', () => {
|
||||
openMcpConnectModal({
|
||||
onComplete: (spec) => this.addMcpPanel(spec),
|
||||
});
|
||||
panelsGrid.appendChild(mcpBlock);
|
||||
}
|
||||
});
|
||||
panelsGrid.appendChild(mcpBlock);
|
||||
|
||||
// Reactively show/hide Pro-only UI blocks based on auth state
|
||||
const proBlocks = [proBlock, mcpBlock];
|
||||
const applyProBlockGating = (isPro: boolean) => {
|
||||
for (const block of proBlocks) {
|
||||
block.style.display = isPro ? '' : 'none';
|
||||
}
|
||||
};
|
||||
applyProBlockGating(
|
||||
isProUser() || getAuthState().user?.role === 'pro'
|
||||
);
|
||||
this.proBlockUnsubscribe = subscribeAuthState((state) => {
|
||||
applyProBlockGating(isProUser() || state.user?.role === 'pro');
|
||||
});
|
||||
|
||||
const bottomGrid = document.getElementById('mapBottomGrid');
|
||||
if (bottomGrid) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { PositionSample } from '@/services/aviation';
|
||||
import { fetchAircraftPositions } from '@/services/aviation';
|
||||
import type { MilitaryFlight } from '@/types';
|
||||
import { isProUser } from '@/services/widget-store';
|
||||
import { getAuthState } from '@/services/auth-state';
|
||||
|
||||
export interface SearchManagerCallbacks {
|
||||
openCountryBriefByCode: (code: string, country: string) => void;
|
||||
@@ -213,8 +214,11 @@ export class SearchManager implements AppModule {
|
||||
this.ctx.searchModal.setOnSelect((result) => this.handleSearchResult(result));
|
||||
this.ctx.searchModal.setOnCommand((cmd) => this.handleCommand(cmd));
|
||||
|
||||
if (isProUser()) {
|
||||
// Always wire flight search — check pro status reactively inside the callback
|
||||
// so mid-session sign-ins get the feature without a page reload.
|
||||
{
|
||||
this.ctx.searchModal.setOnFlightSearch((callsign) => {
|
||||
if (!isProUser() && getAuthState().user?.role !== 'pro') return;
|
||||
fetchAircraftPositions({ callsign }).then((positions) => {
|
||||
if (!this.ctx.searchModal) return;
|
||||
// Deduplicate by callsign: keep the most recently observed entry per callsign.
|
||||
|
||||
57
src/components/AuthHeaderWidget.ts
Normal file
57
src/components/AuthHeaderWidget.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { subscribeAuthState, type AuthSession } from '@/services/auth-state';
|
||||
import { mountUserButton, openSignIn } from '@/services/clerk';
|
||||
|
||||
export class AuthHeaderWidget {
|
||||
private container: HTMLElement;
|
||||
private unsubscribeAuth: (() => void) | null = null;
|
||||
private unmountUserButton: (() => void) | null = null;
|
||||
|
||||
constructor(_onSignInClick?: () => void, _onSettingsClick?: () => void) {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'auth-header-widget';
|
||||
|
||||
this.unsubscribeAuth = subscribeAuthState((state: AuthSession) => {
|
||||
if (state.isPending) {
|
||||
this.container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
this.render(state);
|
||||
});
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.unmountUserButton?.();
|
||||
this.unmountUserButton = null;
|
||||
if (this.unsubscribeAuth) {
|
||||
this.unsubscribeAuth();
|
||||
this.unsubscribeAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
private render(state: AuthSession): void {
|
||||
// Cleanup previous Clerk mount
|
||||
this.unmountUserButton?.();
|
||||
this.unmountUserButton = null;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
if (!state.user) {
|
||||
// Signed out -- show Sign In button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'auth-signin-btn';
|
||||
btn.textContent = 'Sign In';
|
||||
btn.addEventListener('click', () => openSignIn());
|
||||
this.container.appendChild(btn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signed in -- mount Clerk UserButton
|
||||
const userBtnEl = document.createElement('div');
|
||||
userBtnEl.className = 'auth-clerk-user-button';
|
||||
this.container.appendChild(userBtnEl);
|
||||
this.unmountUserButton = mountUserButton(userBtnEl);
|
||||
}
|
||||
}
|
||||
19
src/components/AuthLauncher.ts
Normal file
19
src/components/AuthLauncher.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { openSignIn } from '@/services/clerk';
|
||||
|
||||
/**
|
||||
* Minimal auth launcher -- wraps Clerk.openSignIn().
|
||||
* Replaces the custom OTP modal. Clerk handles all UI.
|
||||
*/
|
||||
export class AuthLauncher {
|
||||
public open(): void {
|
||||
openSignIn();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
// Clerk manages its own modal lifecycle
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// Nothing to clean up -- Clerk manages its own resources
|
||||
}
|
||||
}
|
||||
@@ -12,25 +12,18 @@ export function mountCommunityWidget(): void {
|
||||
widget.className = 'community-widget';
|
||||
widget.innerHTML = `
|
||||
<div class="cw-pill">
|
||||
<div class="cw-dot"></div>
|
||||
<span class="cw-text">${t('components.community.joinDiscussion')}</span>
|
||||
<a class="cw-cta" href="${DISCUSSION_URL}" target="_blank" rel="noopener">${t('components.community.openDiscussion')}</a>
|
||||
<a class="cw-cta" href="${DISCUSSION_URL}" target="_blank" rel="noopener">Join the Discord Community</a>
|
||||
<button class="cw-close" aria-label="${t('common.close')}">×</button>
|
||||
</div>
|
||||
<button class="cw-dismiss">${t('components.community.dontShowAgain')}</button>
|
||||
`;
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(DISMISSED_KEY);
|
||||
widget.classList.add('cw-hiding');
|
||||
setTimeout(() => widget.remove(), 300);
|
||||
};
|
||||
|
||||
widget.querySelector('.cw-close')!.addEventListener('click', dismiss);
|
||||
|
||||
widget.querySelector('.cw-dismiss')!.addEventListener('click', () => {
|
||||
setDismissed(DISMISSED_KEY);
|
||||
dismiss();
|
||||
});
|
||||
|
||||
document.body.appendChild(widget);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { h, replaceChildren, safeHtml } from '../utils/dom-utils';
|
||||
import { trackPanelResized } from '@/services/analytics';
|
||||
import { getAiFlowSettings } from '@/services/ai-flow-settings';
|
||||
import { getSecretState } from '@/services/runtime-config';
|
||||
import { PanelGateReason } from '@/services/panel-gating';
|
||||
|
||||
export interface PanelOptions {
|
||||
id: string;
|
||||
@@ -18,6 +19,10 @@ export interface PanelOptions {
|
||||
defaultRowSpan?: number;
|
||||
}
|
||||
|
||||
const lockSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>`;
|
||||
|
||||
const upgradeSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`;
|
||||
|
||||
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
|
||||
|
||||
function loadPanelSpans(): Record<string, number> {
|
||||
@@ -758,7 +763,6 @@ export class Panel {
|
||||
}
|
||||
this.element.classList.add('panel-is-locked');
|
||||
|
||||
const lockSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>`;
|
||||
const iconEl = h('div', { className: 'panel-locked-icon' });
|
||||
iconEl.innerHTML = lockSvg;
|
||||
|
||||
@@ -786,6 +790,55 @@ export class Panel {
|
||||
replaceChildren(this.content, h('div', { className: 'panel-locked-state' }, ...lockedChildren));
|
||||
}
|
||||
|
||||
public showGatedCta(reason: PanelGateReason, onAction: () => void): void {
|
||||
this._locked = true;
|
||||
this.clearRetryCountdown();
|
||||
|
||||
// Hide elements between header and content (same as showLocked)
|
||||
for (let child = this.header.nextElementSibling; child && child !== this.content; child = child.nextElementSibling) {
|
||||
(child as HTMLElement).style.display = 'none';
|
||||
}
|
||||
this.element.classList.add('panel-is-locked');
|
||||
|
||||
const config: Record<string, { icon: string; desc: string; cta: string }> = {
|
||||
[PanelGateReason.ANONYMOUS]: {
|
||||
icon: lockSvg,
|
||||
desc: t('premium.signInToUnlock'),
|
||||
cta: t('premium.signIn'),
|
||||
},
|
||||
[PanelGateReason.FREE_TIER]: {
|
||||
icon: upgradeSvg,
|
||||
desc: t('premium.upgradeDesc'),
|
||||
cta: t('premium.upgradeToPro'),
|
||||
},
|
||||
};
|
||||
|
||||
const entry = config[reason];
|
||||
if (!entry) return; // PanelGateReason.NONE should never reach here
|
||||
|
||||
const iconEl = h('div', { className: 'panel-locked-icon' });
|
||||
iconEl.innerHTML = entry.icon;
|
||||
|
||||
const descEl = h('div', { className: 'panel-locked-desc' }, entry.desc);
|
||||
|
||||
const ctaBtn = h('button', { type: 'button', className: 'panel-locked-cta' }, entry.cta);
|
||||
ctaBtn.addEventListener('click', onAction);
|
||||
|
||||
replaceChildren(this.content, h('div', { className: 'panel-locked-state' }, iconEl, descEl, ctaBtn));
|
||||
}
|
||||
|
||||
public unlockPanel(): void {
|
||||
if (!this._locked) return;
|
||||
this._locked = false;
|
||||
this.element.classList.remove('panel-is-locked');
|
||||
// Re-show hidden elements
|
||||
for (let child = this.header.nextElementSibling; child && child !== this.content; child = child.nextElementSibling) {
|
||||
(child as HTMLElement).style.display = '';
|
||||
}
|
||||
// Clear the locked state content
|
||||
replaceChildren(this.content);
|
||||
}
|
||||
|
||||
public showRetrying(message?: string, countdownSeconds?: number): void {
|
||||
if (this._locked) return;
|
||||
this.clearRetryCountdown();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { trackGateHit } from '@/services/analytics';
|
||||
|
||||
let bannerEl: HTMLElement | null = null;
|
||||
|
||||
/* TODO: re-enable dismiss after pro launch promotion period
|
||||
@@ -29,6 +31,8 @@ export function showProBanner(container: HTMLElement): void {
|
||||
if (bannerEl) return;
|
||||
if (window.self !== window.top) return;
|
||||
|
||||
trackGateHit('pro-banner');
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'pro-banner';
|
||||
banner.innerHTML = `
|
||||
|
||||
@@ -43,6 +43,9 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
commodities: { name: 'Metals & Materials', enabled: true, priority: 1 },
|
||||
'energy-complex': { name: 'Energy Complex', enabled: true, priority: 1 },
|
||||
markets: { name: 'Markets', enabled: true, priority: 1 },
|
||||
'stock-analysis': { name: 'Stock Analysis', enabled: true, priority: 1, premium: 'locked' as const },
|
||||
'stock-backtest': { name: 'Backtesting', enabled: true, priority: 1, premium: 'locked' as const },
|
||||
'daily-market-brief': { name: 'Daily Market Brief', enabled: true, priority: 1, premium: 'locked' as const },
|
||||
economic: { name: 'Macro Stress', enabled: true, priority: 1 },
|
||||
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
|
||||
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) },
|
||||
|
||||
@@ -145,6 +145,9 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
|
||||
'live-news': { name: 'Market Headlines', enabled: true, priority: 1 },
|
||||
insights: { name: 'AI Market Insights', enabled: true, priority: 1 },
|
||||
markets: { name: 'Live Markets', enabled: true, priority: 1 },
|
||||
'stock-analysis': { name: 'Stock Analysis', enabled: true, priority: 1 },
|
||||
'stock-backtest': { name: 'Backtesting', enabled: true, priority: 1 },
|
||||
'daily-market-brief': { name: 'Daily Market Brief', enabled: true, priority: 1 },
|
||||
'markets-news': { name: 'Markets News', enabled: true, priority: 2 },
|
||||
forex: { name: 'Forex & Currencies', enabled: true, priority: 1 },
|
||||
bonds: { name: 'Fixed Income', enabled: true, priority: 1 },
|
||||
|
||||
@@ -37,6 +37,9 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
|
||||
polymarket: { name: 'Predictions', enabled: true, priority: 1 },
|
||||
commodities: { name: 'Commodities', enabled: true, priority: 1 },
|
||||
markets: { name: 'Markets', enabled: true, priority: 1 },
|
||||
'stock-analysis': { name: 'Stock Analysis', enabled: true, priority: 1 },
|
||||
'stock-backtest': { name: 'Backtesting', enabled: true, priority: 1 },
|
||||
'daily-market-brief': { name: 'Daily Market Brief', enabled: true, priority: 1 },
|
||||
economic: { name: 'Economic Indicators', enabled: true, priority: 1 },
|
||||
finance: { name: 'Financial', enabled: true, priority: 1 },
|
||||
tech: { name: 'Technology', enabled: true, priority: 2 },
|
||||
|
||||
@@ -2723,6 +2723,12 @@
|
||||
"pro": "PRO",
|
||||
"lockedDesc": "Requires a World Monitor license key",
|
||||
"joinWaitlist": "Join Waitlist",
|
||||
"signInToUnlock": "Sign in to unlock premium features",
|
||||
"signIn": "Sign In to Unlock",
|
||||
"verifyEmailToUnlock": "Verify your email to access premium features",
|
||||
"resendVerification": "Resend Verification",
|
||||
"upgradeDesc": "Upgrade to Pro for full access to premium analytics",
|
||||
"upgradeToPro": "Upgrade to Pro",
|
||||
"features": {
|
||||
"orefSirens1": "Real-time Israel missile & rocket alerts",
|
||||
"orefSirens2": "Siren zone mapping with threat classification",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* even if the Umami script has not loaded yet (e.g. ad blockers, SSR).
|
||||
*/
|
||||
|
||||
import { subscribeAuthState } from './auth-state';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-safe event catalog — every event name lives here.
|
||||
// Typo in an event string = compile error.
|
||||
@@ -69,58 +71,50 @@ export async function initAnalytics(): Promise<void> {
|
||||
// by user/plan. Safe to call before Umami script loads.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach user context to all subsequent Umami events for this session.
|
||||
* Call this once after a successful sign-in or on app boot when the user
|
||||
* is already authenticated.
|
||||
*
|
||||
* PR #1812: call from subscribeAuthState() when user is non-null.
|
||||
* Pass user.id and the plan string from the session/subscription object.
|
||||
*/
|
||||
export function identifyUser(userId: string, plan: string): void {
|
||||
window.umami?.identify({ userId, plan });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user identity (call on sign-out so subsequent events are anonymous).
|
||||
*
|
||||
* PR #1812: call from subscribeAuthState() when user becomes null.
|
||||
*/
|
||||
export function clearIdentity(): void {
|
||||
window.umami?.identify({});
|
||||
}
|
||||
|
||||
let _unsubscribeAuthAnalytics: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Stub — wire this in PR #1812.
|
||||
*
|
||||
* Instructions for PR #1812:
|
||||
* 1. Import { identifyUser, clearIdentity, track } from '@/services/analytics'
|
||||
* 2. Replace this body with:
|
||||
*
|
||||
* subscribeAuthState((user) => {
|
||||
* if (user) {
|
||||
* identifyUser(user.id, user.plan ?? 'free');
|
||||
* } else {
|
||||
* clearIdentity();
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* 3. Call initAuthAnalytics() from main.ts after initAnalytics().
|
||||
*
|
||||
* 4. At the sign-in callsite (success callback):
|
||||
* track('sign-in', { method: 'email' }); // or 'google', 'github'
|
||||
*
|
||||
* 5. At the sign-up callsite (success callback):
|
||||
* track('sign-up', { method: 'email' });
|
||||
*
|
||||
* 6. At the sign-out callsite:
|
||||
* track('sign-out');
|
||||
*
|
||||
* 7. Wherever a feature is gated behind auth/pro and the user is blocked:
|
||||
* track('gate-hit', { feature: 'pro-widget' }); // or 'mcp', 'pro-brief', etc.
|
||||
* Call once after initAuthState() to keep Umami identity in sync with
|
||||
* the authenticated user. Re-entrant safe: subsequent calls are no-ops.
|
||||
*/
|
||||
export function initAuthAnalytics(): void {
|
||||
// No-op until PR #1812.
|
||||
if (_unsubscribeAuthAnalytics) return;
|
||||
|
||||
_unsubscribeAuthAnalytics = subscribeAuthState((state) => {
|
||||
if (state.user) {
|
||||
identifyUser(state.user.id, state.user.role);
|
||||
} else {
|
||||
clearIdentity();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function trackSignIn(method: string): void {
|
||||
track('sign-in', { method });
|
||||
}
|
||||
|
||||
export function trackSignUp(method: string): void {
|
||||
track('sign-up', { method });
|
||||
}
|
||||
|
||||
export function trackSignOut(): void {
|
||||
track('sign-out');
|
||||
}
|
||||
|
||||
export function trackGateHit(feature: string): void {
|
||||
track('gate-hit', { feature });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
62
src/services/auth-state.ts
Normal file
62
src/services/auth-state.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { initClerk, getCurrentClerkUser, subscribeClerk } from './clerk';
|
||||
|
||||
/** Minimal user profile exposed to UI components. */
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role: 'free' | 'pro';
|
||||
}
|
||||
|
||||
/** Simplified auth session state for UI consumption. */
|
||||
export interface AuthSession {
|
||||
user: AuthUser | null;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
let _currentSession: AuthSession = { user: null, isPending: true };
|
||||
|
||||
function snapshotSession(): AuthSession {
|
||||
const cu = getCurrentClerkUser();
|
||||
if (!cu) return { user: null, isPending: false };
|
||||
return {
|
||||
user: {
|
||||
id: cu.id,
|
||||
name: cu.name,
|
||||
email: cu.email,
|
||||
image: cu.image,
|
||||
role: cu.plan,
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth state. Call once at app startup before UI subscribes.
|
||||
*/
|
||||
export async function initAuthState(): Promise<void> {
|
||||
await initClerk();
|
||||
_currentSession = snapshotSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to reactive auth state changes.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
export function subscribeAuthState(callback: (state: AuthSession) => void): () => void {
|
||||
// Emit current state immediately
|
||||
callback(_currentSession);
|
||||
|
||||
return subscribeClerk(() => {
|
||||
_currentSession = snapshotSession();
|
||||
callback(_currentSession);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous snapshot of current auth state.
|
||||
*/
|
||||
export function getAuthState(): AuthSession {
|
||||
return _currentSession;
|
||||
}
|
||||
190
src/services/clerk.ts
Normal file
190
src/services/clerk.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Clerk JS initialization and thin wrapper.
|
||||
*
|
||||
* Uses dynamic import so the module is safe to import in Node.js test
|
||||
* environments where @clerk/clerk-js (browser-only) is not available.
|
||||
*/
|
||||
|
||||
import type { Clerk } from '@clerk/clerk-js';
|
||||
|
||||
type ClerkInstance = Clerk;
|
||||
|
||||
const PUBLISHABLE_KEY = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_CLERK_PUBLISHABLE_KEY) as string | undefined;
|
||||
|
||||
let clerkInstance: ClerkInstance | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
const MONO_FONT = "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', 'DejaVu Sans Mono', monospace";
|
||||
|
||||
function getAppearance() {
|
||||
const isDark = typeof document !== 'undefined'
|
||||
? document.documentElement.dataset.theme !== 'light'
|
||||
: true;
|
||||
|
||||
return isDark
|
||||
? {
|
||||
variables: {
|
||||
colorBackground: '#0f0f0f',
|
||||
colorInputBackground: '#141414',
|
||||
colorInputText: '#e8e8e8',
|
||||
colorText: '#e8e8e8',
|
||||
colorTextSecondary: '#aaaaaa',
|
||||
colorPrimary: '#44ff88',
|
||||
colorNeutral: '#e8e8e8',
|
||||
colorDanger: '#ff4444',
|
||||
borderRadius: '4px',
|
||||
fontFamily: MONO_FONT,
|
||||
fontFamilyButtons: MONO_FONT,
|
||||
},
|
||||
elements: {
|
||||
card: { backgroundColor: '#111111', border: '1px solid #2a2a2a', boxShadow: '0 8px 32px rgba(0,0,0,0.6)' },
|
||||
headerTitle: { color: '#e8e8e8' },
|
||||
headerSubtitle: { color: '#aaaaaa' },
|
||||
dividerLine: { backgroundColor: '#2a2a2a' },
|
||||
dividerText: { color: '#666666' },
|
||||
formButtonPrimary: { color: '#000000', fontWeight: '600' },
|
||||
footerActionLink: { color: '#44ff88' },
|
||||
identityPreviewEditButton: { color: '#44ff88' },
|
||||
formFieldLabel: { color: '#cccccc' },
|
||||
formFieldInput: { borderColor: '#2a2a2a' },
|
||||
socialButtonsBlockButton: { borderColor: '#2a2a2a', color: '#e8e8e8', backgroundColor: '#141414' },
|
||||
socialButtonsBlockButtonText: { color: '#e8e8e8' },
|
||||
modalCloseButton: { color: '#888888' },
|
||||
},
|
||||
}
|
||||
: {
|
||||
variables: {
|
||||
colorBackground: '#ffffff',
|
||||
colorInputBackground: '#f8f9fa',
|
||||
colorInputText: '#1a1a1a',
|
||||
colorText: '#1a1a1a',
|
||||
colorTextSecondary: '#555555',
|
||||
colorPrimary: '#16a34a',
|
||||
colorNeutral: '#1a1a1a',
|
||||
colorDanger: '#dc2626',
|
||||
borderRadius: '4px',
|
||||
fontFamily: MONO_FONT,
|
||||
fontFamilyButtons: MONO_FONT,
|
||||
},
|
||||
elements: {
|
||||
card: { backgroundColor: '#ffffff', border: '1px solid #d4d4d4', boxShadow: '0 4px 24px rgba(0,0,0,0.12)' },
|
||||
formButtonPrimary: { color: '#ffffff', fontWeight: '600' },
|
||||
footerActionLink: { color: '#16a34a' },
|
||||
identityPreviewEditButton: { color: '#16a34a' },
|
||||
socialButtonsBlockButton: { borderColor: '#d4d4d4' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Initialize Clerk. Call once at app startup. */
|
||||
export async function initClerk(): Promise<void> {
|
||||
if (clerkInstance) return;
|
||||
if (loadPromise) return loadPromise;
|
||||
if (!PUBLISHABLE_KEY) {
|
||||
console.warn('[clerk] VITE_CLERK_PUBLISHABLE_KEY not set, auth disabled');
|
||||
return;
|
||||
}
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const { Clerk } = await import('@clerk/clerk-js');
|
||||
const clerk = new Clerk(PUBLISHABLE_KEY);
|
||||
await clerk.load({ appearance: getAppearance() });
|
||||
clerkInstance = clerk;
|
||||
} catch (e) {
|
||||
loadPromise = null; // allow retry on next call
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/** Get the initialized Clerk instance. Returns null if not loaded. */
|
||||
export function getClerk(): ClerkInstance | null {
|
||||
return clerkInstance;
|
||||
}
|
||||
|
||||
/** Open the Clerk sign-in modal. */
|
||||
export function openSignIn(): void {
|
||||
clerkInstance?.openSignIn({ appearance: getAppearance() });
|
||||
}
|
||||
|
||||
/** Sign out the current user. */
|
||||
export async function signOut(): Promise<void> {
|
||||
_cachedToken = null;
|
||||
_cachedTokenAt = 0;
|
||||
await clerkInstance?.signOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bearer token for premium API requests.
|
||||
* Uses the 'convex' JWT template which includes the `plan` claim.
|
||||
* Returns null if no active session.
|
||||
*
|
||||
* Tokens are cached for 50s (Clerk tokens expire at 60s) with in-flight
|
||||
* deduplication to prevent concurrent panels from racing against Clerk.
|
||||
*/
|
||||
let _cachedToken: string | null = null;
|
||||
let _cachedTokenAt = 0;
|
||||
let _tokenInflight: Promise<string | null> | null = null;
|
||||
const TOKEN_CACHE_TTL_MS = 50_000;
|
||||
|
||||
export async function getClerkToken(): Promise<string | null> {
|
||||
if (_cachedToken && Date.now() - _cachedTokenAt < TOKEN_CACHE_TTL_MS) {
|
||||
return _cachedToken;
|
||||
}
|
||||
if (_tokenInflight) return _tokenInflight;
|
||||
|
||||
_tokenInflight = (async () => {
|
||||
const session = clerkInstance?.session;
|
||||
if (!session) return null;
|
||||
try {
|
||||
const token = await session.getToken({ template: 'convex' });
|
||||
if (token) {
|
||||
_cachedToken = token;
|
||||
_cachedTokenAt = Date.now();
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
_tokenInflight = null;
|
||||
}
|
||||
})();
|
||||
return _tokenInflight;
|
||||
}
|
||||
|
||||
/** Get current Clerk user metadata. Returns null if signed out. */
|
||||
export function getCurrentClerkUser(): { id: string; name: string; email: string; image: string | null; plan: 'free' | 'pro' } | null {
|
||||
const user = clerkInstance?.user;
|
||||
if (!user) return null;
|
||||
const plan = (user.publicMetadata as Record<string, unknown>)?.plan;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName ?? user.firstName ?? 'User',
|
||||
email: user.primaryEmailAddress?.emailAddress ?? '',
|
||||
image: user.imageUrl ?? null,
|
||||
plan: plan === 'pro' ? 'pro' : 'free',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Clerk auth state changes.
|
||||
* Returns unsubscribe function.
|
||||
*/
|
||||
export function subscribeClerk(callback: () => void): () => void {
|
||||
if (!clerkInstance) return () => {};
|
||||
return clerkInstance.addListener(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Clerk's UserButton component into a DOM element.
|
||||
* Returns an unmount function.
|
||||
*/
|
||||
export function mountUserButton(el: HTMLDivElement): () => void {
|
||||
if (!clerkInstance) return () => {};
|
||||
clerkInstance.mountUserButton(el, {
|
||||
afterSignOutUrl: window.location.href,
|
||||
appearance: getAppearance(),
|
||||
});
|
||||
return () => clerkInstance?.unmountUserButton(el);
|
||||
}
|
||||
32
src/services/panel-gating.ts
Normal file
32
src/services/panel-gating.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AuthSession } from './auth-state';
|
||||
import { isDesktopRuntime } from './runtime';
|
||||
import { getSecretState } from './runtime-config';
|
||||
|
||||
export enum PanelGateReason {
|
||||
NONE = 'none', // show content (pro user, or desktop with API key, or non-premium panel)
|
||||
ANONYMOUS = 'anonymous', // "Sign In to Unlock"
|
||||
FREE_TIER = 'free_tier', // "Upgrade to Pro"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine gating reason for a premium panel given current auth state.
|
||||
* Desktop with valid API key always bypasses auth gating (backward compat).
|
||||
* Non-premium panels always return NONE.
|
||||
*/
|
||||
export function getPanelGateReason(
|
||||
authState: AuthSession,
|
||||
isPremium: boolean,
|
||||
): PanelGateReason {
|
||||
// Non-premium panels are never gated
|
||||
if (!isPremium) return PanelGateReason.NONE;
|
||||
|
||||
// Desktop with API key: always unlocked (backward compat)
|
||||
if (isDesktopRuntime() && getSecretState('WORLDMONITOR_API_KEY').present) {
|
||||
return PanelGateReason.NONE;
|
||||
}
|
||||
|
||||
// Web gating based on auth state
|
||||
if (!authState.user) return PanelGateReason.ANONYMOUS;
|
||||
if (authState.user.role !== 'pro') return PanelGateReason.FREE_TIER;
|
||||
return PanelGateReason.NONE;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SITE_VARIANT } from '@/config/variant';
|
||||
import { getClerkToken } from '@/services/clerk';
|
||||
|
||||
const ENV = (() => {
|
||||
try {
|
||||
@@ -732,6 +733,8 @@ export function installRuntimeFetchPatch(): void {
|
||||
(window as unknown as Record<string, unknown>).__wmFetchPatched = true;
|
||||
}
|
||||
|
||||
import { PREMIUM_RPC_PATHS as WEB_PREMIUM_API_PATHS } from '@/shared/premium-paths';
|
||||
|
||||
const ALLOWED_REDIRECT_HOSTS = /^https:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*worldmonitor\.app(:\d+)?$/;
|
||||
|
||||
function isAllowedRedirectTarget(url: string): boolean {
|
||||
@@ -777,20 +780,42 @@ export function installWebApiRedirect(): void {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For premium API paths, inject an Authorization: Bearer header when the
|
||||
* user has an active session and no existing auth header is present.
|
||||
* Returns the original init unchanged for non-premium paths (zero overhead).
|
||||
*/
|
||||
const enrichInitForPremium = async (pathWithQuery: string, init?: RequestInit): Promise<RequestInit | undefined> => {
|
||||
const path = pathWithQuery.split('?')[0] ?? pathWithQuery;
|
||||
if (!WEB_PREMIUM_API_PATHS.has(path)) return init;
|
||||
const token = await getClerkToken();
|
||||
if (!token) return init;
|
||||
const headers = new Headers(init?.headers);
|
||||
// Don't overwrite existing auth headers (API key users keep their flow)
|
||||
if (headers.has('Authorization') || headers.has('X-WorldMonitor-Key')) return init;
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
return { ...init, headers };
|
||||
};
|
||||
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
if (typeof input === 'string' && shouldRedirectPath(input)) {
|
||||
return fetchWithRedirectFallback(`${API_BASE}${input}`, input, init);
|
||||
const enriched = await enrichInitForPremium(input, init);
|
||||
return fetchWithRedirectFallback(`${API_BASE}${input}`, input, enriched);
|
||||
}
|
||||
if (input instanceof URL && input.origin === window.location.origin && shouldRedirectPath(`${input.pathname}${input.search}`)) {
|
||||
return fetchWithRedirectFallback(new URL(`${API_BASE}${input.pathname}${input.search}`), input, init);
|
||||
const pathAndSearch = `${input.pathname}${input.search}`;
|
||||
const enriched = await enrichInitForPremium(pathAndSearch, init);
|
||||
return fetchWithRedirectFallback(new URL(`${API_BASE}${pathAndSearch}`), input, enriched);
|
||||
}
|
||||
if (input instanceof Request) {
|
||||
const u = new URL(input.url);
|
||||
if (u.origin === window.location.origin && shouldRedirectPath(`${u.pathname}${u.search}`)) {
|
||||
const pathAndSearch = `${u.pathname}${u.search}`;
|
||||
const enriched = await enrichInitForPremium(pathAndSearch, init);
|
||||
return fetchWithRedirectFallback(
|
||||
new Request(`${API_BASE}${u.pathname}${u.search}`, input),
|
||||
new Request(`${API_BASE}${pathAndSearch}`, input),
|
||||
input.clone(),
|
||||
init,
|
||||
enriched,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loadFromStorage, saveToStorage } from '@/utils';
|
||||
import { sanitizeWidgetHtml } from '@/utils/widget-sanitizer';
|
||||
import { getAuthState } from '@/services/auth-state';
|
||||
|
||||
const STORAGE_KEY = 'wm-custom-widgets';
|
||||
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
|
||||
@@ -150,7 +151,7 @@ export function isProWidgetEnabled(): boolean {
|
||||
}
|
||||
|
||||
export function isProUser(): boolean {
|
||||
return isWidgetFeatureEnabled() || isProWidgetEnabled();
|
||||
return isWidgetFeatureEnabled() || isProWidgetEnabled() || getAuthState().user?.role === 'pro';
|
||||
}
|
||||
|
||||
export function getProWidgetKey(): string {
|
||||
|
||||
12
src/shared/premium-paths.ts
Normal file
12
src/shared/premium-paths.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Premium RPC paths that require either an API key or a Pro session.
|
||||
*
|
||||
* Single source of truth consumed by both the server gateway (auth enforcement)
|
||||
* and the web client runtime (token injection).
|
||||
*/
|
||||
export const PREMIUM_RPC_PATHS = new Set<string>([
|
||||
'/api/market/v1/analyze-stock',
|
||||
'/api/market/v1/get-stock-analysis-history',
|
||||
'/api/market/v1/backtest-stock',
|
||||
'/api/market/v1/list-stored-stock-backtests',
|
||||
]);
|
||||
@@ -20182,6 +20182,341 @@ body.has-breaking-alert .panels-grid {
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Auth Modal ── */
|
||||
.auth-modal-content {
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.auth-modal-close:hover { color: var(--text); }
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-form input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-submit-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.auth-submit-btn:hover { opacity: 0.9; }
|
||||
.auth-submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
color: #e74c3c;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.auth-otp-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.auth-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.auth-link-btn:hover { color: var(--text); }
|
||||
.auth-link-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Auth Header Widget ── */
|
||||
.auth-header-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-signin-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.auth-signin-btn:hover { opacity: 0.85; }
|
||||
|
||||
.auth-avatar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.auth-avatar-initials {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 240px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.18);
|
||||
padding: 12px;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.auth-dropdown.open { display: block; }
|
||||
|
||||
.auth-dropdown-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-dropdown-avatar-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-dropdown-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auth-dropdown-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.auth-dropdown-email {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.auth-tier-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.auth-tier-badge-pro {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.auth-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.auth-dropdown-item {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.auth-dropdown-item:hover { background: var(--bg); }
|
||||
.auth-dropdown-item svg { flex-shrink: 0; opacity: 0.7; }
|
||||
|
||||
.auth-signout-item {
|
||||
color: var(--text);
|
||||
}
|
||||
.auth-signout-item:hover {
|
||||
background: var(--bg);
|
||||
color: #e85b5b;
|
||||
}
|
||||
.auth-signout-item:hover svg { opacity: 1; }
|
||||
|
||||
/* Profile edit inline form */
|
||||
.auth-profile-edit {
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.auth-profile-edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-profile-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.auth-profile-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.auth-profile-input:focus { border-color: var(--accent); }
|
||||
|
||||
.auth-profile-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-profile-save-btn,
|
||||
.auth-profile-cancel-btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.12s;
|
||||
border: none;
|
||||
}
|
||||
.auth-profile-save-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.auth-profile-save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.auth-profile-cancel-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.auth-profile-msg {
|
||||
font-size: 12px;
|
||||
min-height: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.auth-profile-msg-ok { color: #4caf7d; }
|
||||
.auth-profile-msg-err { color: #e85b5b; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.widget-chat-modal {
|
||||
width: min(96vw, 720px);
|
||||
|
||||
291
tests/auth-session.test.mts
Normal file
291
tests/auth-session.test.mts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Tests for server/auth-session.ts (Clerk JWT verification with jose)
|
||||
*
|
||||
* Covers the full validation matrix:
|
||||
* - Returns invalid when CLERK_JWT_ISSUER_DOMAIN is not set (fail-closed)
|
||||
* - Valid Pro token → { valid: true, role: 'pro' }
|
||||
* - Valid Free token → { valid: true, role: 'free' }
|
||||
* - Missing plan claim → defaults to 'free'
|
||||
* - Expired token → { valid: false }
|
||||
* - Invalid signature → { valid: false }
|
||||
* - Wrong audience → { valid: false }
|
||||
* - JWKS resolver is reused across calls (module-scoped, not per-request)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 1: fail-closed when CLERK_JWT_ISSUER_DOMAIN is NOT set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Clear env BEFORE dynamic import so the module captures an empty domain
|
||||
delete process.env.CLERK_JWT_ISSUER_DOMAIN;
|
||||
|
||||
let validateBearerTokenNoEnv: (token: string) => Promise<{ valid: boolean; userId?: string; role?: string }>;
|
||||
|
||||
before(async () => {
|
||||
const mod = await import('../server/auth-session.ts');
|
||||
validateBearerTokenNoEnv = mod.validateBearerToken;
|
||||
});
|
||||
|
||||
describe('validateBearerToken (no CLERK_JWT_ISSUER_DOMAIN)', () => {
|
||||
it('returns invalid when CLERK_JWT_ISSUER_DOMAIN is not set', async () => {
|
||||
const result = await validateBearerTokenNoEnv('some-random-token');
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.userId, undefined);
|
||||
assert.equal(result.role, undefined);
|
||||
});
|
||||
|
||||
it('returns invalid for empty token', async () => {
|
||||
const result = await validateBearerTokenNoEnv('');
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('returns SessionResult shape with expected fields', async () => {
|
||||
const result = await validateBearerTokenNoEnv('test');
|
||||
assert.equal(typeof result.valid, 'boolean');
|
||||
if (!result.valid) {
|
||||
assert.equal(result.userId, undefined);
|
||||
assert.equal(result.role, undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 2: full JWT validation with self-signed keys + local JWKS server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateBearerToken (with JWKS)', () => {
|
||||
let privateKey: CryptoKey;
|
||||
let jwksServer: Server;
|
||||
let jwksPort: number;
|
||||
let validateBearerToken: (token: string) => Promise<{ valid: boolean; userId?: string; role?: string }>;
|
||||
|
||||
// Separate key pair for "wrong key" tests
|
||||
let wrongPrivateKey: CryptoKey;
|
||||
|
||||
before(async () => {
|
||||
// Generate an RSA key pair for signing JWTs
|
||||
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
|
||||
privateKey = pk;
|
||||
|
||||
const { privateKey: wpk } = await generateKeyPair('RS256');
|
||||
wrongPrivateKey = wpk;
|
||||
|
||||
// Export public key as JWK for the JWKS endpoint
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
publicJwk.kid = 'test-key-1';
|
||||
publicJwk.alg = 'RS256';
|
||||
publicJwk.use = 'sig';
|
||||
const jwks = { keys: [publicJwk] };
|
||||
|
||||
// Start a local HTTP server serving the JWKS
|
||||
jwksServer = createServer((req, res) => {
|
||||
if (req.url === '/.well-known/jwks.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(jwks));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
jwksServer.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = jwksServer.address();
|
||||
jwksPort = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
|
||||
// Set the issuer domain to the local JWKS server and re-import the module
|
||||
// (fresh import since the module caches JWKS at first use)
|
||||
process.env.CLERK_JWT_ISSUER_DOMAIN = `http://127.0.0.1:${jwksPort}`;
|
||||
|
||||
// Dynamic import with cache-busting query param to get a fresh module instance
|
||||
const mod = await import(`../server/auth-session.ts?t=${Date.now()}`);
|
||||
validateBearerToken = mod.validateBearerToken;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
jwksServer?.close();
|
||||
delete process.env.CLERK_JWT_ISSUER_DOMAIN;
|
||||
});
|
||||
|
||||
/** Helper to sign a JWT with the test private key */
|
||||
function signToken(claims: Record<string, unknown>, opts?: { expiresIn?: string; key?: CryptoKey }) {
|
||||
const builder = new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer(`http://127.0.0.1:${jwksPort}`)
|
||||
.setAudience('convex')
|
||||
.setSubject(claims.sub as string ?? 'user_test123')
|
||||
.setIssuedAt();
|
||||
|
||||
if (opts?.expiresIn) {
|
||||
builder.setExpirationTime(opts.expiresIn);
|
||||
} else {
|
||||
builder.setExpirationTime('1h');
|
||||
}
|
||||
|
||||
return builder.sign(opts?.key ?? privateKey);
|
||||
}
|
||||
|
||||
it('accepts a valid Pro token', async () => {
|
||||
const token = await signToken({ sub: 'user_pro1', plan: 'pro' });
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.userId, 'user_pro1');
|
||||
assert.equal(result.role, 'pro');
|
||||
});
|
||||
|
||||
it('accepts a valid Free token and normalizes role to free', async () => {
|
||||
const token = await signToken({ sub: 'user_free1', plan: 'free' });
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.userId, 'user_free1');
|
||||
assert.equal(result.role, 'free');
|
||||
});
|
||||
|
||||
it('treats missing plan claim as free', async () => {
|
||||
const token = await signToken({ sub: 'user_noplan' });
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.userId, 'user_noplan');
|
||||
assert.equal(result.role, 'free');
|
||||
});
|
||||
|
||||
it('treats unknown plan value as free', async () => {
|
||||
const token = await signToken({ sub: 'user_weird', plan: 'enterprise' });
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.userId, 'user_weird');
|
||||
assert.equal(result.role, 'free');
|
||||
});
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const token = await new SignJWT({ sub: 'user_expired', plan: 'pro' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer(`http://127.0.0.1:${jwksPort}`)
|
||||
.setAudience('convex')
|
||||
.setSubject('user_expired')
|
||||
.setIssuedAt(Math.floor(Date.now() / 1000) - 7200) // 2h ago
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // expired 1h ago
|
||||
.sign(privateKey);
|
||||
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('rejects a token signed with wrong key', async () => {
|
||||
const token = await signToken({ sub: 'user_wrongkey', plan: 'pro' }, { key: wrongPrivateKey });
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('rejects a token with wrong audience', async () => {
|
||||
const token = await new SignJWT({ sub: 'user_wrongaud', plan: 'pro' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer(`http://127.0.0.1:${jwksPort}`)
|
||||
.setAudience('wrong-audience')
|
||||
.setSubject('user_wrongaud')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey);
|
||||
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('rejects a token with wrong issuer', async () => {
|
||||
const token = await new SignJWT({ sub: 'user_wrongiss', plan: 'pro' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer('https://wrong-issuer.example.com')
|
||||
.setAudience('convex')
|
||||
.setSubject('user_wrongiss')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey);
|
||||
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('rejects a token with no sub claim', async () => {
|
||||
const token = await new SignJWT({ plan: 'pro' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer(`http://127.0.0.1:${jwksPort}`)
|
||||
.setAudience('convex')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(privateKey);
|
||||
|
||||
const result = await validateBearerToken(token);
|
||||
assert.equal(result.valid, false);
|
||||
});
|
||||
|
||||
it('reuses the JWKS resolver across calls (not per-request)', async () => {
|
||||
// Make two calls — both should succeed using the same cached JWKS
|
||||
const token1 = await signToken({ sub: 'user_a', plan: 'pro' });
|
||||
const token2 = await signToken({ sub: 'user_b', plan: 'free' });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
validateBearerToken(token1),
|
||||
validateBearerToken(token2),
|
||||
]);
|
||||
|
||||
assert.equal(r1.valid, true);
|
||||
assert.equal(r1.role, 'pro');
|
||||
assert.equal(r2.valid, true);
|
||||
assert.equal(r2.role, 'free');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 3: CORS origin matching -- pure logic (independent of auth provider)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CORS origin matching (convex/http.ts)', () => {
|
||||
function matchOrigin(origin: string, pattern: string): boolean {
|
||||
if (pattern.startsWith('*.')) {
|
||||
return origin.endsWith(pattern.slice(1));
|
||||
}
|
||||
return origin === pattern;
|
||||
}
|
||||
|
||||
function allowedOrigin(origin: string | null, trusted: string[]): string | null {
|
||||
if (!origin) return null;
|
||||
return trusted.some((p) => matchOrigin(origin, p)) ? origin : null;
|
||||
}
|
||||
|
||||
const TRUSTED = [
|
||||
'https://worldmonitor.app',
|
||||
'*.worldmonitor.app',
|
||||
'http://localhost:3000',
|
||||
];
|
||||
|
||||
it('allows exact match', () => {
|
||||
assert.equal(allowedOrigin('https://worldmonitor.app', TRUSTED), 'https://worldmonitor.app');
|
||||
});
|
||||
|
||||
it('allows wildcard subdomain', () => {
|
||||
const origin = 'https://preview-xyz.worldmonitor.app';
|
||||
assert.equal(allowedOrigin(origin, TRUSTED), origin);
|
||||
});
|
||||
|
||||
it('allows localhost', () => {
|
||||
assert.equal(allowedOrigin('http://localhost:3000', TRUSTED), 'http://localhost:3000');
|
||||
});
|
||||
|
||||
it('blocks unknown origin', () => {
|
||||
assert.equal(allowedOrigin('https://evil.com', TRUSTED), null);
|
||||
});
|
||||
|
||||
it('blocks partial domain match', () => {
|
||||
assert.equal(allowedOrigin('https://attackerworldmonitor.app', TRUSTED), null);
|
||||
});
|
||||
|
||||
it('returns null for null origin -- no ACAO header emitted', () => {
|
||||
assert.equal(allowedOrigin(null, TRUSTED), null);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,24 @@ describe('security header guardrails', () => {
|
||||
assert.ok(scriptSrc.includes("'self'"), 'CSP script-src must include self');
|
||||
});
|
||||
|
||||
it('CSP script-src includes Clerk origin for auth UI', () => {
|
||||
const csp = getHeaderValue('Content-Security-Policy');
|
||||
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
|
||||
assert.ok(
|
||||
scriptSrc.includes('clerk.accounts.dev') || scriptSrc.includes('clerk.worldmonitor.app'),
|
||||
'CSP script-src must include Clerk origin for auth UI to load'
|
||||
);
|
||||
});
|
||||
|
||||
it('CSP frame-src includes Clerk origin for auth modals', () => {
|
||||
const csp = getHeaderValue('Content-Security-Policy');
|
||||
const frameSrc = csp.match(/frame-src\s+([^;]+)/)?.[1] ?? '';
|
||||
assert.ok(
|
||||
frameSrc.includes('clerk.accounts.dev') || frameSrc.includes('clerk.worldmonitor.app'),
|
||||
'CSP frame-src must include Clerk origin for sign-in modal'
|
||||
);
|
||||
});
|
||||
|
||||
it('security.txt exists in public/.well-known/', () => {
|
||||
const secTxt = readFileSync(resolve(__dirname, '../public/.well-known/security.txt'), 'utf-8');
|
||||
assert.match(secTxt, /^Contact:/m, 'security.txt must have a Contact field');
|
||||
|
||||
@@ -484,6 +484,10 @@ async function loadRuntimeConfigPanel() {
|
||||
export const PLAINTEXT_KEYS = new Set();
|
||||
export const MASKED_SENTINEL = '***';
|
||||
`],
|
||||
['panel-gating-stub', `
|
||||
export const PanelGateReason = { NONE: 'none', ANONYMOUS: 'anonymous', UNVERIFIED: 'unverified', FREE_TIER: 'free_tier' };
|
||||
export function getPanelGateReason() { return PanelGateReason.NONE; }
|
||||
`],
|
||||
]);
|
||||
|
||||
const aliasMap = new Map([
|
||||
@@ -500,6 +504,7 @@ async function loadRuntimeConfigPanel() {
|
||||
['@/utils/sanitize', 'sanitize-stub'],
|
||||
['@/services/ollama-models', 'ollama-models-stub'],
|
||||
['@/services/settings-constants', 'settings-constants-stub'],
|
||||
['@/services/panel-gating', 'panel-gating-stub'],
|
||||
]);
|
||||
|
||||
const plugin = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { afterEach, describe, it, before, after } from 'node:test';
|
||||
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
||||
|
||||
import { createDomainGateway } from '../server/gateway.ts';
|
||||
|
||||
@@ -11,7 +13,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('premium stock gateway enforcement', () => {
|
||||
it('allows trusted browser origins without a key (client-side isProUser gate controls access)', async () => {
|
||||
it('requires credentials for premium endpoints regardless of origin', async () => {
|
||||
const handler = createDomainGateway([
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -27,11 +29,11 @@ describe('premium stock gateway enforcement', () => {
|
||||
|
||||
process.env.WORLDMONITOR_VALID_KEYS = 'real-key-123';
|
||||
|
||||
// Trusted browser origin (worldmonitor.app) — no key needed; isProUser() is the client-side gate
|
||||
// Trusted browser origin without credentials — 401 (Origin is spoofable, not a security boundary)
|
||||
const browserNoKey = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: { Origin: 'https://worldmonitor.app' },
|
||||
}));
|
||||
assert.equal(browserNoKey.status, 200);
|
||||
assert.equal(browserNoKey.status, 401);
|
||||
|
||||
// Trusted browser origin with a valid key — also allowed
|
||||
const browserWithKey = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
@@ -55,3 +57,119 @@ describe('premium stock gateway enforcement', () => {
|
||||
assert.equal(publicAllowed.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token auth path for premium endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('premium stock gateway bearer token auth', () => {
|
||||
let privateKey: CryptoKey;
|
||||
let wrongPrivateKey: CryptoKey;
|
||||
let jwksServer: Server;
|
||||
let jwksPort: number;
|
||||
let handler: (req: Request) => Promise<Response>;
|
||||
|
||||
before(async () => {
|
||||
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
|
||||
privateKey = pk;
|
||||
|
||||
const { privateKey: wpk } = await generateKeyPair('RS256');
|
||||
wrongPrivateKey = wpk;
|
||||
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
publicJwk.kid = 'test-key-1';
|
||||
publicJwk.alg = 'RS256';
|
||||
publicJwk.use = 'sig';
|
||||
const jwks = { keys: [publicJwk] };
|
||||
|
||||
jwksServer = createServer((req, res) => {
|
||||
if (req.url === '/.well-known/jwks.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(jwks));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
jwksServer.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const addr = jwksServer.address();
|
||||
jwksPort = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
|
||||
process.env.CLERK_JWT_ISSUER_DOMAIN = `http://127.0.0.1:${jwksPort}`;
|
||||
process.env.WORLDMONITOR_VALID_KEYS = 'real-key-123';
|
||||
|
||||
handler = createDomainGateway([
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/market/v1/analyze-stock',
|
||||
handler: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/market/v1/list-market-quotes',
|
||||
handler: async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
jwksServer?.close();
|
||||
delete process.env.CLERK_JWT_ISSUER_DOMAIN;
|
||||
});
|
||||
|
||||
function signToken(claims: Record<string, unknown>, opts?: { key?: CryptoKey; audience?: string }) {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
|
||||
.setIssuer(`http://127.0.0.1:${jwksPort}`)
|
||||
.setAudience(opts?.audience ?? 'convex')
|
||||
.setSubject(claims.sub as string ?? 'user_test')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(opts?.key ?? privateKey);
|
||||
}
|
||||
|
||||
it('accepts valid Pro bearer token on premium endpoint → 200', async () => {
|
||||
const token = await signToken({ sub: 'user_pro', plan: 'pro' });
|
||||
const res = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: {
|
||||
Origin: 'https://worldmonitor.app',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}));
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it('rejects Free bearer token on premium endpoint → 403', async () => {
|
||||
const token = await signToken({ sub: 'user_free', plan: 'free' });
|
||||
const res = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: {
|
||||
Origin: 'https://worldmonitor.app',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}));
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json() as { error: string };
|
||||
assert.match(body.error, /[Pp]ro/);
|
||||
});
|
||||
|
||||
it('rejects invalid/expired bearer token on premium endpoint → 401', async () => {
|
||||
const token = await signToken({ sub: 'user_bad', plan: 'pro' }, { key: wrongPrivateKey });
|
||||
const res = await handler(new Request('https://worldmonitor.app/api/market/v1/analyze-stock?symbol=AAPL', {
|
||||
headers: {
|
||||
Origin: 'https://worldmonitor.app',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}));
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('public routes are unaffected by absence of auth header', async () => {
|
||||
const res = await handler(new Request('https://worldmonitor.app/api/market/v1/list-market-quotes?symbols=AAPL', {
|
||||
headers: { Origin: 'https://worldmonitor.app' },
|
||||
}));
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
|
||||
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" },
|
||||
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com https://abacus.worldmonitor.app; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://challenges.cloudflare.com https://vercel.live https://*.vercel.app; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app https://vercel.live https://*.vercel.app; base-uri 'self'; object-src 'none'; form-action 'self'" }
|
||||
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com https://*.clerk.accounts.dev https://abacus.worldmonitor.app; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://challenges.cloudflare.com https://*.clerk.accounts.dev https://vercel.live https://*.vercel.app; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app https://vercel.live https://*.vercel.app; base-uri 'self'; object-src 'none'; form-action 'self'" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user