feat: implement free/pro plan system with monitor and interval limits

This commit is contained in:
2026-03-18 22:40:45 +04:00
parent 11e6c593ad
commit c89b63bd97
11 changed files with 119 additions and 26 deletions
+2
View File
@@ -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)`;
+15 -9
View File
@@ -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,
};
+27 -4
View File
@@ -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);
+25
View File
@@ -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;
}