diff --git a/api/package.json b/api/package.json index 3cfdc408..2d3a3a1d 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/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", + "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/referral-reward.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 && node tests/intent-funnel.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/lib/referralReward.ts b/api/src/lib/referralReward.ts index fdc6c5bd..dbb143b4 100644 --- a/api/src/lib/referralReward.ts +++ b/api/src/lib/referralReward.ts @@ -55,6 +55,8 @@ const DAILY_CAP_RESULT: ApplyReferralResult = { message: "This referral code has reached its daily reward limit. Try again tomorrow.", }; +const DELETED_AGENT_EMAIL_SUFFIX = "@deleted.invalid"; + // Internal sentinel: thrown inside the reward transaction to roll it back when // the in-transaction cap re-check fails under concurrency. class DailyCapExceeded extends Error {} @@ -63,7 +65,7 @@ export async function applyReferralCode(agentId: string, rawCode: string): Promi const code = rawCode.trim(); const referral = await prisma.referral.findFirst({ - where: { code: { equals: code, mode: "insensitive" }, referredId: null }, + where: { code: { equals: code, mode: "insensitive" }, referredId: null, status: "pending" }, }); if (!referral) { return { ok: false, status: 404, error: "invalid_code", message: "Invalid referral code." }; @@ -78,6 +80,13 @@ export async function applyReferralCode(agentId: string, rawCode: string): Promi prisma.agent.findUnique({ where: { id: referral.referrerId }, select: { email: true } }), ]); + // Deletion anonymizes retained financial rows in place. Legacy shareable + // referral rows may still point at that anonymized Agent, so treat them as + // invalid codes instead of letting a deleted account mint referral credits. + if (!referrer || referrer.email.endsWith(DELETED_AGENT_EMAIL_SUFFIX)) { + return { ok: false, status: 404, error: "invalid_code", message: "Invalid referral code." }; + } + // Anti-farming: the referred account must have a verified email before any // credits are granted. Unverified accounts hold almost no credits anyway. if (!referred?.emailVerified) { diff --git a/api/src/routes/agent.ts b/api/src/routes/agent.ts index 3fafb1d1..69570e48 100644 --- a/api/src/routes/agent.ts +++ b/api/src/routes/agent.ts @@ -539,6 +539,10 @@ router.delete("/", requireAuth, requireApiKeyAuth, async (req: AuthedRequest, re const reqs = await tx.apiRequest.deleteMany({ where: { agentId: agent.id } }); const toks = await tx.oAuthToken.deleteMany({ where: { agentId: agent.id } }); const codes = await tx.oAuthAuthCode.deleteMany({ where: { agentId: agent.id } }); + const referralCodes = await tx.referral.updateMany({ + where: { referrerId: agent.id, referredId: null, status: "pending" }, + data: { status: "expired" }, + }); // Erase the email suppression record so no plaintext email remains (GDPR erasure // wins over the bounded free-signup-again risk). const ident = email ? await tx.signupIdentity.deleteMany({ where: { normalizedEmail: normalizeEmailIdentity(email) } }) : { count: 0 }; @@ -557,7 +561,14 @@ router.delete("/", requireAuth, requireApiKeyAuth, async (req: AuthedRequest, re emailVerified: false, isPublic: false, }, }); - const counts = { apiRequests: reqs.count, oauthTokens: toks.count, oauthCodes: codes.count, signupIdentity: ident.count, stripeSubscriptions: canceledSubscriptions }; + const counts = { + apiRequests: reqs.count, + oauthTokens: toks.count, + oauthCodes: codes.count, + signupIdentity: ident.count, + referralCodesExpired: referralCodes.count, + stripeSubscriptions: canceledSubscriptions, + }; // Durable deletion audit trail (GDPR Art.5(2) accountability). Written // INSIDE the transaction so a deletion can never commit without its audit diff --git a/api/tests/critical-regressions.test.mjs b/api/tests/critical-regressions.test.mjs index 5d4b4609..6a3e5028 100644 --- a/api/tests/critical-regressions.test.mjs +++ b/api/tests/critical-regressions.test.mjs @@ -62,6 +62,15 @@ test("account deletion cancels Stripe subscriptions before local anonymization", assert.match(agentSrc, /BILLABLE_SUBSCRIPTION_STATUSES[\s\S]*active[\s\S]*trialing[\s\S]*past_due[\s\S]*unpaid/); }); +test("account deletion expires pending shareable referral codes", () => { + assert.match( + agentSrc, + /tx\.referral\.updateMany\(\{\s*where:\s*\{\s*referrerId:\s*agent\.id,\s*referredId:\s*null,\s*status:\s*"pending"\s*\},\s*data:\s*\{\s*status:\s*"expired"\s*\}/, + "DELETE /v1/agent must expire reusable shareable referral codes before anonymizing the account", + ); + assert.ok(agentSrc.includes("referralCodesExpired: referralCodes.count"), "deletion audit summary must include expired referral-code count"); +}); + test("seed catalog advertises the audited default/base prices actually charged", () => { assert.strictEqual(seedCredits("web-search"), 14); assert.strictEqual(seedCredits("ocr-extract"), 12); diff --git a/api/tests/referral-reward.test.mjs b/api/tests/referral-reward.test.mjs index 247c35fd..bc5a66e2 100644 --- a/api/tests/referral-reward.test.mjs +++ b/api/tests/referral-reward.test.mjs @@ -45,7 +45,13 @@ function reset() { prisma.referral.findFirst = async (args) => { const where = args?.where ?? {}; - if (where.code !== undefined) { capturedCodeWhere = where; return codeRow; } + if (where.code !== undefined) { + capturedCodeWhere = where; + if (!codeRow) return null; + if (where.status !== undefined && codeRow.status !== where.status) return null; + if (where.referredId === null && codeRow.referredId != null) return null; + return codeRow; + } if (where.referredId !== undefined) return alreadyRow; return null; }; @@ -71,6 +77,7 @@ assert(r.ok && r.reward === REFERRAL_REWARD, `reward = ${REFERRAL_REWARD}`); assert(capturedCodeWhere?.code?.mode === "insensitive", "code lookup is case-insensitive (codes are lowercase hex, inputs may be uppercased)"); assert(capturedCodeWhere?.code?.equals === "ARCH-A1B2C3D4", "input is trimmed before lookup"); assert(capturedCodeWhere?.referredId === null, "lookup excludes internal referred- completion records"); +assert(capturedCodeWhere?.status === "pending", "lookup excludes expired shareable referral codes"); assert(createdRows.length === 1 && createdRows[0].status === "completed" && createdRows[0].code === "referred-referred-9", "completion record is unique-keyed on the referred account (atomic single-use)"); assert(agentUpdates.length === 2, "exactly two credit grants (referrer + referee)"); @@ -86,12 +93,26 @@ r = await applyReferralCode("referred-9", "ARCH-nope0000"); assert(r.ok === false && r.error === "invalid_code" && r.status === 404, "unknown code → invalid_code (404)"); assert(agentUpdates.length === 0, "no credits granted on invalid code"); -// Case 3: self-referral by account id +// Case 3: expired shareable code +reset(); +codeRow.status = "expired"; +r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); +assert(r.ok === false && r.error === "invalid_code" && r.status === 404, "expired shareable code → invalid_code"); +assert(agentUpdates.length === 0, "no credits granted from an expired code"); + +// Case 4: deleted referrer behind legacy pending code +reset(); +agents["referrer-1"] = { email: "deleted-referrer-1@deleted.invalid" }; +r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); +assert(r.ok === false && r.error === "invalid_code" && r.status === 404, "deleted referrer's legacy code → invalid_code"); +assert(agentUpdates.length === 0, "no credits granted from a deleted referrer"); + +// Case 5: self-referral by account id reset(); r = await applyReferralCode("referrer-1", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "self_referral", "own code → self_referral"); -// Case 4: self-referral by normalized email identity (gmail +alias / dots) +// Case 6: self-referral by normalized email identity (gmail +alias / dots) reset(); agents["referrer-1"] = { email: "same.person@gmail.com" }; agents["referred-9"] = { emailVerified: true, email: "sameperson+farm@gmail.com" }; @@ -99,39 +120,39 @@ r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "self_referral", "same normalized gmail identity → self_referral (alias-farming blocked)"); assert(agentUpdates.length === 0, "no credits granted to an alias farm"); -// Case 4b: distinct identities on a non-gmail domain are allowed +// Case 6b: distinct identities on a non-gmail domain are allowed reset(); agents["referrer-1"] = { email: "a+x@fastmail.com" }; agents["referred-9"] = { emailVerified: true, email: "a+y@fastmail.com" }; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === true, "non-gmail +aliases are distinct identities (matches signup policy)"); -// Case 5: unverified referee +// Case 7: unverified referee reset(); agents["referred-9"] = { emailVerified: false, email: "bob@example.com" }; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "email_not_verified" && r.status === 403, "unverified email → email_not_verified (403)"); -// Case 6: one referral bonus per account +// Case 8: one referral bonus per account reset(); alreadyRow = { id: "prior", status: "completed" }; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "already_referred", "second apply → already_referred"); -// Case 7: per-referrer daily cap +// Case 9: per-referrer daily cap reset(); completedCount = REFERRAL_DAILY_CAP; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "referral_daily_cap" && r.status === 429, `referrer at ${REFERRAL_DAILY_CAP} rewarded referrals in 24h → referral_daily_cap (429)`); assert(agentUpdates.length === 0, "no credits granted past the daily cap"); -// Case 8: cap-1 still allowed +// Case 10: cap-1 still allowed reset(); completedCount = REFERRAL_DAILY_CAP - 1; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === true, "one under the daily cap still rewards"); -// Case 9: concurrent apply race — unique violation maps to already_referred +// Case 11: concurrent apply race — unique violation maps to already_referred reset(); const dup = new Error("duplicate key"); dup.code = "P2002"; @@ -139,7 +160,7 @@ createThrows = dup; r = await applyReferralCode("referred-9", "ARCH-a1b2c3d4"); assert(r.ok === false && r.error === "already_referred", "P2002 race loser → already_referred, not a 500"); -// Case 10: non-P2002 transaction failure propagates (fail loud) +// Case 12: non-P2002 transaction failure propagates (fail loud) reset(); createThrows = new Error("connection lost"); let threw = false;