From c0a5905ed60d77b2aad8da44665e9f8b9b1b4c05 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:09:09 +0000 Subject: [PATCH] fix(invites): one accepted invite per account (@profullstack/affiliates 0.3) Two confirmed sign-ins racing with different invite cookies could both pass the invitedBy() pre-check, both redeem, and both pay. affiliates 0.3 claims atomically (aff_claim_invite + unique index aff_invites_one_per_user), so the bonus now rests on redeemInvite's result alone: already_invited pays nothing and leaves the second invite unused. Migration 019 is the module's supabase-0.3.0-upgrade.sql (applied to prod by hand, 0 legacy duplicates). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 +-- package.json | 2 +- src/lib/__tests__/rewards.test.ts | 29 +++++++++++++++++++ src/lib/rewards.ts | 9 ++++-- supabase/migrations/019_affiliates_0_3.sql | 33 ++++++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/019_affiliates_0_3.sql diff --git a/bun.lock b/bun.lock index 2508f4e3..bd6ddf18 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "smshub", "dependencies": { - "@profullstack/affiliates": "^0.2.0", + "@profullstack/affiliates": "^0.3.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", "@profullstack/redeem-credits": "^0.2.0", @@ -287,7 +287,7 @@ "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="], - "@profullstack/affiliates": ["@profullstack/affiliates@0.2.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-fbP5uiS8Pa5g8mk5WFD79U4Cj7BUPH6Qy0tQP7ZXE9gfxHOHirHASmZ/rAk8MqnmzGOZTuwq9WwOHbmoFV2TSA=="], + "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], diff --git a/package.json b/package.json index e92508ee..f7b66579 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ] }, "dependencies": { - "@profullstack/affiliates": "^0.2.0", + "@profullstack/affiliates": "^0.3.0", "@profullstack/footer": "latest", "@profullstack/profile": "0.1.6", "@profullstack/redeem-credits": "^0.2.0", diff --git a/src/lib/__tests__/rewards.test.ts b/src/lib/__tests__/rewards.test.ts index d2fb6c5c..a4070687 100644 --- a/src/lib/__tests__/rewards.test.ts +++ b/src/lib/__tests__/rewards.test.ts @@ -86,6 +86,35 @@ describe("invites", () => { expect((await acceptInvite(fresh("bob"), inv.code, true, deps())).accepted).toBe(true); }); + it("two simultaneous sign-ins with different invites: one is accepted and paid, the other refused unused", async () => { + const a1 = await createInvite("alice", "x", "", aff); + const a2 = await createInvite("dan", "x", "", aff); + if (!a1.ok || !a2.ok) throw new Error("no invite"); + const [r1, r2] = await Promise.all([ + acceptInvite(fresh("bob"), a1.code, true, deps()), + acceptInvite(fresh("bob"), a2.code, true, deps()), + ]); + const outcomes = [r1, r2]; + expect(outcomes.filter((r) => r.accepted)).toHaveLength(1); + expect(outcomes.find((r) => !r.accepted)).toEqual({ accepted: false, reason: "already_invited" }); + // Bob and exactly one inviter are paid once; the losing invite is still unused and spendable. + expect(await credits.balance("bob", CREDIT_UNIT)).toBe(INVITE_BONUS_CENTS); + const paid = [await credits.balance("alice", CREDIT_UNIT), await credits.balance("dan", CREDIT_UNIT)].sort(); + expect(paid).toEqual([0, INVITE_BONUS_CENTS]); + const loser = r1.accepted ? a2 : a1; + expect((await acceptInvite(fresh("erin"), loser.code, true, deps())).accepted).toBe(true); + }); + + it("an account that already took an invite is refused a second one later, which stays unused", async () => { + const a1 = await createInvite("alice", "x", "", aff); + const a2 = await createInvite("dan", "x", "", aff); + if (!a1.ok || !a2.ok) throw new Error("no invite"); + expect((await acceptInvite(fresh("bob"), a1.code, true, deps())).accepted).toBe(true); + expect(await acceptInvite(fresh("bob"), a2.code, true, deps())).toEqual({ accepted: false, reason: "already_invited" }); + expect(await credits.balance("dan", CREDIT_UNIT)).toBe(0); + expect((await invitesSummary("dan", "x", aff)).invites[0].used).toBe(false); + }); + it("takes the invite from the cookie, else from signup metadata", () => { expect(inviteCodeFor("INV-AAAA-BBBB-CCCC", fresh("b"))).toBe("INV-AAAA-BBBB-CCCC"); expect(inviteCodeFor(null, fresh("b", { user_metadata: { invite_code: "INV-1111-2222-3333" } }))).toBe("INV-1111-2222-3333"); diff --git a/src/lib/rewards.ts b/src/lib/rewards.ts index 2638bdee..cac75a2a 100644 --- a/src/lib/rewards.ts +++ b/src/lib/rewards.ts @@ -177,8 +177,12 @@ export type InviteOutcome = /** * Spend an invite for a new account and credit both sides. Only called once the - * account passes the signup checks. The invite itself is single use, so the - * bonus can only be paid once per invite; an account redeems one invite. + * account passes the signup checks. The bonus rests on redeemInvite's answer + * alone: since @profullstack/affiliates 0.3 the database (aff_claim_invite plus + * the aff_invites_one_per_user unique index) lets an account take ONE invite, + * atomically, so two sign-ins racing with different invite cookies get one + * `ok` and one `already_invited` (no bonus, the second invite stays unused). + * A separate invitedBy() check first would only be a racy read, so there is none. */ export async function acceptInvite( user: SignupUser, @@ -189,7 +193,6 @@ export async function acceptInvite( if (!passesSignupChecks(user, hasProfile, deps.now)) return { accepted: false, reason: "checks" }; const a = deps.affiliates ?? getAffiliates(); const c = deps.credits ?? getCredits(); - if (await a.invitedBy(user.id)) return { accepted: false, reason: "already_invited" }; const r = await a.redeemInvite(code, user.id); if (!r.ok) return { accepted: false, reason: r.reason }; const grant = { unit: CREDIT_UNIT, amount: INVITE_BONUS_CENTS }; diff --git a/supabase/migrations/019_affiliates_0_3.sql b/supabase/migrations/019_affiliates_0_3.sql new file mode 100644 index 00000000..2b63eb26 --- /dev/null +++ b/supabase/migrations/019_affiliates_0_3.sql @@ -0,0 +1,33 @@ +-- @profullstack/affiliates 0.3.0 upgrade for a database on 0.2's migration (prefix aff_) +-- Generated by supabaseUpgradeSql('aff_'). Idempotent; the full migration includes the same statements. +-- 0.3.0: one invite per invitee. Upgrades a 0.2 database in place: earlier duplicate redemptions +-- are kept and marked legacy_dup = 1, then a unique index allows one redeemed invite per user. + +ALTER TABLE public.aff_invites ADD COLUMN IF NOT EXISTS legacy_dup INTEGER NOT NULL DEFAULT 0; +UPDATE public.aff_invites SET legacy_dup = 1 + WHERE used_by IS NOT NULL AND legacy_dup = 0 AND EXISTS ( + SELECT 1 FROM public.aff_invites o WHERE o.used_by = public.aff_invites.used_by + AND (o.used_at < public.aff_invites.used_at OR (o.used_at = public.aff_invites.used_at AND o.id < public.aff_invites.id))); +CREATE UNIQUE INDEX IF NOT EXISTS aff_invites_one_per_user ON public.aff_invites (used_by) + WHERE used_by IS NOT NULL AND legacy_dup = 0; + +-- CREATE OR REPLACE keeps the grants 0.2 set (service_role only). +CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $fn$ +BEGIN + -- One invite per invitee: NOT EXISTS refuses a repeat, and the unique index aff_invites_one_per_user + -- turns a concurrent repeat by the same user into a unique_violation, answered as FALSE. + UPDATE public.aff_invites SET used_by = p_user, used_at = p_at + WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL + AND NOT EXISTS (SELECT 1 FROM public.aff_invites prior WHERE prior.used_by = p_user); + RETURN FOUND; +EXCEPTION WHEN unique_violation THEN + RETURN FALSE; +END +$fn$; + +NOTIFY pgrst, 'reload schema';