Skip to content

Commit bd479f4

Browse files
committed
chore: split out refcode and reflinks into separate tables
1 parent 73b2a60 commit bd479f4

12 files changed

Lines changed: 2543 additions & 153 deletions

File tree

apps/api/src/routes/v1/widget/init.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ export default async function widgetInitRoutes(fastify: FastifyInstance) {
206206
participantId: participantRecord.id,
207207
programId: activeProgram.id,
208208
productId: productId,
209-
global: true,
210209
})
211210
.onConflictDoNothing()
212211
.returning();
@@ -266,26 +265,8 @@ export default async function widgetInitRoutes(fastify: FastifyInstance) {
266265
});
267266
}
268267

269-
// Build referral URL based on code type
270-
let referralUrl: string;
271-
if (refcodeRecord.global) {
272-
// Global code: /:code
273-
referralUrl = `${referralHostUrl}/${refcodeRecord.code}`;
274-
} else {
275-
// Local code: /:productSlug/:code (need product slug)
276-
const productData = await request.db.query.product.findFirst({
277-
where: (product, { eq }) => eq(product.id, productId),
278-
});
279-
280-
if (!productData?.slug) {
281-
return reply.code(500).send({
282-
error: "Internal Server Error",
283-
message: "Product slug not configured for local refcode",
284-
});
285-
}
286-
287-
referralUrl = `${referralHostUrl}/${productData.slug}/${refcodeRecord.code}`;
288-
}
268+
// Build referral URL (all refcodes now use the direct /:code pattern)
269+
const referralUrl = `${referralHostUrl}/${refcodeRecord.code}`;
289270

290271
// Return the widget configuration
291272
const response: WidgetInitResponseType = {

apps/api/test/unit/referral-attribution.test.ts

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe("Referral Attribution Logic", () => {
1515
participantId: referrerId,
1616
programId: "prg_1",
1717
productId: "prd_1",
18-
global: true,
1918
};
2019

2120
const mockNewReferral = {
@@ -124,7 +123,6 @@ describe("Referral Attribution Logic", () => {
124123
participantId: "participant_referrer",
125124
programId: "prg_1",
126125
productId: "prd_1",
127-
global: true,
128126
};
129127

130128
expect(refcode.code).toBe("abc123");
@@ -172,8 +170,8 @@ describe("Referral Attribution Logic", () => {
172170
});
173171

174172
describe("Product boundary enforcement (P1 Security Fix)", () => {
175-
it("should NOT allow cross-product attribution with global codes", async () => {
176-
// Scenario: Product A creates a global code, Product B tries to use it
173+
it("should NOT allow cross-product attribution with refcodes", async () => {
174+
// Scenario: Product A creates a refcode, Product B tries to use it
177175
const productA = "prd_AAA";
178176
const productB = "prd_BBB";
179177
const globalCode = "abc1234";
@@ -185,7 +183,6 @@ describe("Referral Attribution Logic", () => {
185183
participantId: "participant_A",
186184
programId: "prg_A",
187185
productId: productA, // Belongs to Product A
188-
global: true,
189186
};
190187

191188
// When Product B's widget init is called with this code
@@ -216,26 +213,25 @@ describe("Referral Attribution Logic", () => {
216213
expect(resultForProductA?.productId).toBe(productA);
217214
});
218215

219-
it("should enforce product boundary even when global flag is true", () => {
220-
// The global flag only means:
221-
// 1. The code uses 7-character format
222-
// 2. The code is globally unique
216+
it("should enforce product boundary for all refcodes", () => {
217+
// All refcodes are:
218+
// 1. Auto-generated 7-character format
219+
// 2. Globally unique
223220
//
224-
// It does NOT mean:
225-
// 1. The code can be used across products
226-
// 2. Attribution should ignore productId
221+
// But they are STILL:
222+
// 1. Bound to a specific product
223+
// 2. Can only be used for attribution within that product
227224

228-
const globalRefcode = {
225+
const refcodeData = {
229226
code: "xyz9876",
230227
productId: "prd_A",
231-
global: true, // Global format, but still belongs to a specific product
232228
};
233229

234230
// The query should ALWAYS check productId
235-
expect(globalRefcode.productId).toBeDefined();
236-
expect(globalRefcode.productId).toBe("prd_A");
231+
expect(refcodeData.productId).toBeDefined();
232+
expect(refcodeData.productId).toBe("prd_A");
237233

238-
// Even though it's global, it can only attribute within Product A
234+
// Refcodes can only attribute within their assigned Product
239235
});
240236

241237
it("should prevent referral creation across product boundaries", async () => {
@@ -252,7 +248,6 @@ describe("Referral Attribution Logic", () => {
252248
code: "abc1234",
253249
participantId: participantA.id,
254250
productId: productA,
255-
global: true,
256251
};
257252

258253
// Product B's new user trying to sign up with Product A's code
@@ -292,20 +287,20 @@ describe("Referral Attribution Logic", () => {
292287
// Solution: Always enforce product boundary
293288
// Result: No cross-product attribution
294289

295-
const vulnerableQuery = {
296-
before: "WHERE code = ? AND (global = true OR productId = ?)",
297-
problem: "global=true bypasses productId check",
298-
vulnerability: "Cross-product attribution",
299-
};
300-
301-
const fixedQuery = {
302-
after: "WHERE code = ? AND productId = ?",
290+
const correctQuery = {
291+
query: "WHERE code = ? AND productId = ?",
303292
solution: "Always enforce product boundary",
304293
result: "Multi-tenancy isolation maintained",
305294
};
306295

307-
expect(vulnerableQuery.problem).toContain("bypasses productId");
308-
expect(fixedQuery.solution).toContain("enforce product boundary");
296+
const incorrectQuery = {
297+
query: "WHERE code = ? WITHOUT productId check",
298+
problem: "Could allow cross-product attribution",
299+
vulnerability: "Multi-tenancy breach",
300+
};
301+
302+
expect(incorrectQuery.problem).toContain("cross-product");
303+
expect(correctQuery.solution).toContain("enforce product boundary");
309304
});
310305
});
311306
});

apps/refer/src/routes/r.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
22
import { schema } from "@refref/coredb";
3-
const { refcode, product } = schema;
3+
const { refcode, product, reflink } = schema;
44
import { eq, and } from "drizzle-orm";
55
import { normalizeCode } from "@refref/utils";
66
import type { ProgramConfigV1Type } from "@refref/types";
@@ -16,10 +16,10 @@ interface LocalCodeParams {
1616

1717
export default async function referralRedirectRoutes(fastify: FastifyInstance) {
1818
/**
19-
* Handles GET requests to /r/:code (global codes)
19+
* Handles GET requests to /r/:code (auto-generated refcodes)
2020
* Example: /r/abc1234
2121
*
22-
* Global codes are unique across the entire system and don't require product context.
22+
* Auto-generated codes are unique across the entire system and don't require product context.
2323
*/
2424
fastify.get<{ Params: GlobalCodeParams }>(
2525
"/:code",
@@ -42,10 +42,7 @@ export default async function referralRedirectRoutes(fastify: FastifyInstance) {
4242
// Single optimized query using relations
4343
// This does a JOIN under the hood: refcode → participant → program
4444
const result = await request.db.query.refcode.findFirst({
45-
where: and(
46-
eq(refcode.code, normalizedCode),
47-
eq(refcode.global, true),
48-
),
45+
where: eq(refcode.code, normalizedCode),
4946
with: {
5047
participant: true,
5148
program: true,
@@ -103,10 +100,10 @@ export default async function referralRedirectRoutes(fastify: FastifyInstance) {
103100
);
104101

105102
/**
106-
* Handles GET requests to /r/:productSlug/:code (local/product-scoped codes)
103+
* Handles GET requests to /r/:productSlug/:code (vanity links via reflink table)
107104
* Example: /r/acme/john-doe
108105
*
109-
* Local codes are unique within a product and require the product slug for disambiguation.
106+
* Vanity links are unique within a product and require the product slug for disambiguation.
110107
* This allows for vanity URLs like /r/acme/ceo or /r/startup/founder
111108
*/
112109
fastify.get<{ Params: LocalCodeParams }>(
@@ -136,24 +133,28 @@ export default async function referralRedirectRoutes(fastify: FastifyInstance) {
136133
return reply.code(404).send({ error: "Product not found" });
137134
}
138135

139-
// Single optimized query using relations
140-
// This does a JOIN under the hood: refcode → participant → program
141-
const result = await request.db.query.refcode.findFirst({
136+
// Look up the vanity link in the reflink table
137+
const reflinkResult = await request.db.query.reflink.findFirst({
142138
where: and(
143-
eq(refcode.code, normalizedCode),
144-
eq(refcode.productId, productRecord.id),
145-
eq(refcode.global, false),
139+
eq(reflink.slug, normalizedCode),
140+
eq(reflink.productId, productRecord.id),
146141
),
147142
with: {
148-
participant: true,
149-
program: true,
143+
refcode: {
144+
with: {
145+
participant: true,
146+
program: true,
147+
},
148+
},
150149
},
151150
});
152151

153-
if (!result || !result.participant) {
154-
return reply.code(404).send({ error: "Referral code not found" });
152+
if (!reflinkResult || !reflinkResult.refcode || !reflinkResult.refcode.participant) {
153+
return reply.code(404).send({ error: "Referral link not found" });
155154
}
156155

156+
const result = reflinkResult.refcode;
157+
157158
// Type assertion needed due to Drizzle's type inference limitations with nested relations
158159
// eslint-disable-next-line @typescript-eslint/no-explicit-any
159160
const participantRecord = result.participant as any;
@@ -187,7 +188,7 @@ export default async function referralRedirectRoutes(fastify: FastifyInstance) {
187188
Object.entries(paramsObj).forEach(([key, value]) => {
188189
if (value) searchParams.set(key, value);
189190
});
190-
searchParams.set("refcode", normalizedCode);
191+
searchParams.set("refcode", result.code);
191192

192193
// Redirect with 307 to the product URL with encoded params
193194
return reply

0 commit comments

Comments
 (0)