feat: split web and api into separate apps
This commit is contained in:
parent
ba437e3c5a
commit
841a852491
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "@pingql/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot src/index.ts",
|
||||||
|
"start": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"elysia": "^1.4.27",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.10",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql");
|
||||||
|
|
||||||
|
export default sql;
|
||||||
|
|
||||||
|
export async function migrate() {
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
email_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitors (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL DEFAULT 'GET',
|
||||||
|
request_headers JSONB,
|
||||||
|
request_body TEXT,
|
||||||
|
timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 60,
|
||||||
|
query JSONB,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS pings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
jitter_ms INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
up BOOLEAN NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
meta JSONB
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Migrations for existing deployments
|
||||||
|
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`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)`;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log("DB ready");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { cors } from "@elysiajs/cors";
|
||||||
|
import { ingest } from "./routes/pings";
|
||||||
|
import { monitors } from "./routes/monitors";
|
||||||
|
import { account } from "./routes/auth";
|
||||||
|
import { internal } from "./routes/internal";
|
||||||
|
import { migrate } from "./db";
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
|
.get("/", () => ({
|
||||||
|
name: "PingQL API",
|
||||||
|
version: "1",
|
||||||
|
docs: "https://pingql.com/docs",
|
||||||
|
}))
|
||||||
|
.use(account)
|
||||||
|
.use(monitors)
|
||||||
|
.use(ingest)
|
||||||
|
.use(internal)
|
||||||
|
.listen(3001);
|
||||||
|
|
||||||
|
console.log(`PingQL API running at http://localhost:${app.server?.port}`);
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
/**
|
||||||
|
* PingQL Query Engine — TypeScript implementation
|
||||||
|
*
|
||||||
|
* MongoDB-inspired query language for evaluating HTTP response conditions.
|
||||||
|
* Mirrors the Rust implementation but also powers the visual query builder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface QueryField {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: "number" | "string" | "boolean" | "object";
|
||||||
|
operators: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvalContext {
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
latency_ms?: number;
|
||||||
|
cert_expiry_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Available fields ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAvailableFields(): QueryField[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
description: "HTTP status code (e.g. 200, 404, 500)",
|
||||||
|
type: "number",
|
||||||
|
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body",
|
||||||
|
description: "Response body as text",
|
||||||
|
type: "string",
|
||||||
|
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "headers.*",
|
||||||
|
description: "Response header value (e.g. headers.content-type)",
|
||||||
|
type: "string",
|
||||||
|
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "$select",
|
||||||
|
description: "CSS selector — returns text content of first matching element",
|
||||||
|
type: "string",
|
||||||
|
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "$json",
|
||||||
|
description: "JSONPath expression evaluated against response body (e.g. $.data.status)",
|
||||||
|
type: "string",
|
||||||
|
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "$responseTime",
|
||||||
|
description: "Request latency in milliseconds",
|
||||||
|
type: "number",
|
||||||
|
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "$certExpiry",
|
||||||
|
description: "Days until SSL certificate expires",
|
||||||
|
type: "number",
|
||||||
|
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evaluate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function evaluate(query: unknown, ctx: EvalContext): boolean {
|
||||||
|
if (query === null || query === undefined) return true;
|
||||||
|
if (typeof query !== "object" || Array.isArray(query)) {
|
||||||
|
throw new Error("Query must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query as Record<string, unknown>;
|
||||||
|
|
||||||
|
// $consider — "up" (default) or "down": flips result if conditions match
|
||||||
|
if ("$consider" in q) {
|
||||||
|
const consider = q.$consider as string;
|
||||||
|
const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider"));
|
||||||
|
const matches = evaluate(rest, ctx);
|
||||||
|
return consider === "down" ? !matches : matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $and
|
||||||
|
if ("$and" in q) {
|
||||||
|
const clauses = q.$and;
|
||||||
|
if (!Array.isArray(clauses)) throw new Error("$and expects array");
|
||||||
|
return clauses.every((c) => evaluate(c, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// $or
|
||||||
|
if ("$or" in q) {
|
||||||
|
const clauses = q.$or;
|
||||||
|
if (!Array.isArray(clauses)) throw new Error("$or expects array");
|
||||||
|
return clauses.some((c) => evaluate(c, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// $not
|
||||||
|
if ("$not" in q) {
|
||||||
|
return !evaluate(q.$not, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $responseTime
|
||||||
|
if ("$responseTime" in q) {
|
||||||
|
const val = ctx.latency_ms ?? 0;
|
||||||
|
return evalCondition(q.$responseTime, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $certExpiry
|
||||||
|
if ("$certExpiry" in q) {
|
||||||
|
const val = ctx.cert_expiry_days ?? Infinity;
|
||||||
|
return evalCondition(q.$certExpiry, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $select — { "$select": { "css.selector": { "$op": val } } }
|
||||||
|
if ("$select" in q) {
|
||||||
|
// Server-side: no DOM parser available, pass through (Rust runner evaluates)
|
||||||
|
// Validate structure only
|
||||||
|
const selMap = q.$select as Record<string, unknown>;
|
||||||
|
if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $json — { "$json": { "$.path": { "$op": val } } }
|
||||||
|
if ("$json" in q) {
|
||||||
|
const pathMap = q.$json as Record<string, unknown>;
|
||||||
|
if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }");
|
||||||
|
for (const [path, condition] of Object.entries(pathMap)) {
|
||||||
|
const resolved = resolveJsonPath(ctx.body, path);
|
||||||
|
if (!evalCondition(condition, resolved)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level checks
|
||||||
|
for (const [field, condition] of Object.entries(q)) {
|
||||||
|
if (field.startsWith("$")) continue; // skip unknown $ ops
|
||||||
|
const fieldVal = resolveField(field, ctx);
|
||||||
|
if (!evalCondition(condition, fieldVal)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveField(field: string, ctx: EvalContext): unknown {
|
||||||
|
switch (field) {
|
||||||
|
case "status":
|
||||||
|
case "status_code":
|
||||||
|
return ctx.status;
|
||||||
|
case "body":
|
||||||
|
return ctx.body;
|
||||||
|
default:
|
||||||
|
if (field.startsWith("headers.")) {
|
||||||
|
const key = field.slice(8).toLowerCase();
|
||||||
|
return ctx.headers[key] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveJsonPath(body: string, expr: string): unknown {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(body);
|
||||||
|
// Simple dot-notation JSONPath: $.foo.bar[0].baz
|
||||||
|
const path = expr.replace(/^\$\.?/, "");
|
||||||
|
if (!path) return obj;
|
||||||
|
const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean);
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === null || current === undefined) return null;
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return current ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalCondition(condition: unknown, fieldVal: unknown): boolean {
|
||||||
|
if (condition === null || condition === undefined) return true;
|
||||||
|
|
||||||
|
// Direct equality shorthand: { "status": 200 }
|
||||||
|
if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") {
|
||||||
|
return fieldVal === condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof condition === "object" && !Array.isArray(condition)) {
|
||||||
|
const ops = condition as Record<string, unknown>;
|
||||||
|
for (const [op, opVal] of Object.entries(ops)) {
|
||||||
|
if (!evalOp(op, fieldVal, opVal)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
||||||
|
switch (op) {
|
||||||
|
case "$eq":
|
||||||
|
return fieldVal === opVal;
|
||||||
|
case "$ne":
|
||||||
|
return fieldVal !== opVal;
|
||||||
|
case "$gt":
|
||||||
|
return toNum(fieldVal) > toNum(opVal);
|
||||||
|
case "$gte":
|
||||||
|
return toNum(fieldVal) >= toNum(opVal);
|
||||||
|
case "$lt":
|
||||||
|
return toNum(fieldVal) < toNum(opVal);
|
||||||
|
case "$lte":
|
||||||
|
return toNum(fieldVal) <= toNum(opVal);
|
||||||
|
case "$contains":
|
||||||
|
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal);
|
||||||
|
case "$startsWith":
|
||||||
|
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal);
|
||||||
|
case "$endsWith":
|
||||||
|
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
|
||||||
|
case "$regex": {
|
||||||
|
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
||||||
|
if (opVal.length > 200) return false;
|
||||||
|
try {
|
||||||
|
return new RegExp(opVal).test(fieldVal);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "$exists":
|
||||||
|
return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined;
|
||||||
|
case "$in":
|
||||||
|
return Array.isArray(opVal) && opVal.includes(fieldVal);
|
||||||
|
default:
|
||||||
|
return true; // unknown op — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(v: unknown): number {
|
||||||
|
return typeof v === "number" ? v : Number(v) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VALID_OPS = new Set([
|
||||||
|
"$eq", "$ne", "$gt", "$gte", "$lt", "$lte",
|
||||||
|
"$contains", "$startsWith", "$endsWith", "$regex",
|
||||||
|
"$exists", "$in",
|
||||||
|
"$select", "$json",
|
||||||
|
"$and", "$or", "$not",
|
||||||
|
"$responseTime", "$certExpiry",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VALID_FIELDS = new Set([
|
||||||
|
"status", "status_code", "body",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function validateQuery(query: unknown, path = ""): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
if (query === null || query === undefined) return errors;
|
||||||
|
|
||||||
|
if (typeof query !== "object" || Array.isArray(query)) {
|
||||||
|
errors.push({ path: path || "$", message: "Query must be an object" });
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(q)) {
|
||||||
|
const keyPath = path ? `${path}.${key}` : key;
|
||||||
|
|
||||||
|
if (key === "$and" || key === "$or") {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push({ path: keyPath, message: `${key} expects an array` });
|
||||||
|
} else {
|
||||||
|
value.forEach((clause, i) => {
|
||||||
|
errors.push(...validateQuery(clause, `${keyPath}[${i}]`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (key === "$not") {
|
||||||
|
errors.push(...validateQuery(value, keyPath));
|
||||||
|
} else if (key === "$responseTime" || key === "$certExpiry") {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` });
|
||||||
|
} else {
|
||||||
|
for (const op of Object.keys(value as Record<string, unknown>)) {
|
||||||
|
if (!VALID_OPS.has(op)) {
|
||||||
|
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key === "$select" || key === "$json") {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
errors.push({ path: keyPath, message: `${key} expects a string` });
|
||||||
|
}
|
||||||
|
} else if (key.startsWith("$")) {
|
||||||
|
// It's an operator inside a field condition — skip validation here
|
||||||
|
} else {
|
||||||
|
// Field name
|
||||||
|
if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) {
|
||||||
|
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
const ops = value as Record<string, unknown>;
|
||||||
|
for (const op of Object.keys(ops)) {
|
||||||
|
if (!op.startsWith("$")) continue;
|
||||||
|
if (!VALID_OPS.has(op)) {
|
||||||
|
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
||||||
|
}
|
||||||
|
if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) {
|
||||||
|
errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import sql from "../db";
|
||||||
|
|
||||||
|
function generateKey(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashEmail(email: string): string {
|
||||||
|
return createHash("sha256").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 };
|
||||||
|
|
||||||
|
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE 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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveKey };
|
||||||
|
|
||||||
|
export function requireAuth(app: Elysia) {
|
||||||
|
return app
|
||||||
|
.derive(async ({ headers, cookie, set }) => {
|
||||||
|
const authHeader = headers["authorization"] ?? "";
|
||||||
|
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||||
|
const cookieKey = cookie?.pingql_key?.value;
|
||||||
|
|
||||||
|
const key = bearer || cookieKey;
|
||||||
|
if (!key) {
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId };
|
||||||
|
|
||||||
|
set.status = 401;
|
||||||
|
return { accountId: null as string | null, keyId: null as string | null };
|
||||||
|
})
|
||||||
|
.onBeforeHandle(({ accountId, set }) => {
|
||||||
|
if (!accountId) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Invalid or missing account key" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE_OPTS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV !== "development",
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const account = new Elysia({ prefix: "/account" })
|
||||||
|
|
||||||
|
.post("/login", async ({ body, cookie, set }) => {
|
||||||
|
const key = (body.key as string)?.trim();
|
||||||
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
||||||
|
|
||||||
|
const resolved = await resolveKey(key);
|
||||||
|
if (!resolved) {
|
||||||
|
set.status = 401;
|
||||||
|
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
|
||||||
|
return { error: "Invalid account key" };
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
|
||||||
|
return { ok: true };
|
||||||
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
|
.get("/logout", ({ cookie, set }) => {
|
||||||
|
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
|
||||||
|
set.redirect = "/dashboard";
|
||||||
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
|
.post("/register", async ({ body, cookie }) => {
|
||||||
|
const key = generateKey();
|
||||||
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||||
|
};
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.use(requireAuth)
|
||||||
|
|
||||||
|
.get("/settings", async ({ accountId }) => {
|
||||||
|
const [acc] = await sql`SELECT id, email_hash, 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`;
|
||||||
|
return {
|
||||||
|
account_id: acc.id,
|
||||||
|
has_email: !!acc.email_hash,
|
||||||
|
created_at: acc.created_at,
|
||||||
|
api_keys: keys,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/email", async ({ accountId, body }) => {
|
||||||
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
|
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
|
||||||
|
return { ok: true };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/reset-key", async ({ accountId, cookie }) => {
|
||||||
|
const key = generateKey();
|
||||||
|
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
|
||||||
|
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||||
|
return { key, message: "Primary key rotated. Your old key is now invalid." };
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/keys", async ({ accountId, body }) => {
|
||||||
|
const key = generateKey();
|
||||||
|
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
|
||||||
|
return { key, id: created.id, label: body.label };
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete("/keys/:id", async ({ accountId, params, error }) => {
|
||||||
|
const [deleted] = await sql`
|
||||||
|
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
||||||
|
`;
|
||||||
|
if (!deleted) return error(404, { error: "Key not found" });
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
@ -1,49 +1,18 @@
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { ingest } from "./routes/pings";
|
|
||||||
import { monitors } from "./routes/monitors";
|
|
||||||
import { account } from "./routes/auth";
|
|
||||||
import { internal } from "./routes/internal";
|
|
||||||
import { dashboard } from "./routes/dashboard";
|
import { dashboard } from "./routes/dashboard";
|
||||||
|
import { account } from "./routes/auth";
|
||||||
import { migrate } from "./db";
|
import { migrate } from "./db";
|
||||||
|
|
||||||
await migrate();
|
await migrate();
|
||||||
|
|
||||||
// Web-only paths that shouldn't be accessible via api.pingql.com
|
|
||||||
const WEB_ONLY_PATHS = ["/", "/docs", "/privacy", "/tos", "/dashboard"];
|
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(cors({
|
.use(cors({
|
||||||
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com", "https://api.pingql.com"],
|
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}))
|
}))
|
||||||
// Host-based routing: api.pingql.com gets JSON-only responses
|
|
||||||
.onBeforeHandle(({ request, set }) => {
|
|
||||||
const host = new URL(request.url).hostname;
|
|
||||||
if (host === "api.pingql.com") {
|
|
||||||
const path = new URL(request.url).pathname;
|
|
||||||
if (path === "/") {
|
|
||||||
set.headers["content-type"] = "application/json";
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
name: "PingQL API",
|
|
||||||
version: "1",
|
|
||||||
docs: "https://pingql.com/docs",
|
|
||||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
||||||
}
|
|
||||||
const isWebOnly = WEB_ONLY_PATHS.some(p => p !== "/" && path.startsWith(p));
|
|
||||||
if (isWebOnly) {
|
|
||||||
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.use(dashboard)
|
.use(dashboard)
|
||||||
.use(account)
|
.use(account)
|
||||||
.use(monitors)
|
|
||||||
.use(ingest)
|
|
||||||
.use(internal)
|
|
||||||
.listen(3000);
|
.listen(3000);
|
||||||
|
|
||||||
console.log(`PingQL running at http://localhost:${app.server?.port}`);
|
console.log(`PingQL Web running at http://localhost:${app.server?.port}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue