Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 41 additions & 1 deletion api/src/assets/dashboardHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,12 @@ export const DASHBOARD_HTML = `<!DOCTYPE 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) {
Expand Down Expand Up @@ -196,6 +200,10 @@ export const DASHBOARD_HTML = `<!DOCTYPE html>
<!-- UNVERIFIED EMAIL — pending credits waiting -->
<div id="verify-banner" class="verify-banner" style="display:none">
<div>Your email isn&#39;t verified yet — verify it to unlock your <strong><span id="pending-credits">0</span> pending credits</strong>. Use the verification link we emailed you at signup.</div>
<div class="verify-actions">
<button id="resend-verify-btn" class="verify-btn" type="button">Resend email</button>
<span id="verify-resend-status" class="verify-status"></span>
</div>
</div>

<!-- ACTIVITY -->
Expand Down Expand Up @@ -336,6 +344,14 @@ export const DASHBOARD_HTML = `<!DOCTYPE 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";
Expand Down Expand Up @@ -383,6 +399,30 @@ export const DASHBOARD_HTML = `<!DOCTYPE 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";
Expand Down
9 changes: 9 additions & 0 deletions api/src/lib/sessionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends SessionContextMessage>(
messages: T[],
maxChars: number,
Expand Down
1 change: 1 addition & 0 deletions api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 50 additions & 5 deletions api/src/routes/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, any>> {
return (await resp.json().catch(() => ({}))) as Record<string, any>;
}

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, any>): 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]*\}/);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
61 changes: 61 additions & 0 deletions api/tests/ai-generate-provider-errors.test.mjs
Original file line number Diff line number Diff line change
@@ -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.");
6 changes: 5 additions & 1 deletion api/tests/intent-funnel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
Expand Down
Loading
Loading