feat: implement free/pro plan system with monitor and interval limits
This commit is contained in:
parent
11e6c593ad
commit
c89b63bd97
|
|
@ -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)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
export type Plan = "free" | "pro" | "lifetime";
|
||||
|
||||
export interface PlanLimits {
|
||||
maxMonitors: number;
|
||||
minIntervalS: number;
|
||||
}
|
||||
|
||||
const PLANS: Record<Plan, PlanLimits> = {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@
|
|||
<!-- Edit form -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
||||
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
||||
<%~ 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 }) %>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
||||
</div>
|
||||
|
||||
<%~ 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 }) %>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
const prefix = it._form?.prefix || '';
|
||||
const bg = it._form?.bg || 'bg-gray-900';
|
||||
const border = it._form?.border || 'border-gray-800';
|
||||
const plan = it.plan || 'free';
|
||||
const minInterval = { free: 30, pro: 2, lifetime: 2 }[plan] || 30;
|
||||
const btnText = isEdit ? 'Save Changes' : 'Create Monitor';
|
||||
const formId = prefix + 'form';
|
||||
const methods = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];
|
||||
const intervals = [['2','2 seconds'],['5','5 seconds'],['10','10 seconds'],['20','20 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']];
|
||||
const allIntervals = [['2','2 seconds'],['5','5 seconds'],['10','10 seconds'],['20','20 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']];
|
||||
const intervals = allIntervals.filter(([val]) => Number(val) >= minInterval);
|
||||
const timeouts = [['5000','5 seconds'],['10000','10 seconds'],['20000','20 seconds'],['30000','30 seconds'],['40000','40 seconds'],['50000','50 seconds'],['60000','60 seconds']];
|
||||
const regions = [['eu-central','🇩🇪 EU Central'],['us-west','🇺🇸 US West']];
|
||||
const curMethod = monitor.method || 'GET';
|
||||
|
|
|
|||
|
|
@ -4,12 +4,43 @@
|
|||
<%
|
||||
const hasEmail = !!it.account.email_hash;
|
||||
const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const plan = it.account.plan || 'free';
|
||||
const planLabel = { free: 'Free', pro: 'Pro', lifetime: 'Lifetime' }[plan] || plan;
|
||||
const planColor = { free: 'gray', pro: 'blue', lifetime: 'yellow' }[plan] || 'gray';
|
||||
const limits = { free: { monitors: 5, interval: '30s' }, pro: { monitors: 500, interval: '2s' }, lifetime: { monitors: 500, interval: '2s' } }[plan] || { monitors: 5, interval: '30s' };
|
||||
%>
|
||||
|
||||
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
||||
|
||||
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
||||
|
||||
<!-- Plan -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-300">Plan</h2>
|
||||
<span class="text-xs font-medium px-2.5 py-1 rounded-full border border-<%= planColor %>-500/30 text-<%= planColor %>-400"><%= planLabel %></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-200"><%= it.monitorCount %><span class="text-gray-600 text-sm font-normal">/<%= limits.monitors %></span></div>
|
||||
<div class="text-xs text-gray-500">Monitors</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-200"><%= limits.interval %></div>
|
||||
<div class="text-xs text-gray-500">Min Interval</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-200">2</div>
|
||||
<div class="text-xs text-gray-500">Regions</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if (plan === 'free') { %>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
|
||||
Upgrade to Pro for 500 monitors and 2s intervals. <span class="text-gray-600">Coming soon.</span>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<!-- Account info -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
||||
|
|
|
|||
Loading…
Reference in New Issue