diff --git a/api/package.json b/api/package.json index de294d2d..20207b9a 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", -"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", +"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/ai-generate-provider-errors.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/assets/dashboardHtml.ts b/api/src/assets/dashboardHtml.ts index 40c72f26..b2cce090 100644 --- a/api/src/assets/dashboardHtml.ts +++ b/api/src/assets/dashboardHtml.ts @@ -76,8 +76,12 @@ export const DASHBOARD_HTML = ` .depleted-banner { background:rgba(248,113,113,0.10); border:1px solid rgba(248,113,113,0.35); border-radius:14px; padding:18px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:16px; } .depleted-text { font-size:14px; color:rgba(255,255,255,0.85); } .depleted-text strong { color:#f87171; } - .verify-banner { background:rgba(34,211,238,0.07); border:1px solid rgba(34,211,238,0.25); border-radius:14px; padding:14px 20px; display:flex; align-items:center; gap:12px; margin-bottom:16px; font-size:13px; color:rgba(255,255,255,0.75); } + .verify-banner { background:rgba(34,211,238,0.07); border:1px solid rgba(34,211,238,0.25); border-radius:14px; padding:14px 20px; display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; font-size:13px; color:rgba(255,255,255,0.75); } .verify-banner strong { color:#22d3ee; } + .verify-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } + .verify-btn { height:34px; padding:0 12px; border-radius:8px; border:1px solid rgba(34,211,238,0.35); background:rgba(34,211,238,0.12); color:#22d3ee; font-size:12px; font-weight:700; font-family:inherit; cursor:pointer; white-space:nowrap; } + .verify-btn:disabled { opacity:0.55; cursor:not-allowed; } + .verify-status { font-size:12px; color:rgba(255,255,255,0.55); min-height:16px; } /* STATUS */ .status-tag { font-size:12px; color:var(--muted); font-family:"JetBrains Mono",monospace; } @media (max-width:600px) { @@ -196,6 +200,10 @@ export const DASHBOARD_HTML = ` @@ -336,6 +344,14 @@ export const DASHBOARD_HTML = ` var pending = data.pending_credits ?? 0; if (data.email_verified === false && pending > 0) { document.getElementById("pending-credits").textContent = pending.toLocaleString(); + var accountEmail = (typeof data.email === "string") ? data.email : ""; + var resendBtn = document.getElementById("resend-verify-btn"); + var resendStatus = document.getElementById("verify-resend-status"); + if (resendStatus) resendStatus.textContent = ""; + if (resendBtn) { + resendBtn.disabled = !accountEmail; + resendBtn.onclick = function() { resendVerificationEmail(accountEmail); }; + } document.getElementById("verify-banner").style.display = "flex"; } else { document.getElementById("verify-banner").style.display = "none"; @@ -383,6 +399,30 @@ export const DASHBOARD_HTML = ` } } + async function resendVerificationEmail(email) { + var btn = document.getElementById("resend-verify-btn"); + var st = document.getElementById("verify-resend-status"); + if (!email || !btn || !st) return; + btn.disabled = true; + btn.textContent = "Sending..."; + st.textContent = ""; + try { + var resp = await fetch("/v1/agent/verify-email/resend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email }) + }); + var data; + try { data = await resp.json(); } catch(_) { data = {}; } + st.textContent = data.message || (resp.ok ? "Requested — check your inbox." : "Could not resend right now."); + } catch(_) { + st.textContent = "Could not reach the server — try again shortly."; + } finally { + btn.disabled = false; + btn.textContent = "Resend email"; + } + } + function showKeyEntryFallback() { document.getElementById("loading-card").style.display = "none"; document.getElementById("key-entry-card").style.display = "block"; diff --git a/api/src/lib/sessionContext.ts b/api/src/lib/sessionContext.ts index 8ae2ad01..415d19fc 100644 --- a/api/src/lib/sessionContext.ts +++ b/api/src/lib/sessionContext.ts @@ -21,6 +21,15 @@ export interface SessionContextMessage { content: string; } +export const DEFAULT_SESSION_CONTEXT_MAX_CHARS = 40000; + +export function parseSessionContextMaxChars(raw: string | undefined): number { + const value = (raw ?? String(DEFAULT_SESSION_CONTEXT_MAX_CHARS)).trim(); + if (!/^\d+$/.test(value)) return DEFAULT_SESSION_CONTEXT_MAX_CHARS; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SESSION_CONTEXT_MAX_CHARS; +} + export function trimSessionContext( messages: T[], maxChars: number, diff --git a/api/src/routes/agent.ts b/api/src/routes/agent.ts index 3fafb1d1..3eeb5f94 100644 --- a/api/src/routes/agent.ts +++ b/api/src/routes/agent.ts @@ -354,6 +354,7 @@ router.get("/usage", requireAuth, async (req: AuthedRequest, res: Response): Pro res.json({ ok: true, agent_id: agent.id, + email: agent.email, credits_remaining: agent.credits, calls_today: callsToday, total_calls: agent.totalCalls, diff --git a/api/src/routes/tools/index.ts b/api/src/routes/tools/index.ts index 250d667a..b1c2c3f0 100644 --- a/api/src/routes/tools/index.ts +++ b/api/src/routes/tools/index.ts @@ -7,7 +7,7 @@ import { config } from "../../config.js"; import { validateUrl, safeAxiosGet, safeFetch, safeAxiosRequest } from "../../lib/ssrf.js"; import { prisma } from "../../lib/prisma.js"; import { applyModelCost, modelCostMultiplier } from "../../lib/modelCost.js"; -import { trimSessionContext } from "../../lib/sessionContext.js"; +import { parseSessionContextMaxChars, trimSessionContext } from "../../lib/sessionContext.js"; import { moderateGenerationPrompt } from "../../lib/promptModeration.js"; import { readArrayBufferWithLimit, ResponseTooLargeError } from "../../utils/responseBody.js"; import { enforcementTierForAccount } from "../../lib/tiers.js"; @@ -187,6 +187,27 @@ function byokAdjustedCost(req: Request, cost: number, headerNames: readonly stri return hasByokKeys(req, headerNames) ? Math.max(1, Math.ceil(cost * 0.2)) : cost; } +async function readProviderJson(resp: globalThis.Response): Promise> { + return (await resp.json().catch(() => ({}))) as Record; +} + +function providerFailureStatus(status: number): number { + return status === 429 ? 429 : 502; +} + +function providerFailureCode(provider: string, status: number): string { + return status === 429 ? "rate_limited" : `${provider}_error`; +} + +function providerFailureMessage(label: string, status: number, data: Record): string { + const detail = typeof data.error?.message === "string" + ? data.error.message + : typeof data.message === "string" + ? data.message + : ""; + return detail ? `${label} API error: ${detail}` : `${label} API returned ${status}`; +} + function extractJsonObject(text: string): string | null { const cleaned = text.replace(/```json|```/g, "").trim(); const match = cleaned.match(/\{[\s\S]*\}/); @@ -1274,8 +1295,16 @@ router.post("/ai-generate", ...toolMiddleware("ai-generate"), async (req: Authed headers: { "Content-Type": "application/json", "Authorization": `Bearer ${openaiKey}` }, body: JSON.stringify({ model, max_tokens: maxTok, messages: [...(system ? [{ role: "system", content: system }] : []), { role: "user", content: prompt }] }), }); - const data = await resp.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number } }; + const data = await readProviderJson(resp) as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number }; error?: { message?: string }; message?: string }; + if (!resp.ok) { + res.status(providerFailureStatus(resp.status)).json({ ok: false, error: providerFailureCode("openai", resp.status), message: providerFailureMessage("OpenAI", resp.status, data), request_id: reqId() }); + return; + } const text = data.choices?.[0]?.message?.content ?? ""; + if (!text) { + res.status(502).json({ ok: false, error: "openai_error", message: "OpenAI returned an empty response", request_id: reqId() }); + return; + } const _u = { input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 }; res.json({ ok: true, text, model, ...(resolvedMode ? { mode: resolvedMode } : {}), provider: "openai", usage: _u, word_count: text.split(/\s+/).filter(Boolean).length, char_count: text.length, sentence_count: text.split(/[.!?]+/).filter((s: string) => s.trim()).length, estimated_cost_usd: (_u.input_tokens * 0.000003 + _u.output_tokens * 0.000015).toFixed(6), response_format: "structured", arch_tools_version: "1.9.0", processed_at: new Date().toISOString(), ...(byokProvider === "openai" ? { byok: true, byok_provider: "openai" } : {}), request_id: reqId() }); return; @@ -1291,8 +1320,16 @@ router.post("/ai-generate", ...toolMiddleware("ai-generate"), async (req: Authed headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: [{ parts: [{ text: fullPrompt }] }], generationConfig: { maxOutputTokens: maxTok } }), }); - const data = await resp.json() as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number } }; + const data = await readProviderJson(resp) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number }; error?: { message?: string }; message?: string }; + if (!resp.ok) { + res.status(providerFailureStatus(resp.status)).json({ ok: false, error: providerFailureCode("google", resp.status), message: providerFailureMessage("Google", resp.status, data), request_id: reqId() }); + return; + } const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + if (!text) { + res.status(502).json({ ok: false, error: "google_error", message: "Google returned an empty response", request_id: reqId() }); + return; + } const _ug = { input_tokens: data.usageMetadata?.promptTokenCount ?? 0, output_tokens: data.usageMetadata?.candidatesTokenCount ?? 0 }; res.json({ ok: true, text, model, ...(resolvedMode ? { mode: resolvedMode } : {}), provider: "google", usage: _ug, word_count: text.split(/\s+/).filter(Boolean).length, char_count: text.length, sentence_count: text.split(/[.!?]+/).filter((s: string) => s.trim()).length, estimated_cost_usd: (_ug.input_tokens * 0.000001 + _ug.output_tokens * 0.000004).toFixed(6), response_format: "structured", arch_tools_version: "1.9.0", processed_at: new Date().toISOString(), ...(byokProvider === "google" ? { byok: true, byok_provider: "google" } : {}), request_id: reqId() }); return; @@ -1307,8 +1344,16 @@ router.post("/ai-generate", ...toolMiddleware("ai-generate"), async (req: Authed headers: { "Content-Type": "application/json", "Authorization": `Bearer ${xaiKey}` }, body: JSON.stringify({ model, max_tokens: maxTok, messages: [...(system ? [{ role: "system", content: system }] : []), { role: "user", content: prompt }] }), }); - const data = await resp.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number } }; + const data = await readProviderJson(resp) as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number }; error?: { message?: string }; message?: string }; + if (!resp.ok) { + res.status(providerFailureStatus(resp.status)).json({ ok: false, error: providerFailureCode("xai", resp.status), message: providerFailureMessage("xAI", resp.status, data), request_id: reqId() }); + return; + } const text = data.choices?.[0]?.message?.content ?? ""; + if (!text) { + res.status(502).json({ ok: false, error: "xai_error", message: "xAI returned an empty response", request_id: reqId() }); + return; + } const _ux = { input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 }; res.json({ ok: true, text, model, ...(resolvedMode ? { mode: resolvedMode } : {}), provider: "xai", usage: _ux, word_count: text.split(/\s+/).filter(Boolean).length, char_count: text.length, sentence_count: text.split(/[.!?]+/).filter((s: string) => s.trim()).length, estimated_cost_usd: (_ux.input_tokens * 0.000005 + _ux.output_tokens * 0.000015).toFixed(6), response_format: "structured", arch_tools_version: "1.9.0", processed_at: new Date().toISOString(), ...(byokProvider === "xai" ? { byok: true, byok_provider: "xai" } : {}), request_id: reqId() }); return; @@ -3071,7 +3116,7 @@ router.post("/session-message", ...toolMiddleware("session-message"), async (req // a loop. Trim oldest-first to SESSION_CONTEXT_MAX_CHARS (env-tunable, // default 40000); the newest message is always sent. Stored history is // unchanged — only the window sent upstream is trimmed. - const SESSION_CONTEXT_MAX_CHARS = parseInt(process.env.SESSION_CONTEXT_MAX_CHARS ?? "40000", 10); + const SESSION_CONTEXT_MAX_CHARS = parseSessionContextMaxChars(process.env.SESSION_CONTEXT_MAX_CHARS); const { window: upstreamMessages, truncated: contextTruncated } = trimSessionContext(session.messages, SESSION_CONTEXT_MAX_CHARS); diff --git a/api/tests/ai-generate-provider-errors.test.mjs b/api/tests/ai-generate-provider-errors.test.mjs new file mode 100644 index 00000000..f0c05389 --- /dev/null +++ b/api/tests/ai-generate-provider-errors.test.mjs @@ -0,0 +1,61 @@ +/** + * ai-generate provider failure regression coverage. + * + * Guards the paid-empty-success bug: OpenAI/Gemini/xAI non-2xx or empty + * responses must not be coerced into { ok:true, text:"" }, because successful + * responses are charged/logged by the credit finalizer. + * + * Run: cd api && node tests/ai-generate-provider-errors.test.mjs + */ +import assert from "assert"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const toolsSrc = fs.readFileSync(path.join(__dirname, "..", "src", "routes", "tools", "index.ts"), "utf8"); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log(` ✓ ${name}`); } + catch (e) { failures++; console.error(` ✗ ${name}: ${e.message}`); } +} + +function route(name) { + const start = toolsSrc.indexOf(`router.post("/${name}"`); + assert.ok(start >= 0, `missing route for ${name}`); + const end = toolsSrc.indexOf("router.post(", start + 1); + return toolsSrc.slice(start, end > start ? end : undefined); +} + +const aiGenerateRoute = route("ai-generate"); + +console.log("ai-generate provider failure handling:"); + +test("shared helpers convert provider failures into non-2xx API responses", () => { + assert.match(toolsSrc, /async function readProviderJson\(resp: globalThis\.Response\)/); + assert.match(toolsSrc, /function providerFailureStatus\(status: number\): number/); + assert.match(toolsSrc, /status === 429 \? "rate_limited" : `\$\{provider\}_error`/); +}); + +for (const [provider, label, emptyMessage] of [ + ["openai", "OpenAI", "OpenAI returned an empty response"], + ["google", "Google", "Google returned an empty response"], + ["xai", "xAI", "xAI returned an empty response"], +]) { + test(`${provider}: checks resp.ok before success`, () => { + const providerBlock = aiGenerateRoute.slice(aiGenerateRoute.indexOf(`provider: "${provider}"`) - 900, aiGenerateRoute.indexOf(`provider: "${provider}"`) + 500); + assert.match(providerBlock, /if \(!resp\.ok\)/, `${provider} branch must check provider HTTP status`); + assert.match(providerBlock, new RegExp(`providerFailureCode\\("${provider}", resp\\.status\\)`)); + assert.match(providerBlock, new RegExp(`providerFailureMessage\\("${label}", resp\\.status, data\\)`)); + }); + + test(`${provider}: empty provider output is not a paid success`, () => { + assert.ok(aiGenerateRoute.includes(emptyMessage), `${provider} empty response message missing`); + assert.ok(!new RegExp(`provider: "${provider}"[\\s\\S]{0,500}const text = data\\.[\\s\\S]*?\\?\\? "";[\\s\\S]{0,250}res\\.json\\(\\{ ok: true`).test(aiGenerateRoute), + `${provider} must not immediately turn missing output into ok:true`); + }); +} + +if (failures) { console.error(`\n${failures} failure(s)`); process.exit(1); } +console.log("\nAll ai-generate provider-error tests passed."); diff --git a/api/tests/intent-funnel.test.mjs b/api/tests/intent-funnel.test.mjs index 2a244fb3..d4479f2d 100644 --- a/api/tests/intent-funnel.test.mjs +++ b/api/tests/intent-funnel.test.mjs @@ -162,13 +162,17 @@ async function main() { assert.ok(DASHBOARD_HTML.includes('id="depleted-banner"'), "zero-balance banner missing"); assert.ok(DASHBOARD_HTML.includes('id="upgrade-banner"'), "low-balance banner missing"); assert.ok(DASHBOARD_HTML.includes('id="verify-banner"'), "verify banner missing"); + assert.ok(DASHBOARD_HTML.includes('id="resend-verify-btn"'), "verify resend button missing"); assert.ok(DASHBOARD_HTML.includes('(cr === 0) ? "flex" : "none"')); assert.ok(DASHBOARD_HTML.includes('(cr > 0 && cr < 50) ? "flex" : "none"')); assert.ok(DASHBOARD_HTML.includes('data.email_verified === false && pending > 0')); + assert.ok(DASHBOARD_HTML.includes('resendVerificationEmail(accountEmail)'), "verify banner must wire an explicit resend action"); + assert.ok(DASHBOARD_HTML.includes('/v1/agent/verify-email/resend'), "dashboard must call the neutral resend endpoint"); assert.ok(DASHBOARD_HTML.includes('href="/pricing?pack=starter"')); }); - test("/v1/agent/usage exposes email_verified + pending_credits", () => { + test("/v1/agent/usage exposes email + email_verified + pending_credits", () => { const agentSrc = fs.readFileSync(src("routes", "agent.ts"), "utf-8"); + assert.ok(agentSrc.includes("email: agent.email")); assert.ok(agentSrc.includes("email_verified: verification?.emailVerified ?? true")); assert.ok(agentSrc.includes("pending_credits: verification?.pendingCredits ?? 0")); }); diff --git a/api/tests/session-pricing.test.mjs b/api/tests/session-pricing.test.mjs index 6b32dce6..15c50c5e 100644 --- a/api/tests/session-pricing.test.mjs +++ b/api/tests/session-pricing.test.mjs @@ -12,7 +12,7 @@ import assert from "assert"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import { trimSessionContext } from "../dist/lib/sessionContext.js"; +import { DEFAULT_SESSION_CONTEXT_MAX_CHARS, parseSessionContextMaxChars, trimSessionContext } from "../dist/lib/sessionContext.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const toolsSrc = fs.readFileSync(path.join(__dirname, "..", "src", "routes", "tools", "index.ts"), "utf8"); @@ -81,6 +81,24 @@ test("stored history is not mutated by trimming", () => { assert.deepStrictEqual(messages, copy); }); +console.log("\nContext cap env parsing:"); + +test("default context cap is the safe 40k budget", () => { + assert.strictEqual(DEFAULT_SESSION_CONTEXT_MAX_CHARS, 40000); + assert.strictEqual(parseSessionContextMaxChars(undefined), 40000); +}); + +for (const bad of ["", " ", "abc", "40000x", "-1", "0", String(Number.MAX_SAFE_INTEGER + 1)]) { + test(`invalid SESSION_CONTEXT_MAX_CHARS=${JSON.stringify(bad)} falls back to default`, () => { + assert.strictEqual(parseSessionContextMaxChars(bad), DEFAULT_SESSION_CONTEXT_MAX_CHARS); + }); +} + +test("valid SESSION_CONTEXT_MAX_CHARS is honored", () => { + assert.strictEqual(parseSessionContextMaxChars("25000"), 25000); + assert.strictEqual(parseSessionContextMaxChars(" 12000 "), 12000); +}); + console.log("\nsession-message route shape:"); const sessionMessageRoute = route("session-message"); @@ -110,7 +128,8 @@ test("x402-paid session-create cannot store a premium model", () => { test("upstream calls send the trimmed window, not the raw stored history", () => { assert.match(sessionMessageRoute, /trimSessionContext\(session\.messages, SESSION_CONTEXT_MAX_CHARS\)/); - assert.match(sessionMessageRoute, /SESSION_CONTEXT_MAX_CHARS = parseInt\(process\.env\.SESSION_CONTEXT_MAX_CHARS \?\? "40000", 10\)/); + assert.match(sessionMessageRoute, /SESSION_CONTEXT_MAX_CHARS = parseSessionContextMaxChars\(process\.env\.SESSION_CONTEXT_MAX_CHARS\)/); + assert.ok(!/SESSION_CONTEXT_MAX_CHARS = parseInt/.test(sessionMessageRoute), "bare parseInt would let NaN disable trimming"); assert.match(sessionMessageRoute, /messages: upstreamMessages\.map/); assert.match(sessionMessageRoute, /\.\.\.upstreamMessages\.map/); assert.ok(!/messages: session\.messages\.map/.test(sessionMessageRoute), "raw history must not be sent to Anthropic");