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/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",
Expand Down
11 changes: 10 additions & 1 deletion api/src/lib/referralReward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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." };
Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions api/tests/critical-regressions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
41 changes: 31 additions & 10 deletions api/tests/referral-reward.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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-<id> 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)");
Expand All @@ -86,60 +93,74 @@ 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" };
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";
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;
Expand Down
Loading