mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user