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
31 changes: 25 additions & 6 deletions api/src/lib/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ export async function reissueEmailVerification(email: string): Promise<boolean>
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 }[]
{ id: string; email: string; email_verified: boolean; pending_credits: number; verify_token: string | null; verify_token_expiry: Date | null }[]
>`
SELECT "id", "email", "email_verified", "pending_credits", "verify_token_expiry" FROM "Agent"
SELECT "id", "email", "email_verified", "pending_credits", "verify_token", "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'
Expand All @@ -326,6 +326,7 @@ export async function reissueEmailVerification(email: string): Promise<boolean>
id: row.id,
email: row.email,
pendingCredits: row.pending_credits,
verifyToken: row.verify_token,
verifyTokenExpiry: row.verify_token_expiry,
};
if (agent.verifyTokenExpiry) {
Expand All @@ -343,10 +344,28 @@ export async function reissueEmailVerification(email: string): Promise<boolean>
},
});
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");
const restorePreviousToken = async (message: string, error?: unknown) => {
try {
await prisma.agent.updateMany({
where: { id: agent.id, verifyToken: token },
data: {
verifyToken: agent.verifyToken,
verifyTokenExpiry: agent.verifyTokenExpiry,
},
});
logger.warn({ agentId: agent.id, ...(error ? { error: String(error) } : {}) }, message);
} catch (restoreError) {
logger.error({ agentId: agent.id, error: String(restoreError) }, "Verification token rollback failed");
}
};
void sendVerificationEmail({ to: agent.email, verifyUrl, pendingCredits: agent.pendingCredits })
.then((sent) => {
if (!sent) {
return restorePreviousToken("Verification token reissue rolled back because email was not delivered");
}
logger.info({ agentId: agent.id }, "Verification token re-issued");
})
.catch((e) => restorePreviousToken("Verification token reissue rolled back because email send failed", e));
return true;
}

Expand Down
11 changes: 8 additions & 3 deletions api/src/services/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,16 @@ export function renderVerificationEmail(verifyUrl: string, pendingCredits?: numb
return { subject, html, text };
}

export async function sendVerificationEmail(args: { to: string; verifyUrl: string; pendingCredits?: number }): Promise<void> {
export async function sendVerificationEmail(args: { to: string; verifyUrl: string; pendingCredits?: number }): Promise<boolean> {
const { to, verifyUrl, pendingCredits } = args;
const { subject, html, text } = renderVerificationEmail(verifyUrl, pendingCredits);
await sendEmail(to, subject, html, text);
logger.info({ to }, "Verification email sent");
const sent = await sendEmail(to, subject, html, text);
if (sent) {
logger.info({ to }, "Verification email sent");
} else {
logger.warn({ to }, "Verification email not sent");
}
return sent;
}

// ─── 2. Welcome Email ───
Expand Down
81 changes: 81 additions & 0 deletions api/tests/verify-resend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@
import assert from "assert";

process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub";
process.env.RESEND_API_KEY = "test-resend-key";

const realFetch = globalThis.fetch.bind(globalThis);
let emailSendOk = true;
let emailSendGate = null;
let sentEmails = [];
globalThis.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input?.url;
if (url === "https://api.resend.com/emails") {
sentEmails.push(JSON.parse(String(init?.body ?? "{}")));
if (emailSendGate) await emailSendGate;
return new Response(emailSendOk ? "{}" : "resend unavailable", { status: emailSendOk ? 200 : 503 });
}
return realFetch(input, init);
};

const { prisma } = await import("../dist/lib/prisma.js");
const { renderVerifyErrorPage, renderVerifyResendSentPage } = await import("../dist/assets/verifyEmailHtml.js");
Expand Down Expand Up @@ -94,17 +109,20 @@ const BASE = `http://127.0.0.1:${server.address().port}`;
// with the raw snake_case column names.
let agentRow = null; // what the normalized-identity lookup returns
let updates = []; // captured update() calls
let updateManyCalls = []; // captured rollback updateMany() calls
let lookupParams = []; // captured $queryRaw bind values
const rawRow = () => ({
id: agentRow.id,
email: agentRow.email ?? "stored@example.com",
email_verified: agentRow.emailVerified,
pending_credits: agentRow.pendingCredits,
verify_token: agentRow.verifyToken ?? null,
verify_token_expiry: agentRow.verifyTokenExpiry,
});
const stubLookup = async (_strings, ...values) => { lookupParams = values; return agentRow ? [rawRow()] : []; };
prisma.$queryRaw = stubLookup;
prisma.agent.update = async (args) => { updates.push(args); return {}; };
prisma.agent.updateMany = async (args) => { updateManyCalls.push(args); return { count: 1 }; };

async function postResend(email, headers = {}) {
const res = await fetch(`${BASE}/v1/agent/verify-email/resend`, {
Expand All @@ -116,6 +134,21 @@ async function postResend(email, headers = {}) {
return { res, text };
}

function deferred() {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { promise, resolve };
}

async function waitFor(predicate, label, timeoutMs = 250) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(predicate(), label);
}

// The exact neutral body every internal outcome must produce (modulo request_id).
function neutralShape(text) {
const body = JSON.parse(text);
Expand All @@ -127,10 +160,14 @@ function neutralShape(text) {

await atest("happy path: unverified account + EXPIRED token → 200, single token-only rotation", async () => {
updates = [];
updateManyCalls = [];
sentEmails = [];
emailSendOk = true;
agentRow = {
id: "agent-resend-1",
emailVerified: false,
pendingCredits: 75,
verifyToken: "old-expired-token",
verifyTokenExpiry: new Date(Date.now() - 60_000), // expired 1 min ago (issued 31 min ago)
};
const { res, text } = await postResend("stranded@example.com");
Expand All @@ -145,6 +182,8 @@ await atest("happy path: unverified account + EXPIRED token → 200, single toke
for (const forbidden of ["credits", "pendingCredits", "emailVerified"]) {
assert.ok(!(forbidden in data), `resend must not write ${forbidden}`);
}
assert.strictEqual(updateManyCalls.length, 0, "successful delivery must not roll back the token");
assert.strictEqual(sentEmails.length, 1, "verification email sent once");
});

let happyBody;
Expand Down Expand Up @@ -188,10 +227,52 @@ await atest("cooldown: token minted moments ago → 200 but NOT re-minted", asyn

await atest("no-token unverified account (failed signup setup) → recoverable", async () => {
updates = [];
updateManyCalls = [];
emailSendOk = true;
agentRow = { id: "agent-notoken", emailVerified: false, pendingCredits: 0, verifyTokenExpiry: null };
const { res } = await postResend("failedsetup@example.com");
assert.strictEqual(res.status, 200);
assert.strictEqual(updates.length, 1, "token issued even when none existed");
assert.strictEqual(updateManyCalls.length, 0, "successful recovery must not roll back");
});

await atest("provider failure → neutral 200 before provider completes, then previous token restored", async () => {
updates = [];
updateManyCalls = [];
sentEmails = [];
emailSendOk = false;
const gate = deferred();
emailSendGate = gate.promise;
const oldExpiry = new Date(Date.now() + 10 * 60 * 1000); // live token, minted outside cooldown
agentRow = {
id: "agent-maildown",
email: "maildown@example.com",
emailVerified: false,
pendingCredits: 75,
verifyToken: "still-usable-token",
verifyTokenExpiry: oldExpiry,
};
const resendPromise = postResend("maildown@example.com");
const returnedBeforeProvider = await Promise.race([
resendPromise.then(() => true),
new Promise((resolve) => setTimeout(() => resolve(false), 50)),
]);
assert.strictEqual(returnedBeforeProvider, true, "neutral response must not wait on the email provider");
const { res, text } = await resendPromise;
assert.strictEqual(res.status, 200);
assert.strictEqual(neutralShape(text), happyBody);
assert.strictEqual(updates.length, 1, "resend attempts to mint a fresh token");
const mintedToken = updates[0].data.verifyToken;
assert.strictEqual(sentEmails.length, 1, "delivery was attempted");
assert.strictEqual(updateManyCalls.length, 0, "rollback waits for the provider result");
gate.resolve();
emailSendGate = null;
await waitFor(() => updateManyCalls.length === 1, "failed delivery rolls the token back");
assert.strictEqual(updateManyCalls.length, 1, "failed delivery rolls the token back");
assert.deepStrictEqual(updateManyCalls[0].where, { id: "agent-maildown", verifyToken: mintedToken });
assert.strictEqual(updateManyCalls[0].data.verifyToken, "still-usable-token");
assert.strictEqual(updateManyCalls[0].data.verifyTokenExpiry, oldExpiry);
emailSendOk = true;
});

await atest("gmail alias submitted → stored dotted account still found, token rotated", async () => {
Expand Down
Loading