From c89b63bd974bd93e761172175f88c61ee15795b4 Mon Sep 17 00:00:00 2001 From: nate Date: Wed, 18 Mar 2026 22:40:45 +0400 Subject: [PATCH] feat: implement free/pro plan system with monitor and interval limits --- apps/api/src/db.ts | 2 ++ apps/api/src/routes/auth.ts | 24 +++++++++------ apps/api/src/routes/monitors.ts | 31 +++++++++++++++++--- apps/api/src/utils/plans.ts | 25 ++++++++++++++++ apps/web/src/db.ts | 2 ++ apps/web/src/routes/auth.ts | 10 +++---- apps/web/src/routes/dashboard.ts | 11 +++---- apps/web/src/views/detail.ejs | 2 +- apps/web/src/views/new.ejs | 2 +- apps/web/src/views/partials/monitor-form.ejs | 5 +++- apps/web/src/views/settings.ejs | 31 ++++++++++++++++++++ 11 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/utils/plans.ts diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index c23b72c..fab64ad 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -58,6 +58,8 @@ export async function migrate() { await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS region TEXT`; await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; + await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index bf6aa24..92317ec 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -2,6 +2,7 @@ import { Elysia, t } from "elysia"; import { createHmac, randomBytes } from "crypto"; import sql from "../db"; import { createRateLimiter } from "../utils/rate-limit"; +import { getPlanLimits } from "../utils/plans"; // ── Per-IP rate limiting for auth endpoints ─────────────────────────── const checkAuthRateLimit = createRateLimiter(); @@ -16,14 +17,14 @@ function hashEmail(email: string): string { return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex"); } -async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { - const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`; - if (account) return { accountId: account.id, keyId: null }; +async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> { + const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`; + if (account) return { accountId: account.id, keyId: null, plan: account.plan }; - const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`; + const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`; if (apiKey) { sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {}); - return { accountId: apiKey.account_id, keyId: apiKey.id }; + return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan }; } return null; @@ -41,14 +42,14 @@ export function requireAuth(app: Elysia) { const key = bearer || cookieKey; if (!key) { set.status = 401; - return { accountId: null as string | null, keyId: null as string | null }; + return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string }; } const resolved = await resolveKey(key); - if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId }; + if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId, plan: resolved.plan }; set.status = 401; - return { accountId: null as string | null, keyId: null as string | null }; + return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string }; }) .onBeforeHandle(({ accountId, set }) => { if (!accountId) { @@ -114,11 +115,16 @@ export const account = new Elysia({ prefix: "/account" }) .use(requireAuth) .get("/settings", async ({ accountId }) => { - const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, created_at FROM accounts WHERE id = ${accountId}`; const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; + const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; + const limits = getPlanLimits(acc.plan); return { account_id: acc.id, has_email: !!acc.email_hash, + plan: acc.plan, + monitor_count: monitorCount, + limits, created_at: acc.created_at, api_keys: keys, }; diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index ad440c8..490fb5b 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -2,6 +2,7 @@ import { Elysia, t } from "elysia"; import { requireAuth } from "./auth"; import sql from "../db"; import { validateMonitorUrl } from "../utils/ssrf"; +import { getPlanLimits } from "../utils/plans"; const MonitorBody = t.Object({ name: t.String({ maxLength: 200, description: "Human-readable name" }), @@ -24,7 +25,21 @@ export const monitors = new Elysia({ prefix: "/monitors" }) }, { detail: { summary: "List monitors", tags: ["monitors"] } }) // Create monitor - .post("/", async ({ accountId, body, error }) => { + .post("/", async ({ accountId, plan, body, error }) => { + const limits = getPlanLimits(plan); + + // Enforce monitor count limit + const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; + if (count >= limits.maxMonitors) { + return error(403, { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` }); + } + + // Enforce minimum interval for plan + const interval = body.interval_s ?? 30; + if (interval < limits.minIntervalS) { + return error(400, { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` }); + } + // SSRF protection const ssrfError = await validateMonitorUrl(body.url); if (ssrfError) return error(400, { error: ssrfError }); @@ -37,8 +52,8 @@ export const monitors = new Elysia({ prefix: "/monitors" }) ${(body.method ?? 'GET').toUpperCase()}, ${body.request_headers ? sql.json(body.request_headers) : null}, ${body.request_body ?? null}, - ${body.timeout_ms ?? 30000}, - ${body.interval_s ?? 60}, + ${body.timeout_ms ?? 10000}, + ${interval}, ${body.query ? sql.json(body.query) : null}, ${sql.array(regions)} ) @@ -62,7 +77,15 @@ export const monitors = new Elysia({ prefix: "/monitors" }) }, { detail: { summary: "Get monitor with results", tags: ["monitors"] } }) // Update monitor - .patch("/:id", async ({ accountId, params, body, error }) => { + .patch("/:id", async ({ accountId, plan, params, body, error }) => { + // Enforce minimum interval for plan + if (body.interval_s != null) { + const limits = getPlanLimits(plan); + if (body.interval_s < limits.minIntervalS) { + return error(400, { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` }); + } + } + // SSRF protection on URL change if (body.url) { const ssrfError = await validateMonitorUrl(body.url); diff --git a/apps/api/src/utils/plans.ts b/apps/api/src/utils/plans.ts new file mode 100644 index 0000000..1ec8e29 --- /dev/null +++ b/apps/api/src/utils/plans.ts @@ -0,0 +1,25 @@ +export type Plan = "free" | "pro" | "lifetime"; + +export interface PlanLimits { + maxMonitors: number; + minIntervalS: number; +} + +const PLANS: Record = { + free: { + maxMonitors: 5, + minIntervalS: 30, + }, + pro: { + maxMonitors: 500, + minIntervalS: 2, + }, + lifetime: { + maxMonitors: 500, + minIntervalS: 2, + }, +}; + +export function getPlanLimits(plan: string): PlanLimits { + return PLANS[plan as Plan] || PLANS.free; +} diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 42ce4a0..7a9d087 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -55,6 +55,8 @@ export async function migrate() { await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`; await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; + await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index 7a54819..35bedbc 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -16,14 +16,14 @@ function hashEmail(email: string): string { return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex"); } -async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { - const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`; - if (account) return { accountId: account.id, keyId: null }; +async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> { + const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`; + if (account) return { accountId: account.id, keyId: null, plan: account.plan }; - const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`; + const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`; if (apiKey) { sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {}); - return { accountId: apiKey.account_id, keyId: apiKey.id }; + return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan }; } return null; diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 69f645f..895d7e8 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -114,7 +114,7 @@ function redirect(to: string) { return new Response(null, { status: 302, headers: { Location: to } }); } -async function getAccountId(cookie: any, headers: any): Promise<{ accountId: string; keyId: string | null } | null> { +async function getAccountId(cookie: any, headers: any): Promise<{ accountId: string; keyId: string | null; plan: string } | null> { const authHeader = headers["authorization"] ?? ""; const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); const key = cookie?.pingql_key?.value || bearer; @@ -198,12 +198,13 @@ export const dashboard = new Elysia() const keyId = resolved?.keyId ?? null; if (!accountId) return redirect("/dashboard"); - const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, created_at FROM accounts WHERE id = ${accountId}`; const isSubKey = !!keyId; const apiKeys = isSubKey ? [] : await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null); + const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; - return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey }); + return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount }); }) // New monitor @@ -212,7 +213,7 @@ export const dashboard = new Elysia() const accountId = resolved?.accountId ?? null; const keyId = resolved?.keyId ?? null; if (!accountId) return redirect("/dashboard"); - return html("new", { nav: "monitors", scripts: ["/dashboard/query-builder.js"] }); + return html("new", { nav: "monitors", plan: resolved?.plan || "free" }); }) // Home data endpoint for polling (monitor list change detection) @@ -247,7 +248,7 @@ export const dashboard = new Elysia() ORDER BY checked_at DESC LIMIT 100 `; - return html("detail", { nav: "monitors", monitor, pings, scripts: ["/dashboard/query-builder.js"] }); + return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free" }); }) // Chart partial endpoint — returns just the latency chart SVG diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 50279b7..296e485 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -127,7 +127,7 @@

Edit Monitor

- <%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800', border: 'border-gray-700' } }) %> + <%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800', border: 'border-gray-700' }, plan: it.plan }) %>
diff --git a/apps/web/src/views/new.ejs b/apps/web/src/views/new.ejs index a0a2d8f..5cf7a17 100644 --- a/apps/web/src/views/new.ejs +++ b/apps/web/src/views/new.ejs @@ -7,7 +7,7 @@

Create Monitor

- <%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-gray-900', border: 'border-gray-800' } }) %> + <%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-gray-900', border: 'border-gray-800' }, plan: it.plan }) %>