Skip to content
Merged
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
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
"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/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",
"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/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",
"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",
"test:oauth-refresh-race": "node tests/oauth-refresh-race.test.js",
"test:pages": "node tests/pages.test.js",
"test:rate-limit": "node tests/global-rate-limit.test.js",
Expand Down
37 changes: 37 additions & 0 deletions api/src/assets/signupHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
// is safe to embed in a double-quoted href.
var oauthNext = '';

// Email of the account created in THIS page session (set on successful
// register) — powers the verify-note resend link. Empty on the OAuth
// ?key= arrival path, where the email isn't known, so no link renders.
var signupEmail = '';

(function() {
const params = new URLSearchParams(window.location.search);
const preKey = params.get('key');
Expand Down Expand Up @@ -224,6 +229,12 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
refNote = '<div style="font-size:12px;color:#34d399;margin-bottom:12px;background:rgba(52,211,153,0.08);border:1px solid rgba(52,211,153,0.25);border-radius:10px;padding:10px 12px">&#127873; Referral code <strong>' + savedRef + '</strong> saved. Verify your email, then apply it from your dashboard — you and your referrer each get bonus credits.</div>';
}
} catch(_) {}
// Verify note + resend recovery (only when we know the email that was
// just registered — not on the OAuth ?key= arrival path).
var verifyNote = '';
if (signupEmail) {
verifyNote = '<div id="verify-note" style="font-size:12px;color:rgba(255,255,255,0.5);margin-bottom:12px">Next: verify your email from the link we just sent to unlock your remaining pending credits. Didn&#39;t get the email? <a href="#" id="resend-verify-link" style="color:#22d3ee">Resend it</a>.</div>';
}
// OAuth resume: opt-in link back to the preserved /oauth/authorize URL
// so consent can finish. oauthNext was validated at load (same-origin
// authorize path only, no quote/angle/whitespace chars).
Expand All @@ -238,6 +249,7 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
'<div id="api-key-box" class="mono" style="background:rgba(0,0,0,0.4);border:1px solid rgba(0,229,176,0.35);padding:10px 14px;border-radius:10px;word-break:break-all;font-size:13px;margin-bottom:12px;user-select:all;color:#e0ffe0">' + apiKey + '</div>' +
'<button id="copy-btn" class="copy-btn-full">Copy API Key</button>' +
'<div style="font-size:12px;color:rgba(255,255,255,0.5);margin-bottom:12px">You have <strong style="color:#f0f0f6">' + credits + ' credits</strong> to get started. Refreshed monthly on the free plan. No subscription required.</div>' +
verifyNote +
refNote +
resumeCta +
'<div style="border-top:1px solid rgba(255,255,255,0.1);margin:14px 0 0;padding-top:14px">' +
Expand All @@ -263,6 +275,29 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
});
});
}
// Resend wiring: fires ONLY from an explicit click (never auto), posts
// the email as JSON, and mirrors the server's neutral message via
// textContent so nothing from the response is interpreted as markup.
var resendLink = document.getElementById('resend-verify-link');
if (resendLink) {
resendLink.addEventListener('click', function(e) {
e.preventDefault();
resendLink.textContent = 'Sending...';
fetch('/v1/agent/verify-email/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: signupEmail })
}).then(function(r) {
return r.json().catch(function() { return {}; });
}).then(function(d) {
var note = document.getElementById('verify-note');
if (note) note.textContent = (d && d.message) ? String(d.message) : 'Requested — check your inbox and spam folder.';
}).catch(function() {
var note = document.getElementById('verify-note');
if (note) note.textContent = 'Could not reach the server — please try again in a minute.';
});
});
}
// Curl example with the fresh key prefilled. Set via textContent (never
// string-built HTML) so the key can never be interpreted as markup.
var curlEl = document.getElementById('first-call-curl');
Expand Down Expand Up @@ -361,6 +396,8 @@ export const SIGNUP_HTML = `<!DOCTYPE html>
let data;
try { data = await res.json(); } catch(_) { data = {}; }
if (res.ok && data.api_key) {
// Remember the email for the verify-note resend link.
signupEmail = email;
// If password provided, set it and log in via session cookie
const pw = (document.getElementById('password').value || '').trim();
if (pw && pw.length >= 8) {
Expand Down
31 changes: 30 additions & 1 deletion api/src/assets/verifyEmailHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,42 @@ export function renderVerifyActivationPage(creditsActivated: number): string {
`);
}

/** Invalid / expired / already-used token. */
/**
* Invalid / expired / already-used token — with a script-free recovery form
* that POSTs to /v1/agent/verify-email/resend (plain HTML form: these pages
* are reachable from an emailed link, so zero JavaScript by design).
*/
export function renderVerifyErrorPage(): string {
return pageShell("Link invalid or expired", `
<h1 class="card-title">Link invalid or expired</h1>
<p class="card-sub">This verification link is invalid, expired, or was already used.
If you clicked it before, your email may already be verified — sign in to your
<a href="${SITE}/dashboard" style="color:#22d3ee;text-decoration:none;">dashboard</a> to check your status and credit balance.</p>
<a class="btn-primary" href="${SITE}/dashboard">Open dashboard →</a>
<div class="cta" style="margin-top:22px;">
<h3>Didn't get the email — or did the link expire?</h3>
<p>Enter your account email and we'll send a fresh verification link (valid for 30 minutes).</p>
<form method="POST" action="/v1/agent/verify-email/resend">
<input type="email" name="email" required maxlength="254" placeholder="you@company.com" autocomplete="email"
style="width:100%;height:44px;border-radius:10px;border:1px solid var(--border);background:rgba(0,0,0,0.3);color:var(--text);padding:0 14px;font-family:inherit;font-size:13px;outline:none;margin-bottom:10px;">
<button type="submit" class="btn-primary" style="height:44px;line-height:44px;">Resend verification email</button>
</form>
</div>
`);
}

/**
* Neutral resend confirmation (anti-enumeration) — rendered for browser form
* posts to /v1/agent/verify-email/resend. IDENTICAL for every input: it never
* confirms whether an account exists, is verified, or was inside the resend
* cooldown. Script-free like every page reachable from an emailed link.
*/
export function renderVerifyResendSentPage(): string {
return pageShell("Check your inbox", `
<h1 class="card-title">Check your inbox</h1>
<p class="card-sub">If an unverified account exists for that email, a new verification
link is on its way (valid for 30 minutes). Don't see it after a couple of minutes?
Check your spam folder.</p>
<a class="btn-primary" href="${SITE}/dashboard">Open dashboard →</a>
`);
}
105 changes: 104 additions & 1 deletion api/src/lib/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ export function recordSignupIp(ip: string | undefined): void {
* Set up the verification gate for a freshly-created agent: activates a small
* starter allowance immediately (SIGNUP_STARTER_CREDITS), moves the remainder
* of the grant into pendingCredits, issues a token, sends the email.
* Non-fatal on email failure (token can be re-issued via /v1/verify-email/resend).
* Non-fatal on email failure (token can be re-issued via
* POST /v1/agent/verify-email/resend → reissueEmailVerification below).
*
* The whole grant (starter + pending) is gated by an ATOMIC claim on the
* normalized email identity (SignupIdentity unique insert). If the identity
Expand Down Expand Up @@ -247,6 +248,108 @@ export async function issueEmailVerification(
return { starter, pending };
}

// ─── Verification resend (recovery for expired/lost tokens) ──────────────

/**
* Resend rate limit: N requests per calendar hour per (normalized email + IP)
* key. Same in-process Map pattern as the per-IP signup counter above —
* conservative, resets on deploy, counts EVERY attempt (before any DB read)
* so the limiter itself can never become an account-enumeration oracle.
*/
const VERIFY_RESEND_MAX_PER_HOUR = parseInt(process.env.VERIFY_RESEND_MAX_PER_HOUR ?? "3", 10);
const resendCounts = new Map<string, { hour: string; count: number }>();

export function allowVerificationResend(email: string, ip: string | undefined): boolean {
const hour = new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
const key = `${normalizeEmailIdentity(email)}|${ip ?? "unknown"}`;
const entry = resendCounts.get(key);
const count = entry && entry.hour === hour ? entry.count : 0;
if (count >= VERIFY_RESEND_MAX_PER_HOUR) return false;
resendCounts.set(key, { hour, count: count + 1 });
// Opportunistic cleanup to bound memory (mirrors ipSignupCounts)
if (resendCounts.size > 10000) {
for (const [k, v] of resendCounts) {
if (v.hour !== hour) resendCounts.delete(k);
}
}
return true;
}

/**
* Cooldown between token issues: if the CURRENT token was minted within this
* window, a resend is silently skipped — the email is already on its way, and
* re-minting on every click would let the endpoint be used as a mail cannon
* inside the hourly rate window.
*/
export const VERIFY_RESEND_COOLDOWN_MS = parseInt(
process.env.VERIFY_RESEND_COOLDOWN_MS ?? String(2 * 60 * 1000),
10
);

/**
* Re-issue a verification token for an existing UNVERIFIED account (recovery
* path for expired/lost tokens: POST /v1/agent/verify-email/resend).
*
* ⚠️ Deliberately NOT issueEmailVerification(): that function is the SIGNUP
* grant-splitter — on a non-fresh agent the identity claim has already been
* consumed, so it would compute pending=0 and SET pendingCredits to 0, wiping
* the user's gated grant. This function rotates ONLY verifyToken +
* verifyTokenExpiry and never touches any credit field.
*
* Silent no-op (returns false) when the account doesn't exist, is already
* verified, or a live token is inside the resend cooldown — callers must NOT
* branch their response on the return value (anti-enumeration).
*/
export async function reissueEmailVerification(email: string): Promise<boolean> {
// Resolve by NORMALIZED identity (same SQL as enforceSignupLimits), not an
// exact match: signup stores the raw lowercased input, so the stored address
// may be a Gmail dot/plus/googlemail alias of what the user types here.
// Prefer an exact match, then the oldest account, for determinism; the mail
// goes to the STORED Agent.email, never the submitted string.
const lowered = email.toLowerCase().trim();
const normalized = normalizeEmailIdentity(email);
const rows = await prisma.$queryRaw<
{ id: string; email: string; email_verified: boolean; pending_credits: number; verify_token_expiry: Date | null }[]
>`
SELECT "id", "email", "email_verified", "pending_credits", "verify_token_expiry" FROM "Agent"
WHERE (
CASE WHEN lower(split_part(email, '@', 2)) IN ('gmail.com', 'googlemail.com')
THEN replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@gmail.com'
ELSE lower(split_part(email, '@', 1)) || '@' || lower(split_part(email, '@', 2))
END
) = ${normalized}
ORDER BY (lower(email) = ${lowered}) DESC, "createdAt" ASC
LIMIT 1`;
const row = rows[0];
if (!row || row.email_verified) return false;
const agent = {
id: row.id,
email: row.email,
pendingCredits: row.pending_credits,
verifyTokenExpiry: row.verify_token_expiry,
};
if (agent.verifyTokenExpiry) {
// Tokens live VERIFY_TOKEN_TTL_MS, so issue time = expiry − TTL. An
// EXPIRED token was minted ≥30 min ago and always passes this gate.
const issuedAt = agent.verifyTokenExpiry.getTime() - VERIFY_TOKEN_TTL_MS;
if (Date.now() - issuedAt < VERIFY_RESEND_COOLDOWN_MS) return false;
}
const token = crypto.randomBytes(32).toString("hex");
await prisma.agent.update({
where: { id: agent.id },
data: {
verifyToken: token,
verifyTokenExpiry: new Date(Date.now() + VERIFY_TOKEN_TTL_MS),
},
});
const verifyUrl = `https://archtools.dev/v1/agent/verify-email?token=${token}`;
sendVerificationEmail({ to: agent.email, verifyUrl, pendingCredits: agent.pendingCredits }).catch((e) => {
logger.warn({ agentId: agent.id, error: String(e) }, "Verification resend email failed");
});
logger.info({ agentId: agent.id }, "Verification token re-issued");
return true;
}

/**
* Non-consuming validity check for a verify token (used by the GET confirm
* page). Returns the pending credits the token would activate, or null if the
Expand Down
50 changes: 46 additions & 4 deletions api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { stripe } from "../lib/stripe.js";
import Stripe from "stripe";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity } from "../lib/verification.js";
import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage } from "../assets/verifyEmailHtml.js";
import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity, allowVerificationResend, reissueEmailVerification } from "../lib/verification.js";
import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage, renderVerifyResendSentPage } from "../assets/verifyEmailHtml.js";
import { REFERRAL_REWARD } from "../lib/referralReward.js";

const router = Router();
Expand Down Expand Up @@ -186,8 +186,9 @@ router.post("/register", async (req: Request, res: Response): Promise<void> => {
gatedCredits = grant.pending;
} catch (e) {
// Fail closed: do NOT grant credits if the verification gate could not be
// set up. The user can re-trigger via the resend endpoint.
console.error("Verification setup failed (credits remain 0, user can resend):", e);
// set up. Recovery: POST /v1/agent/verify-email/resend (below) re-issues
// the token for any unverified account.
console.error("Verification setup failed (credits remain 0, user can resend via /v1/agent/verify-email/resend):", e);
}

res.status(201).json({
Expand Down Expand Up @@ -285,6 +286,47 @@ router.post("/verify-email", async (req: Request, res: Response): Promise<void>
}
});

// POST /v1/agent/verify-email/resend — recovery for expired/lost verification
// links: re-issues the token for an existing UNVERIFIED account and re-sends
// the email. ANTI-ENUMERATION: the response is ALWAYS the same neutral 200
// (JSON for API clients, a script-free HTML page for browser form posts)
// whether the account exists, is already verified, or is inside the resend
// cooldown — nothing about account state is ever revealed. The only non-200s
// are 400 (no usable email supplied) and 429 (rate limit, counted on the
// SUBMITTED email+IP BEFORE any DB read, so it can't leak existence either).
router.post("/verify-email/resend", async (req: Request, res: Response): Promise<void> => {
const email = String((req.body?.email ?? "")).toLowerCase().trim();
// Browser form posts (Accept: text/html) get a page; fetch/API clients
// (Accept: */* or application/json) get JSON — json listed first wins */*.
const wantsHtml = req.accepts(["json", "html"]) === "html";
if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ ok: false, error: "invalid_request", message: "A valid email is required.", request_id: reqId() });
return;
}
if (!allowVerificationResend(email, req.ip)) {
res.status(429).json({ ok: false, error: "rate_limited", message: "Too many resend requests for this email. Try again in an hour.", request_id: reqId() });
return;
}
try {
// Return value intentionally ignored — the response below is identical
// for every internal outcome (see anti-enumeration note above).
await reissueEmailVerification(email);
} catch (e) {
// Still neutral: an internal failure must not become an enumeration or
// availability oracle.
console.error("verify-email resend error:", e);
}
if (wantsHtml) {
res.send(renderVerifyResendSentPage());
return;
}
res.json({
ok: true,
message: "If an unverified account exists for that email, a new verification link has been sent. Check your inbox and spam folder.",
request_id: reqId(),
});
});

// GET /v1/agent/usage
router.get("/usage", requireAuth, async (req: AuthedRequest, res: Response): Promise<void> => {
const agent = req.agent;
Expand Down
4 changes: 3 additions & 1 deletion api/src/routes/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ router.post("/register", async (req: Request, res: Response): Promise<void> => {
starterCredits = grant.starter;
gatedCredits = grant.pending;
} catch (e) {
console.error("Verification setup failed (credits remain 0, user can resend):", e);
// Recovery: POST /v1/agent/verify-email/resend re-issues the token for
// any unverified account.
console.error("Verification setup failed (credits remain 0, user can resend via /v1/agent/verify-email/resend):", e);
}

res.status(201).json({
Expand Down
Loading
Loading