diff --git a/apps/backend/.env b/apps/backend/.env index 19b5f0253..94b908d19 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -84,6 +84,7 @@ STACK_QSTASH_NEXT_SIGNING_KEY= # Email monitor STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=# enter the resend poller api key here STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=# enter the resend domain that should receive the emails +STACK_EMAIL_MONITOR_PROJECT_ID=# enter the project id for the project that the email monitor will attempt to sign up for STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=# enter the publishable client key for email monitor to use when attempting a sign up STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=# enter a valid verification callback url for the project that the email monitor will attempt to sign up for STACK_EMAIL_MONITOR_INBUCKET_API_URL=# enter a valid inbucket api url for the email monitor to check emails from in test mode diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 7dfbb471f..37b046cc9 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -59,6 +59,7 @@ STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret # Email monitor configuration for tests STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification +STACK_EMAIL_MONITOR_PROJECT_ID=internal STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key diff --git a/apps/backend/src/app/health/email/route.tsx b/apps/backend/src/app/health/email/route.tsx index 6e4809989..7159c84d4 100644 --- a/apps/backend/src/app/health/email/route.tsx +++ b/apps/backend/src/app/health/email/route.tsx @@ -69,7 +69,7 @@ const performSignUp = async (email: string, password: string) => { "Content-Type": "application/json", "X-Stack-Access-Type": "client", "X-Stack-Publishable-Client-Key": getEnvVariable("STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"), - "X-Stack-Project-Id": "internal", + "X-Stack-Project-Id": getEnvVariable("STACK_EMAIL_MONITOR_PROJECT_ID"), }, body: JSON.stringify({ email, diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index ae1968f56..61fbd735e 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -620,15 +620,18 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } const BLOCKED_PROJECT_ID = "2397ef60-a33e-4efb-ad9b-300da67ee29e"; - const BLOCKED_DOMAIN = "gsmoal.com"; + const BLOCKED_DOMAINS = ["gsmoal.com", "virgilian.com"]; if (context.tenancy.project.id === BLOCKED_PROJECT_ID) { for (const email of resolution.emails) { const emailDomain = email.split("@")[1]?.toLowerCase(); - if (emailDomain === BLOCKED_DOMAIN || emailDomain.endsWith(`.${BLOCKED_DOMAIN}`)) { - console.warn(`[email-queue] Blocked email to ${email} from project ${BLOCKED_PROJECT_ID} — domain @${BLOCKED_DOMAIN} (or subdomain) is blocked for this project`); + const blockedDomain = emailDomain + ? BLOCKED_DOMAINS.find((domain) => emailDomain === domain || emailDomain.endsWith(`.${domain}`)) + : undefined; + if (blockedDomain) { + console.warn(`[email-queue] Blocked email to ${email} from project ${BLOCKED_PROJECT_ID} — domain @${blockedDomain} (or subdomain) is blocked for this project`); await markSkipped(row, EmailOutboxSkippedReason.LIKELY_NOT_DELIVERABLE, { reason: "domain_blocked_for_project", - blockedDomain: BLOCKED_DOMAIN, + blockedDomain, email, }); return; diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 76be22d61..8770ddd5f 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -1,5 +1,6 @@ import "../polyfills"; +import { recordRequestStats } from "@/lib/dev-request-stats"; import * as Sentry from "@sentry/nextjs"; import { EndpointDocumentation } from "@stackframe/stack-shared/dist/crud"; import { KnownError, KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; @@ -10,7 +11,6 @@ import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/pro import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry"; import { NextRequest } from "next/server"; import * as yup from "yup"; -import { recordRequestStats } from "@/lib/dev-request-stats"; import { DeepPartialSmartRequestWithSentinel, MergeSmartRequest, SmartRequest, createSmartRequest, validateSmartRequest } from "./smart-request"; import { SmartResponse, createResponse, validateSmartResponse } from "./smart-response"; @@ -103,7 +103,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque } // request duration warning - if (req.nextUrl.pathname !== "/api/latest/internal/email-queue-step") { + const allowedLongRequestPaths = ["/api/latest/internal/email-queue-step", "/api/latest/internal/analytics/query", "/health/email", "/api/latest/internal/metrics"]; + if (!allowedLongRequestPaths.includes(req.nextUrl.pathname)) { const warnAfterSeconds = 12; runAsynchronously(async () => { await wait(warnAfterSeconds * 1000); diff --git a/apps/dashboard/src/lib/classify-query.test.ts b/apps/dashboard/src/lib/classify-query.test.ts index be8a24bf0..221ddf2ad 100644 --- a/apps/dashboard/src/lib/classify-query.test.ts +++ b/apps/dashboard/src/lib/classify-query.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { classifyClickHouseSqlVsPrompt } from "./classify-query"; describe("classifyClickHouseSqlVsPrompt", () => { diff --git a/apps/dashboard/src/lib/classify-query.ts b/apps/dashboard/src/lib/classify-query.ts index c1a6ec9bc..ecb00f6a2 100644 --- a/apps/dashboard/src/lib/classify-query.ts +++ b/apps/dashboard/src/lib/classify-query.ts @@ -11,7 +11,9 @@ export function classifyClickHouseSqlVsPrompt(input: unknown): { const raw = (input ?? "").toString(); const s = raw.trim(); - if (!s) return { kind: "prompt", confidence: 0.5, reasons: ["empty"] }; + if (!s) { + return { kind: "prompt", confidence: 0.5, reasons: ["empty"] }; + } // Strip code fences if someone pasted markdown const unfenced = s.replace(/^```[\w-]*\n([\s\S]*?)\n```$/m, "$1").trim(); @@ -25,15 +27,28 @@ export function classifyClickHouseSqlVsPrompt(input: unknown): { const reasons: string[] = []; // 1) Strong starts (high signal) - const startsWithSql = /^(with|select|insert|update|delete|alter|create|drop|truncate|show|describe|desc|explain|use|set)\b/i.test(unfenced); - if (startsWithSql) { sql += 3; reasons.push("starts-with-sql-keyword"); } + const startsWithSqlKeyword = /^(with|select|insert|update|delete|alter|create|drop|truncate|describe|desc|explain|use|set)\b/i.test(unfenced); + const showEnglishLead = /^show\s+(me|us|my|our|your|them|him|her)\b/i.test(unfenced); + const showHasSqlTarget = /^show\s+(tables?|databases?|columns?|create|processlist|functions?|settings|grants|roles|quotas|dictionary|dictionaries|clusters|indexes|partitions|privileges|users?)\b/i.test(unfenced); + const startsWithShowSql = /^show\b/i.test(unfenced) && showHasSqlTarget && !showEnglishLead; + const startsWithSql = startsWithSqlKeyword || startsWithShowSql; + if (startsWithSql) { + sql += 3; + reasons.push("starts-with-sql-keyword"); + } // 2) Structural patterns (very strong) const hasSelectFrom = /\bselect\b[\s\S]{0,300}\bfrom\b/i.test(unfenced); - if (hasSelectFrom) { sql += 4; reasons.push("select-from-structure"); } + if (hasSelectFrom) { + sql += 4; + reasons.push("select-from-structure"); + } const hasInsertInto = /\binsert\b[\s\S]{0,80}\binto\b/i.test(unfenced); - if (hasInsertInto) { sql += 4; reasons.push("insert-into-structure"); } + if (hasInsertInto) { + sql += 4; + reasons.push("insert-into-structure"); + } // 3) Common SQL clauses const clauseHits = [ @@ -49,44 +64,64 @@ export function classifyClickHouseSqlVsPrompt(input: unknown): { "prewhere", "final", "sample", "array", "engine", "partition", "ttl", "distributed", "merge", "replacing", "collapsing", "materialized", "view", "database", "table", "cluster" ].filter(k => wordSet.has(k)); - if (chHits.length) { sql += 2; reasons.push("clickhouse-ish:" + chHits.join(",")); } + if (chHits.length) { + sql += 2; + reasons.push("clickhouse-ish:" + chHits.join(",")); + } // 5) Operator / punctuation density const opCount = (unfenced.match(/(<=|>=|!=|=|<|>|\b(in|like|ilike|between|and|or)\b)/gi) ?? []).length; - if (opCount >= 2) { sql += 2; reasons.push("many-operators"); } - else if (opCount === 1) { sql += 1; reasons.push("some-operators"); } + if (opCount >= 2) { + sql += 2; + reasons.push("many-operators"); + } else if (opCount === 1) { + sql += 1; + reasons.push("some-operators"); + } const punct = (unfenced.match(/[(),;*]/g) ?? []).length; const punctRatio = punct / Math.max(1, unfenced.length); - if (punctRatio > 0.03) { sql += 1; reasons.push("sql-punctuation-density"); } + if (punctRatio > 0.03) { + sql += 1; + reasons.push("sql-punctuation-density"); + } // 6) Identifier-ish things if (/`[^`]+`/.test(unfenced) || /"[^"]+"\."[^"]+"/.test(unfenced)) { - sql += 1; reasons.push("quoted-identifiers"); + sql += 1; + reasons.push("quoted-identifiers"); } if (/\b[a-z_]+\.[a-z_]+\b/i.test(unfenced)) { - sql += 1; reasons.push("dot-identifiers"); + sql += 1; + reasons.push("dot-identifiers"); } if (/--|\/\*/.test(unfenced)) { - sql += 1; reasons.push("sql-comments"); + sql += 1; + reasons.push("sql-comments"); } // Prompt-ish features - if (/[?]\s*$/.test(unfenced)) { prompt += 2; reasons.push("ends-with-question-mark"); } + if (/[?]\s*$/.test(unfenced)) { + prompt += 2; + reasons.push("ends-with-question-mark"); + } if (/\b(please|could you|can you|what|why|how|explain|help)\b/i.test(unfenced)) { - prompt += 2; reasons.push("prompt-words"); + prompt += 2; + reasons.push("prompt-words"); } // If it's mostly letters/spaces and barely any operators, lean prompt. const symbolChars = (unfenced.match(/[^a-z0-9_\s]/gi) ?? []).length; const symbolRatio = symbolChars / Math.max(1, unfenced.length); if (symbolRatio < 0.06 && opCount === 0 && !startsWithSql) { - prompt += 2; reasons.push("low-symbol-low-operator"); + prompt += 2; + reasons.push("low-symbol-low-operator"); } // Avoid the classic false positive: "select ..." in English without any SQL structure if (/^select\b/i.test(unfenced) && !hasSelectFrom && clauseHits.length === 0 && opCount === 0) { - prompt += 3; reasons.push("english-select-false-positive-guard"); + prompt += 3; + reasons.push("english-select-false-positive-guard"); } const margin = sql - prompt;