@checodo/better-auth-referral 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,957 @@
1
+ import { t as REFERRAL_ERROR_CODES } from "./error-codes-Bw554PRu.js";
2
+ import { mergeSchema } from "better-auth/db";
3
+ import { getCurrentAdapter, queueAfterTransactionHook, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
4
+ import { APIError, createAuthEndpoint, getOAuthState, sessionMiddleware } from "better-auth/api";
5
+ import * as z from "zod";
6
+ // package.json
7
+ var version = "0.1.0";
8
+ // ../plugin-utils/src/admin.ts
9
+ /** Applies the defaults of `AdminOptions`. */
10
+ function resolveAdminOptions(options) {
11
+ return {
12
+ adminRoles: options.adminRoles ?? ["admin"],
13
+ adminUserIds: options.adminUserIds ?? []
14
+ };
15
+ }
16
+ /**
17
+ * Whether `user` is listed in `adminUserIds`, or has one of `adminRoles`
18
+ * in the admin plugin's `role` field, which holds several roles
19
+ * comma-separated (`"user, admin"`).
20
+ */
21
+ function isAdmin(user, options) {
22
+ if (options.adminUserIds.includes(user.id)) return true;
23
+ if (typeof user.role !== "string") return false;
24
+ return user.role.split(",").some((role) => options.adminRoles.includes(role.trim()));
25
+ }
26
+ // ../plugin-utils/src/options.ts
27
+ /**
28
+ * Returns a function that throws an option error prefixed with the plugin
29
+ * id, `[referral] "codeLength" must be ...`, for validating options at
30
+ * plugin creation.
31
+ */
32
+ function createFail(pluginId) {
33
+ return (message) => {
34
+ throw new Error(`[${pluginId}] ${message}`);
35
+ };
36
+ }
37
+ // src/options.ts
38
+ /** The step every referral starts with. Not configurable, and not stored. */
39
+ const SIGN_UP_STEP = "sign-up";
40
+ /** A step the plugin records itself, when the referred user's email is verified. */
41
+ const EMAIL_VERIFIED_STEP = "email-verified";
42
+ const fail = createFail("referral");
43
+ /** Resolves defaults and validates the options once, at plugin creation. */
44
+ function resolveOptions(options) {
45
+ const codeLength = options.codeLength ?? 8;
46
+ if (!Number.isInteger(codeLength) || codeLength < 6 || codeLength > 32) fail(`"codeLength" must be an integer between 6 and 32, got ${codeLength}.`);
47
+ const max = options.maxReferralsPerUser;
48
+ if (max !== void 0 && (!Number.isInteger(max) || max < 1)) fail(`"maxReferralsPerUser" must be a positive integer, got ${max}.`);
49
+ const steps = [...options.steps ?? []];
50
+ for (const step of steps) {
51
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(step) || step.length > 64) fail(`Invalid step ${JSON.stringify(step)}: use letters, digits, "-", and "_", up to 64 characters.`);
52
+ if (step === "sign-up") fail(`"${SIGN_UP_STEP}" is always the first step; don't list it in "steps".`);
53
+ }
54
+ if (new Set(steps).size !== steps.length) fail(`"steps" lists a step twice.`);
55
+ return {
56
+ codeLength,
57
+ generateCode: options.generateCode,
58
+ createCodeOnSignUp: options.createCodeOnSignUp ?? false,
59
+ steps,
60
+ stepsConfigured: options.steps !== void 0,
61
+ maxReferralsPerUser: max,
62
+ invalidCode: options.invalidCode ?? "ignore",
63
+ allowReferral: options.allowReferral,
64
+ onReferral: options.onReferral,
65
+ onStepCompleted: options.onStepCompleted,
66
+ onReferralCompleted: options.onReferralCompleted,
67
+ maskEmails: options.maskEmails ?? true,
68
+ hideNames: options.hideNames ?? false,
69
+ ...resolveAdminOptions(options)
70
+ };
71
+ }
72
+ /**
73
+ * Defaults that depend on the app's auth config, applied in the plugin's
74
+ * `init`: without explicit `steps`, apps that send verification emails
75
+ * require `email-verified`, so fake sign-ups don't complete referrals.
76
+ * Warns when referrals earn rewards but complete at sign-up.
77
+ */
78
+ function applyAuthDefaults(options, ctx) {
79
+ if (!options.stepsConfigured && ctx.options.emailVerification?.sendVerificationEmail) options.steps = [EMAIL_VERIFIED_STEP];
80
+ if (!options.stepsConfigured && options.steps.length === 0 && options.onReferralCompleted) ctx.logger.warn("[referral] Referrals complete at sign-up (no \"steps\"), so fake sign-ups earn rewards. Require at least \"email-verified\" (see \"Steps and rewards\" in the referral plugin docs).");
81
+ }
82
+ // ../plugin-utils/src/audit.ts
83
+ /**
84
+ * Records an event in the audit log plugin, if the app uses it. The audit
85
+ * log is found through `getPlugin`, like official plugins find each other,
86
+ * so it isn't a dependency. Does nothing outside a request. Never throws:
87
+ * the audit log's `recordEvent` doesn't.
88
+ */
89
+ async function recordAuditEvent(event) {
90
+ await (tryGetCurrentAuthEndpointContext()?.context.getPlugin("audit-log"))?.recordEvent?.(event);
91
+ }
92
+ // ../plugin-utils/src/callbacks.ts
93
+ /**
94
+ * Runs a user callback (`onJoin`, `onReferralCompleted`, ...) in the
95
+ * background when better-auth's `advanced.backgroundTasks` is configured,
96
+ * so response times don't depend on it, and awaits it otherwise. Errors
97
+ * are logged as `[<pluginId>] <name> threw an error` and never reach the
98
+ * client.
99
+ */
100
+ async function runCallback(context, pluginId, name, callback) {
101
+ const task = (async () => {
102
+ try {
103
+ await callback();
104
+ } catch (error) {
105
+ context.logger.error(`[${pluginId}] ${name} threw an error`, error);
106
+ }
107
+ })();
108
+ await context.runInBackgroundOrAwait(task);
109
+ }
110
+ // src/store.ts
111
+ async function findCodeByUserId(adapter, userId) {
112
+ return adapter.findOne({
113
+ model: "referralCode",
114
+ where: [{
115
+ field: "userId",
116
+ value: userId
117
+ }]
118
+ });
119
+ }
120
+ async function findCodeByCode(adapter, code) {
121
+ return adapter.findOne({
122
+ model: "referralCode",
123
+ where: [{
124
+ field: "code",
125
+ value: code
126
+ }]
127
+ });
128
+ }
129
+ async function createCode(adapter, input) {
130
+ return adapter.create({
131
+ model: "referralCode",
132
+ data: {
133
+ userId: input.userId,
134
+ code: input.code,
135
+ createdAt: /* @__PURE__ */ new Date()
136
+ }
137
+ });
138
+ }
139
+ async function replaceCode(adapter, id, code) {
140
+ return adapter.update({
141
+ model: "referralCode",
142
+ where: [{
143
+ field: "id",
144
+ value: id
145
+ }],
146
+ update: {
147
+ code,
148
+ createdAt: /* @__PURE__ */ new Date()
149
+ }
150
+ });
151
+ }
152
+ async function findReferralByReferredUserId(adapter, referredUserId) {
153
+ return adapter.findOne({
154
+ model: "referral",
155
+ where: [{
156
+ field: "referredUserId",
157
+ value: referredUserId
158
+ }]
159
+ });
160
+ }
161
+ async function createReferral(adapter, input) {
162
+ const now = /* @__PURE__ */ new Date();
163
+ return adapter.create({
164
+ model: "referral",
165
+ data: {
166
+ ...input,
167
+ completedAt: input.status === "completed" ? now : null,
168
+ createdAt: now
169
+ }
170
+ });
171
+ }
172
+ async function deleteReferral(adapter, id) {
173
+ await adapter.delete({
174
+ model: "referral",
175
+ where: [{
176
+ field: "id",
177
+ value: id
178
+ }]
179
+ });
180
+ }
181
+ /**
182
+ * Number of the referrer's referrals made before `referral`: earlier ones,
183
+ * and those made in the same millisecond with a smaller id. Gives every
184
+ * referral of a referrer a fixed place, so concurrent sign-ups agree on
185
+ * which of them are over `maxReferralsPerUser`.
186
+ */
187
+ async function countReferralsBefore(adapter, referral) {
188
+ const createdAt = new Date(referral.createdAt);
189
+ const referrer = {
190
+ field: "referrerUserId",
191
+ value: referral.referrerUserId
192
+ };
193
+ const [earlier, sameTime] = await Promise.all([adapter.count({
194
+ model: "referral",
195
+ where: [referrer, {
196
+ field: "createdAt",
197
+ value: createdAt,
198
+ operator: "lt"
199
+ }]
200
+ }), adapter.count({
201
+ model: "referral",
202
+ where: [
203
+ referrer,
204
+ {
205
+ field: "createdAt",
206
+ value: createdAt
207
+ },
208
+ {
209
+ field: "id",
210
+ value: referral.id,
211
+ operator: "lt"
212
+ }
213
+ ]
214
+ })]);
215
+ return earlier + sameTime;
216
+ }
217
+ /**
218
+ * Marks a pending referral completed. Returns `null` if it wasn't pending
219
+ * (anymore), so only one caller gets to run `onReferralCompleted`.
220
+ */
221
+ async function completeReferral(adapter, id) {
222
+ return adapter.update({
223
+ model: "referral",
224
+ where: [{
225
+ field: "id",
226
+ value: id
227
+ }, {
228
+ field: "status",
229
+ value: "pending"
230
+ }],
231
+ update: {
232
+ status: "completed",
233
+ completedAt: /* @__PURE__ */ new Date()
234
+ }
235
+ });
236
+ }
237
+ function referralFilters(input) {
238
+ const where = [];
239
+ if (input.referrerUserId) where.push({
240
+ field: "referrerUserId",
241
+ value: input.referrerUserId
242
+ });
243
+ if (input.status) where.push({
244
+ field: "status",
245
+ value: input.status
246
+ });
247
+ return where;
248
+ }
249
+ /** Newest first. */
250
+ async function listReferrals(adapter, input) {
251
+ return adapter.findMany({
252
+ model: "referral",
253
+ where: referralFilters(input),
254
+ limit: input.limit,
255
+ offset: input.offset,
256
+ sortBy: {
257
+ field: "createdAt",
258
+ direction: "desc"
259
+ }
260
+ });
261
+ }
262
+ async function countReferrals(adapter, input) {
263
+ return adapter.count({
264
+ model: "referral",
265
+ where: referralFilters(input)
266
+ });
267
+ }
268
+ async function createStep(adapter, input) {
269
+ return adapter.create({
270
+ model: "referralStep",
271
+ data: {
272
+ referralId: input.referralId,
273
+ step: input.step,
274
+ metadata: input.metadata ?? null,
275
+ completedAt: /* @__PURE__ */ new Date()
276
+ }
277
+ });
278
+ }
279
+ async function findStep(adapter, referralId, step) {
280
+ return adapter.findOne({
281
+ model: "referralStep",
282
+ where: [{
283
+ field: "referralId",
284
+ value: referralId
285
+ }, {
286
+ field: "step",
287
+ value: step
288
+ }]
289
+ });
290
+ }
291
+ /** The completed steps of these referrals, by referral id. */
292
+ async function stepsByReferral(adapter, referralIds) {
293
+ const result = new Map(referralIds.map((id) => [id, []]));
294
+ if (referralIds.length === 0) return result;
295
+ const steps = await adapter.findMany({
296
+ model: "referralStep",
297
+ where: [{
298
+ field: "referralId",
299
+ value: referralIds,
300
+ operator: "in"
301
+ }],
302
+ sortBy: {
303
+ field: "completedAt",
304
+ direction: "asc"
305
+ }
306
+ });
307
+ for (const { referralId, step } of steps) result.get(referralId)?.push(step);
308
+ return result;
309
+ }
310
+ /**
311
+ * Whether a user can't refer anyone: deleted, or banned with better-auth's
312
+ * `admin` plugin (until `banExpires`, if set). Without the admin plugin,
313
+ * nobody is banned.
314
+ */
315
+ async function isReferrerBlocked(adapter, userId) {
316
+ const user = await adapter.findOne({
317
+ model: "user",
318
+ where: [{
319
+ field: "id",
320
+ value: userId
321
+ }]
322
+ });
323
+ if (!user) return true;
324
+ if (user.banned !== true) return false;
325
+ return !user.banExpires || new Date(user.banExpires).getTime() > Date.now();
326
+ }
327
+ async function findUsers(adapter, ids) {
328
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
329
+ const users = await adapter.findMany({
330
+ model: "user",
331
+ where: [{
332
+ field: "id",
333
+ value: ids,
334
+ operator: "in"
335
+ }]
336
+ });
337
+ return new Map(users.map((user) => [user.id, user]));
338
+ }
339
+ // src/utils/code.ts
340
+ /**
341
+ * Consonants and digits. No look-alikes (0/O, 1/I/L), and no vowels
342
+ * (A, E, I, O, U, Y), so random codes can't spell words, offensive ones
343
+ * included.
344
+ */
345
+ const CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ23456789";
346
+ /** What a code may look like once normalized: letters, digits, `-`, `_`. */
347
+ const CODE_PATTERN = /^[A-Z0-9_-]{4,64}$/;
348
+ /** A random code of `length` characters from `CODE_ALPHABET`, without modulo bias. */
349
+ function generateCode(length) {
350
+ const limit = 243;
351
+ let code = "";
352
+ while (code.length < length) for (const byte of crypto.getRandomValues(new Uint8Array(length * 2))) {
353
+ if (byte >= limit) continue;
354
+ code += CODE_ALPHABET[byte % 27];
355
+ if (code.length === length) break;
356
+ }
357
+ return code;
358
+ }
359
+ /** Trims and uppercases a code, the way codes are stored and compared. */
360
+ function normalizeCode(code) {
361
+ return code.trim().toUpperCase();
362
+ }
363
+ /** Whether `code` (normalized) is a well-formed code. */
364
+ function isWellFormedCode(code) {
365
+ return CODE_PATTERN.test(code);
366
+ }
367
+ // src/referrals.ts
368
+ /** How often a colliding generated code is replaced before giving up. */
369
+ const MAX_CODE_ATTEMPTS = 5;
370
+ /** A new, unused, well-formed code from `generateCode` or the built-in generator. */
371
+ async function newCode(adapter, options) {
372
+ const code = normalizeCode(options.generateCode ? await options.generateCode() : generateCode(options.codeLength));
373
+ if (!isWellFormedCode(code)) throw new Error(`[referral] generateCode returned ${JSON.stringify(code)}: use 4 to 64 letters, digits, "-", or "_".`);
374
+ return await findCodeByCode(adapter, code) ? null : code;
375
+ }
376
+ /**
377
+ * The code `userId` refers with, created on first use. Safe to call
378
+ * concurrently: a second creation hits the unique index and returns the
379
+ * code the first one stored.
380
+ */
381
+ async function getOrCreateCode(adapter, options, userId) {
382
+ const existing = await findCodeByUserId(adapter, userId);
383
+ if (existing) return existing;
384
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
385
+ const code = await newCode(adapter, options);
386
+ if (!code) continue;
387
+ try {
388
+ return await createCode(adapter, {
389
+ userId,
390
+ code
391
+ });
392
+ } catch (error) {
393
+ const raced = await findCodeByUserId(adapter, userId);
394
+ if (raced) return raced;
395
+ if (!await findCodeByCode(adapter, code)) throw error;
396
+ }
397
+ }
398
+ throw APIError.from("INTERNAL_SERVER_ERROR", REFERRAL_ERROR_CODES.CODE_GENERATION_FAILED);
399
+ }
400
+ /**
401
+ * Gives `userId` a new code; the old one stops working. Referrals made
402
+ * with the old code keep counting (they store the code they used).
403
+ */
404
+ async function regenerateCode(adapter, options, userId) {
405
+ const existing = await findCodeByUserId(adapter, userId);
406
+ if (!existing) return getOrCreateCode(adapter, options, userId);
407
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
408
+ const code = await newCode(adapter, options);
409
+ if (!code) continue;
410
+ try {
411
+ return await replaceCode(adapter, existing.id, code) ?? {
412
+ ...existing,
413
+ code
414
+ };
415
+ } catch (error) {
416
+ if (!await findCodeByCode(adapter, code)) throw error;
417
+ }
418
+ }
419
+ throw APIError.from("INTERNAL_SERVER_ERROR", REFERRAL_ERROR_CODES.CODE_GENERATION_FAILED);
420
+ }
421
+ /**
422
+ * The stored code for `rawCode` if it can still refer someone: it exists,
423
+ * its owner isn't banned, and hasn't reached `maxReferralsPerUser`. `null`
424
+ * otherwise.
425
+ */
426
+ async function findUsableCode(adapter, options, rawCode) {
427
+ const code = normalizeCode(rawCode);
428
+ if (!isWellFormedCode(code)) return null;
429
+ const stored = await findCodeByCode(adapter, code);
430
+ if (!stored) return null;
431
+ if (await isReferrerBlocked(adapter, stored.userId)) return null;
432
+ if (options.maxReferralsPerUser !== void 0 && await countReferrals(adapter, { referrerUserId: stored.userId }) >= options.maxReferralsPerUser) return null;
433
+ return stored;
434
+ }
435
+ /** Records a completed referral in the audit log, for the referrer it rewards. */
436
+ function recordCompleted(referral) {
437
+ return recordAuditEvent({
438
+ type: "referral.completed",
439
+ severity: "medium",
440
+ userId: referral.referrerUserId,
441
+ metadata: {
442
+ referralId: referral.id,
443
+ referredUserId: referral.referredUserId
444
+ }
445
+ });
446
+ }
447
+ /**
448
+ * Marks `referral` completed once all required steps are done, and runs
449
+ * `onReferralCompleted` exactly once.
450
+ */
451
+ async function completeIfDone(adapter, context, options, referral) {
452
+ if (referral.status === "completed") return referral;
453
+ const done = new Set((await stepsByReferral(adapter, [referral.id])).get(referral.id));
454
+ if (!options.steps.every((step) => done.has(step))) return referral;
455
+ const completed = await completeReferral(adapter, referral.id);
456
+ if (!completed) return {
457
+ ...referral,
458
+ status: "completed"
459
+ };
460
+ await recordCompleted(completed);
461
+ const { onReferralCompleted } = options;
462
+ if (onReferralCompleted) await runCallback(context, "referral", "onReferralCompleted", () => onReferralCompleted(completed));
463
+ return completed;
464
+ }
465
+ /**
466
+ * Records `step` for `referral` (once; repeating it changes nothing) and
467
+ * completes the referral when it was the last required step.
468
+ */
469
+ async function recordStep(adapter, context, options, referral, step, metadata) {
470
+ if (!options.steps.includes(step)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.UNKNOWN_REFERRAL_STEP);
471
+ let isNew = false;
472
+ if (!await findStep(adapter, referral.id, step)) try {
473
+ await createStep(adapter, {
474
+ referralId: referral.id,
475
+ step,
476
+ metadata
477
+ });
478
+ isNew = true;
479
+ } catch (error) {
480
+ if (!await findStep(adapter, referral.id, step)) throw error;
481
+ }
482
+ const { onStepCompleted } = options;
483
+ if (isNew && onStepCompleted) await runCallback(context, "referral", "onStepCompleted", () => onStepCompleted({
484
+ referral,
485
+ step
486
+ }));
487
+ return completeIfDone(adapter, context, options, referral);
488
+ }
489
+ /**
490
+ * Runs once a referral is created: `onReferral`, then `onReferralCompleted`
491
+ * if it was created completed (no steps required).
492
+ */
493
+ async function referralCreated(context, options, referral) {
494
+ await recordAuditEvent({
495
+ type: "referral.created",
496
+ userId: referral.referredUserId,
497
+ actorUserId: referral.referredUserId,
498
+ metadata: {
499
+ referralId: referral.id,
500
+ referrerUserId: referral.referrerUserId,
501
+ code: referral.code
502
+ }
503
+ });
504
+ if (referral.status === "completed") await recordCompleted(referral);
505
+ const { onReferral, onReferralCompleted } = options;
506
+ if (onReferral) await runCallback(context, "referral", "onReferral", () => onReferral(referral));
507
+ if (referral.status === "completed" && onReferralCompleted) await runCallback(context, "referral", "onReferralCompleted", () => onReferralCompleted(referral));
508
+ }
509
+ // src/hooks/database.ts
510
+ const REFERRAL_CODE_HEADER = "x-referral-code";
511
+ /** Longest code read from a request; longer values are ignored. */
512
+ const MAX_CODE_INPUT_LENGTH = 128;
513
+ /**
514
+ * The referral code sent with the request that creates the user: the
515
+ * `referralCode` field of the sign-up body, the `x-referral-code` header,
516
+ * or `additionalData.referralCode` of a social sign-in (read on the
517
+ * callback).
518
+ */
519
+ async function readReferralCode(context) {
520
+ const valid = (value) => typeof value === "string" && value.trim() !== "" && value.length <= MAX_CODE_INPUT_LENGTH;
521
+ const body = context?.body;
522
+ if (valid(body?.referralCode)) return body.referralCode;
523
+ const header = context?.headers?.get(REFERRAL_CODE_HEADER);
524
+ if (valid(header)) return header;
525
+ try {
526
+ const state = await getOAuthState();
527
+ if (valid(state?.referralCode)) return state.referralCode;
528
+ } catch {}
529
+ }
530
+ /**
531
+ * Attributes new users to the code they signed up with, whichever way
532
+ * they sign up, and records the `email-verified` step. Failures after the
533
+ * user exists are logged, never returned: a referral problem must not
534
+ * fail a sign-up that already happened.
535
+ */
536
+ function createDatabaseHooks(authContext, options) {
537
+ const contextFor = (context) => context?.context ?? authContext;
538
+ const tracksEmailVerified = options.steps.includes(EMAIL_VERIFIED_STEP);
539
+ /** Requests whose user update sets `emailVerified: true` (see `update.before`). */
540
+ const verifying = /* @__PURE__ */ new WeakSet();
541
+ const attribute = async (user, context) => {
542
+ const ctx = contextFor(context);
543
+ const adapter = await getCurrentAdapter(ctx.adapter);
544
+ if (options.createCodeOnSignUp) await getOrCreateCode(adapter, options, user.id);
545
+ const rawCode = await readReferralCode(context);
546
+ if (!rawCode) return;
547
+ const code = await findUsableCode(adapter, options, rawCode);
548
+ if (!code || code.userId === user.id) return;
549
+ if (options.allowReferral) {
550
+ let allowed = false;
551
+ try {
552
+ allowed = await options.allowReferral({
553
+ referrerUserId: code.userId,
554
+ referredUser: {
555
+ id: user.id,
556
+ email: user.email
557
+ },
558
+ code: code.code,
559
+ context: context ?? null
560
+ });
561
+ } catch (error) {
562
+ ctx.logger.error("[referral] allowReferral threw an error", error);
563
+ }
564
+ if (!allowed) return;
565
+ }
566
+ let referral;
567
+ try {
568
+ referral = await createReferral(adapter, {
569
+ referrerUserId: code.userId,
570
+ referredUserId: user.id,
571
+ code: code.code,
572
+ status: options.steps.length === 0 ? "completed" : "pending"
573
+ });
574
+ } catch (error) {
575
+ if (await findReferralByReferredUserId(adapter, user.id)) return;
576
+ throw error;
577
+ }
578
+ const settle = async () => {
579
+ try {
580
+ const max = options.maxReferralsPerUser;
581
+ if (max !== void 0 && await countReferralsBefore(ctx.adapter, referral) >= max) {
582
+ await deleteReferral(ctx.adapter, referral.id);
583
+ return;
584
+ }
585
+ await referralCreated(ctx, options, referral);
586
+ if (tracksEmailVerified && user.emailVerified) await recordStep(ctx.adapter, ctx, options, referral, EMAIL_VERIFIED_STEP);
587
+ } catch (error) {
588
+ ctx.logger.error("[referral] failed to record the referral", error);
589
+ }
590
+ };
591
+ if (adapter === ctx.adapter) await settle();
592
+ else await queueAfterTransactionHook(settle);
593
+ };
594
+ return { user: {
595
+ create: {
596
+ before: async (_user, context) => {
597
+ if (options.invalidCode !== "reject") return;
598
+ const rawCode = await readReferralCode(context);
599
+ if (rawCode === void 0) return;
600
+ if (!await findUsableCode(await getCurrentAdapter(contextFor(context).adapter), options, rawCode)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.INVALID_REFERRAL_CODE);
601
+ },
602
+ after: async (user, context) => {
603
+ try {
604
+ await attribute(user, context);
605
+ } catch (error) {
606
+ contextFor(context).logger.error("[referral] failed to record the referral", error);
607
+ }
608
+ }
609
+ },
610
+ update: {
611
+ before: async (data, context) => {
612
+ if (tracksEmailVerified && context && data.emailVerified === true) verifying.add(context);
613
+ },
614
+ after: async (user, context) => {
615
+ if (!tracksEmailVerified || user.emailVerified !== true) return;
616
+ if (context && !verifying.delete(context)) return;
617
+ const ctx = contextFor(context);
618
+ try {
619
+ const adapter = await getCurrentAdapter(ctx.adapter);
620
+ const referral = await findReferralByReferredUserId(adapter, user.id);
621
+ if (referral) await recordStep(adapter, ctx, options, referral, EMAIL_VERIFIED_STEP);
622
+ } catch (error) {
623
+ ctx.logger.error("[referral] failed to record the email-verified step", error);
624
+ }
625
+ }
626
+ }
627
+ } };
628
+ }
629
+ // src/routes/codes.ts
630
+ function createCodeEndpoints(options) {
631
+ return {
632
+ getReferralCode: createAuthEndpoint("/referral/get-code", {
633
+ method: "GET",
634
+ use: [sessionMiddleware],
635
+ metadata: { openapi: { description: "Get the signed-in user's referral code, or null if they have none" } }
636
+ }, async (ctx) => {
637
+ const code = await findCodeByUserId(ctx.context.adapter, ctx.context.session.user.id);
638
+ return ctx.json({ code: code?.code ?? null });
639
+ }),
640
+ createReferralCode: createAuthEndpoint("/referral/create-code", {
641
+ method: "POST",
642
+ use: [sessionMiddleware],
643
+ metadata: { openapi: { description: "Create the signed-in user's referral code. Returns the existing code if they already have one" } }
644
+ }, async (ctx) => {
645
+ const code = await getOrCreateCode(ctx.context.adapter, options, ctx.context.session.user.id);
646
+ return ctx.json({ code: code.code });
647
+ }),
648
+ regenerateReferralCode: createAuthEndpoint("/referral/regenerate-code", {
649
+ method: "POST",
650
+ use: [sessionMiddleware],
651
+ metadata: { openapi: { description: "Replace the signed-in user's referral code with a new one. The old code and its share links stop working; referrals made with it keep counting" } }
652
+ }, async (ctx) => {
653
+ const code = await regenerateCode(ctx.context.adapter, options, ctx.context.session.user.id);
654
+ return ctx.json({ code: code.code });
655
+ }),
656
+ checkReferralCode: createAuthEndpoint("/referral/check-code", {
657
+ method: "GET",
658
+ query: z.object({ code: z.string().min(1).max(128).meta({ description: "The referral code" }) }),
659
+ metadata: { openapi: { description: "Check whether a referral code can be used at sign-up. Doesn't reveal whose code it is" } }
660
+ }, async (ctx) => {
661
+ const code = await findUsableCode(ctx.context.adapter, options, ctx.query.code);
662
+ return ctx.json({ valid: code !== null });
663
+ })
664
+ };
665
+ }
666
+ // ../plugin-utils/src/pagination.ts
667
+ /**
668
+ * The `limit` and `offset` fields of a list endpoint's query, coerced from
669
+ * query strings. Spread into the endpoint's `z.object({ ... })`.
670
+ */
671
+ function paginationQuery({ defaultLimit, maxLimit }) {
672
+ return {
673
+ limit: z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit).meta({ description: `How many to return, at most ${maxLimit}. Defaults to ${defaultLimit}` }),
674
+ offset: z.coerce.number().int().min(0).default(0).meta({ description: "How many to skip. Defaults to 0" })
675
+ };
676
+ }
677
+ // src/utils/mask.ts
678
+ /** `ada@example.com` → `a***@example.com`. Emails without `@` become `***`. */
679
+ function maskEmail(email) {
680
+ const at = email.lastIndexOf("@");
681
+ if (at < 1) return "***";
682
+ return `${email[0]}***${email.slice(at)}`;
683
+ }
684
+ const pageSchema = paginationQuery({
685
+ defaultLimit: 20,
686
+ maxLimit: 100
687
+ });
688
+ const statusSchema = z.enum(["pending", "completed"]).meta({ description: "Only referrals with this status" });
689
+ async function withSteps(adapter, referrals) {
690
+ const steps = await stepsByReferral(adapter, referrals.map((referral) => referral.id));
691
+ return referrals.map((referral) => ({
692
+ ...referral,
693
+ steps: steps.get(referral.id) ?? []
694
+ }));
695
+ }
696
+ function createReferralEndpoints(options) {
697
+ return {
698
+ listReferrals: createAuthEndpoint("/referral/list-referrals", {
699
+ method: "GET",
700
+ use: [sessionMiddleware],
701
+ query: z.optional(z.object({
702
+ status: statusSchema.optional(),
703
+ ...pageSchema
704
+ })),
705
+ metadata: { openapi: { description: "List the users the signed-in user referred, newest first, with their steps" } }
706
+ }, async (ctx) => {
707
+ const { adapter } = ctx.context;
708
+ const referrerUserId = ctx.context.session.user.id;
709
+ const status = ctx.query?.status;
710
+ const [referrals, total, completed] = await Promise.all([
711
+ listReferrals(adapter, {
712
+ referrerUserId,
713
+ status,
714
+ limit: ctx.query?.limit ?? 20,
715
+ offset: ctx.query?.offset ?? 0
716
+ }),
717
+ countReferrals(adapter, {
718
+ referrerUserId,
719
+ status
720
+ }),
721
+ countReferrals(adapter, {
722
+ referrerUserId,
723
+ status: "completed"
724
+ })
725
+ ]);
726
+ const [detailed, users] = await Promise.all([withSteps(adapter, referrals), findUsers(adapter, referrals.map((referral) => referral.referredUserId))]);
727
+ return ctx.json({
728
+ referrals: detailed.map((referral) => {
729
+ const user = users.get(referral.referredUserId);
730
+ return {
731
+ id: referral.id,
732
+ status: referral.status,
733
+ steps: referral.steps,
734
+ createdAt: referral.createdAt,
735
+ completedAt: referral.completedAt ?? null,
736
+ referredUser: {
737
+ name: options.hideNames ? null : user?.name ?? null,
738
+ email: user ? options.maskEmails ? maskEmail(user.email) : user.email : null
739
+ }
740
+ };
741
+ }),
742
+ total,
743
+ completed
744
+ });
745
+ }),
746
+ listAllReferrals: createAuthEndpoint("/referral/list-all-referrals", {
747
+ method: "GET",
748
+ use: [sessionMiddleware],
749
+ query: z.optional(z.object({
750
+ referrerUserId: z.string().min(1).optional().meta({ description: "Only referrals by this user" }),
751
+ status: statusSchema.optional(),
752
+ ...pageSchema
753
+ })),
754
+ metadata: { openapi: { description: "List all referrals, newest first. Admins only" } }
755
+ }, async (ctx) => {
756
+ if (!isAdmin(ctx.context.session.user, options)) throw APIError.from("FORBIDDEN", REFERRAL_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_REFERRALS);
757
+ const { adapter } = ctx.context;
758
+ const filters = {
759
+ referrerUserId: ctx.query?.referrerUserId,
760
+ status: ctx.query?.status
761
+ };
762
+ const [referrals, total] = await Promise.all([listReferrals(adapter, {
763
+ ...filters,
764
+ limit: ctx.query?.limit ?? 20,
765
+ offset: ctx.query?.offset ?? 0
766
+ }), countReferrals(adapter, filters)]);
767
+ return ctx.json({
768
+ referrals: await withSteps(adapter, referrals),
769
+ total
770
+ });
771
+ }),
772
+ /**
773
+ * Server only (no path, so not reachable over HTTP): your backend calls
774
+ * `auth.api.completeReferralStep` when a referred user does something
775
+ * that counts, e.g. their first purchase.
776
+ */
777
+ completeReferralStep: createAuthEndpoint({
778
+ method: "POST",
779
+ body: z.object({
780
+ userId: z.string().min(1).meta({ description: "The referred user" }),
781
+ step: z.string().min(1).max(64).meta({ description: "A step from the plugin's `steps` option" }),
782
+ metadata: z.record(z.string(), z.unknown()).optional().meta({ description: "Extra data to keep with the step, e.g. an order id" })
783
+ }),
784
+ metadata: { openapi: { description: "Record a step of the user's referral. Does nothing if the user wasn't referred or the step is already done" } }
785
+ }, async (ctx) => {
786
+ const { userId, step } = ctx.body;
787
+ const metadata = ctx.body.metadata ? JSON.stringify(ctx.body.metadata) : void 0;
788
+ if (metadata && metadata.length > 2e3) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.METADATA_TOO_LARGE);
789
+ if (!options.steps.includes(step)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.UNKNOWN_REFERRAL_STEP);
790
+ const { adapter } = ctx.context;
791
+ const referral = await findReferralByReferredUserId(adapter, userId);
792
+ if (!referral) return ctx.json({ referral: null });
793
+ const [detailed] = await withSteps(adapter, [await recordStep(adapter, ctx.context, options, referral, step, metadata)]);
794
+ return ctx.json({ referral: detailed ?? null });
795
+ })
796
+ };
797
+ }
798
+ // src/schema.ts
799
+ const referralSchema = {
800
+ /** One code per user. */
801
+ referralCode: {
802
+ fields: {
803
+ userId: {
804
+ type: "string",
805
+ required: true,
806
+ references: {
807
+ model: "user",
808
+ field: "id",
809
+ onDelete: "cascade"
810
+ }
811
+ },
812
+ /** Uppercase. */
813
+ code: {
814
+ type: "string",
815
+ required: true
816
+ },
817
+ createdAt: {
818
+ type: "date",
819
+ required: true
820
+ }
821
+ },
822
+ indexes: [{
823
+ fields: ["userId"],
824
+ unique: true
825
+ }, {
826
+ fields: ["code"],
827
+ unique: true
828
+ }]
829
+ },
830
+ /** Who referred whom. A user is referred at most once. */
831
+ referral: {
832
+ fields: {
833
+ referrerUserId: {
834
+ type: "string",
835
+ required: true,
836
+ index: true,
837
+ references: {
838
+ model: "user",
839
+ field: "id",
840
+ onDelete: "cascade"
841
+ }
842
+ },
843
+ referredUserId: {
844
+ type: "string",
845
+ required: true,
846
+ references: {
847
+ model: "user",
848
+ field: "id",
849
+ onDelete: "cascade"
850
+ }
851
+ },
852
+ /** The code used at sign-up, kept even if the code changes. */
853
+ code: {
854
+ type: "string",
855
+ required: true
856
+ },
857
+ /** "pending" | "completed" */
858
+ status: {
859
+ type: "string",
860
+ required: true,
861
+ index: true
862
+ },
863
+ completedAt: {
864
+ type: "date",
865
+ required: false
866
+ },
867
+ createdAt: {
868
+ type: "date",
869
+ required: true
870
+ }
871
+ },
872
+ indexes: [{
873
+ fields: ["referredUserId"],
874
+ unique: true
875
+ }]
876
+ },
877
+ /** Steps a referred user has completed. Each step counts once. */
878
+ referralStep: {
879
+ fields: {
880
+ referralId: {
881
+ type: "string",
882
+ required: true,
883
+ references: {
884
+ model: "referral",
885
+ field: "id",
886
+ onDelete: "cascade"
887
+ }
888
+ },
889
+ step: {
890
+ type: "string",
891
+ required: true
892
+ },
893
+ /** JSON-encoded. */
894
+ metadata: {
895
+ type: "string",
896
+ required: false
897
+ },
898
+ completedAt: {
899
+ type: "date",
900
+ required: true
901
+ }
902
+ },
903
+ indexes: [{
904
+ fields: ["referralId", "step"],
905
+ unique: true
906
+ }]
907
+ }
908
+ };
909
+ // src/index.ts
910
+ /**
911
+ * Gives users referral codes, attributes new users to the code they signed
912
+ * up with (any sign-up method), and tracks steps a referral must complete
913
+ * before it counts, e.g. a verified email or a first purchase.
914
+ *
915
+ * @example
916
+ * ```ts
917
+ * import { betterAuth } from "better-auth";
918
+ * import { referral } from "@checodo/better-auth-referral";
919
+ *
920
+ * export const auth = betterAuth({
921
+ * plugins: [
922
+ * referral({
923
+ * steps: ["email-verified", "first-purchase"],
924
+ * onReferralCompleted: async ({ referrerUserId }) => {
925
+ * await grantCredit(referrerUserId);
926
+ * },
927
+ * }),
928
+ * ],
929
+ * });
930
+ * ```
931
+ */
932
+ const referral = (options = {}) => {
933
+ const resolved = resolveOptions(options);
934
+ return {
935
+ id: "referral",
936
+ version,
937
+ init(ctx) {
938
+ applyAuthDefaults(resolved, ctx);
939
+ return { options: { databaseHooks: createDatabaseHooks(ctx, resolved) } };
940
+ },
941
+ schema: mergeSchema(structuredClone(referralSchema), options.schema),
942
+ endpoints: {
943
+ ...createCodeEndpoints(resolved),
944
+ ...createReferralEndpoints(resolved)
945
+ },
946
+ rateLimit: [{
947
+ pathMatcher: (path) => path === "/referral/check-code",
948
+ window: 60,
949
+ max: 5
950
+ }],
951
+ $ERROR_CODES: REFERRAL_ERROR_CODES,
952
+ options
953
+ };
954
+ };
955
+ export { EMAIL_VERIFIED_STEP, REFERRAL_ERROR_CODES, referral };
956
+
957
+ //# sourceMappingURL=index.js.map