feat(notifications): gate all endpoints behind PRO entitlement (#2852)

* feat(notifications): gate all notification endpoints behind PRO entitlement

Notifications were accessible to any signed-in user. Now all notification
API endpoints require tier >= 1 (Pro plan) with proper upgrade messaging
and checkout flow integration.

Backend: api/notification-channels.ts, api/notify.ts, api/slack/oauth/start.ts,
api/discord/oauth/start.ts all check getEntitlements() and return 403 with
upgradeUrl for free users.

Frontend: preferences-content.ts shows upgrade CTA with Dodo checkout overlay
instead of notification settings for non-Pro users.

* fix(notifications): use hasTier(1) and handle null entitlement state

Address Greptile review comments:
1. Replace isEntitled() with hasTier(1) to match backend tier check exactly
2. When entitlement state is null (not loaded yet), show full notification
   panel instead of upgrade CTA (backend enforces anyway)
This commit is contained in:
Elie Habib
2026-04-09 09:18:19 +04:00
committed by GitHub
parent c35b3618e0
commit 6d9e7d6f6b
5 changed files with 48 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders } from '../../_cors.js';
import { validateBearerToken } from '../../../server/auth-session';
import { getEntitlements } from '../../../server/_shared/entitlement-check';
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID ?? '';
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI ?? '';
@@ -47,6 +48,11 @@ export default async function handler(req: Request): Promise<Response> {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } });
}
const ent = await getEntitlements(session.userId);
if (!ent || ent.features.tier < 1) {
return new Response(JSON.stringify({ error: 'pro_required', message: 'Discord notifications are available on the Pro plan.', upgradeUrl: 'https://worldmonitor.app/pro' }), { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } });
}
// Generate one-time state token (20 random bytes → base64url)
const stateBytes = crypto.getRandomValues(new Uint8Array(20));
const state = btoa(String.fromCharCode(...stateBytes))

View File

@@ -16,6 +16,7 @@ import { getCorsHeaders } from './_cors.js';
// @ts-expect-error — JS module, no declaration file
import { captureEdgeException } from './_sentry-edge.js';
import { validateBearerToken } from '../server/auth-session';
import { getEntitlements } from '../server/_shared/entitlement-check';
// Prefer explicit CONVEX_SITE_URL; fall back to deriving from CONVEX_URL (same pattern as notification-relay.cjs).
const CONVEX_SITE_URL =
@@ -145,6 +146,15 @@ export default async function handler(req: Request, ctx: { waitUntil: (p: Promis
const session = await validateBearerToken(token);
if (!session.valid || !session.userId) return json({ error: 'Unauthorized' }, 401, corsHeaders);
const ent = await getEntitlements(session.userId);
if (!ent || ent.features.tier < 1) {
return json({
error: 'pro_required',
message: 'Real-time alerts are available on the Pro plan.',
upgradeUrl: 'https://worldmonitor.app/pro',
}, 403, corsHeaders);
}
if (!CONVEX_SITE_URL || !RELAY_SHARED_SECRET) {
return json({ error: 'Service unavailable' }, 503, corsHeaders);
}

View File

@@ -14,6 +14,7 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// @ts-expect-error — JS module, no declaration file
import { jsonResponse } from './_json-response.js';
import { validateBearerToken } from '../server/auth-session';
import { getEntitlements } from '../server/_shared/entitlement-check';
export default async function handler(req: Request): Promise<Response> {
if (isDisallowedOrigin(req)) {
@@ -41,6 +42,11 @@ export default async function handler(req: Request): Promise<Response> {
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
}
const ent = await getEntitlements(session.userId);
if (!ent || ent.features.tier < 1) {
return jsonResponse({ error: 'pro_required', message: 'Event publishing is available on the Pro plan.', upgradeUrl: 'https://worldmonitor.app/pro' }, 403, cors);
}
let body: { eventType?: unknown; payload?: unknown; severity?: unknown; variant?: unknown };
try {
body = await req.json();

View File

@@ -14,6 +14,7 @@ export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders } from '../../_cors.js';
import { validateBearerToken } from '../../../server/auth-session';
import { getEntitlements } from '../../../server/_shared/entitlement-check';
const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID ?? '';
const SLACK_REDIRECT_URI = process.env.SLACK_REDIRECT_URI ?? '';
@@ -47,6 +48,11 @@ export default async function handler(req: Request): Promise<Response> {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json', ...corsHeaders } });
}
const ent = await getEntitlements(session.userId);
if (!ent || ent.features.tier < 1) {
return new Response(JSON.stringify({ error: 'pro_required', message: 'Slack notifications are available on the Pro plan.', upgradeUrl: 'https://worldmonitor.app/pro' }), { status: 403, headers: { 'Content-Type': 'application/json', ...corsHeaders } });
}
// Generate one-time state token (20 random bytes → base64url)
const stateBytes = crypto.getRandomValues(new Uint8Array(20));
const state = btoa(String.fromCharCode(...stateBytes))

View File

@@ -26,6 +26,7 @@ import {
type DigestMode,
} from '@/services/notification-channels';
import { getCurrentClerkUser } from '@/services/clerk';
import { hasTier, getEntitlementState } from '@/services/entitlements';
import { SITE_VARIANT } from '@/config/variant';
// When VITE_QUIET_HOURS_BATCH_ENABLED=0 the relay does not honour batch_on_wake.
// Hide that option so users cannot select a mode that silently behaves as critical_only.
@@ -356,10 +357,17 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
</a>`;
html += `</div></details>`;
// ── Notifications group (web-only, signed-in) ──
// ── Notifications group (web-only, signed-in, PRO only) ──
if (!host.isDesktopApp) {
if (!host.isSignedIn) {
html += `<div class="ai-flow-toggle-desc us-notif-signin">Sign in to link notification channels.</div>`;
} else if (getEntitlementState() !== null && !hasTier(1)) {
html += `<details class="wm-pref-group">`;
html += `<summary>Notifications <span class="panel-toggle-pro-badge">PRO</span></summary>`;
html += `<div class="wm-pref-group-content">`;
html += `<div class="ai-flow-toggle-desc">Get real-time intelligence alerts delivered to Telegram, Slack, Discord, and Email with configurable sensitivity, quiet hours, and digest scheduling.</div>`;
html += `<button type="button" class="panel-locked-cta" id="usNotifUpgradeBtn">Upgrade to Pro</button>`;
html += `</div></details>`;
} else {
html += `<details class="wm-pref-group" id="usNotifGroup">`;
html += `<summary>Notifications</summary>`;
@@ -619,7 +627,17 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
if (!host.isDesktopApp) updateAiStatus(container);
// ── Notifications section ──
if (!host.isDesktopApp && host.isSignedIn) {
if (!host.isDesktopApp && host.isSignedIn && getEntitlementState() !== null && !hasTier(1)) {
const upgradeBtn = container.querySelector<HTMLButtonElement>('#usNotifUpgradeBtn');
if (upgradeBtn) {
upgradeBtn.addEventListener('click', () => {
import('@/services/checkout').then(m => import('@/config/products').then(p => m.startCheckout(p.DEFAULT_UPGRADE_PRODUCT))).catch(() => {
window.open('https://worldmonitor.app/pro', '_blank');
});
}, { signal });
}
}
if (!host.isDesktopApp && host.isSignedIn && (getEntitlementState() === null || hasTier(1))) {
let notifPollInterval: ReturnType<typeof setInterval> | null = null;
function clearNotifPoll(): void {