diff --git a/apps/api/src/query/index.ts b/apps/api/src/query/index.ts deleted file mode 100644 index 216525f..0000000 --- a/apps/api/src/query/index.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * 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; - 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; - - // $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; - 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; - 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)[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; - 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; - if (isSafeRegex(opVal) === false) 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; -} - -/** - * Reject regex patterns likely to cause catastrophic backtracking (ReDoS). - * Blocks nested quantifiers like (a+)+ and star-height > 1 patterns. - */ -function isSafeRegex(pattern: string): boolean { - // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc. - if (/\([^)]*[+*}]\)[+*{]/.test(pattern)) return false; - // Reject overlapping alternation with quantifiers: (a|a)+ - if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false; - return true; -} - -// ── 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; - - 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)) { - 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 === "$consider") { - if (value !== "up" && value !== "down") { - errors.push({ path: keyPath, message: '$consider must be "up" or "down"' }); - } - } 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; - 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; -} diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts deleted file mode 100644 index 216525f..0000000 --- a/apps/web/src/query/index.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * 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; - 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; - - // $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; - 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; - 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)[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; - 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; - if (isSafeRegex(opVal) === false) 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; -} - -/** - * Reject regex patterns likely to cause catastrophic backtracking (ReDoS). - * Blocks nested quantifiers like (a+)+ and star-height > 1 patterns. - */ -function isSafeRegex(pattern: string): boolean { - // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc. - if (/\([^)]*[+*}]\)[+*{]/.test(pattern)) return false; - // Reject overlapping alternation with quantifiers: (a|a)+ - if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false; - return true; -} - -// ── 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; - - 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)) { - 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 === "$consider") { - if (value !== "up" && value !== "down") { - errors.push({ path: keyPath, message: '$consider must be "up" or "down"' }); - } - } 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; - 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; -}