Skip to content

Commit 223ca9e

Browse files
feat(invitations): user-facing referral abilities, redemption event, referrer notification (#3980)
* feat(invitations): user-facing referral abilities, redemption event, referrer notification Widen invitationAbilities behind a new config.invitations.userFacing flag (default OFF, admin-only preserved): authenticated users can create their own invitation and read invitations they sent (server-scoped via the existing InvitationsService.list() invitedBy scoping). resend stays explicitly admin-gated in the controller since its POST verb maps to the same CASL 'create' action as new-invitation creation. Also: emit an invitation_redeemed analytics event on accept, carry invite/referral attribution on the signup event, notify the referrer by email (existing mailer abstraction) when their referral reward is freshly credited, and index users.referredBy. Closes #3945 * fix(invitations): rate-limit POST /invitations create (#3945 pre-push gate) Widening invitationAbilities to authenticated users makes an unbounded POST /api/invitations a DB-bloat / outbound-email-spam abuse surface (caught by the pre-push critical-review gate). Add an invitationsCreate rate-limit profile (base-layer pattern, active under every NODE_ENV, stricter cap in production) on both the canonical route and the /api/auth/invitations alias, which points at the same controller. Also: move mailer.isConfigured() inside notifyReferrer's try/catch so a synchronous throw from the check itself can't escape. * fix(invitations): non-empty <title> in referral-reward-earned template CodeRabbit (HTMLHint): title-require. Mirrors the existing app-name pattern used elsewhere in this template's own copy.
1 parent d34528c commit 223ca9e

19 files changed

Lines changed: 667 additions & 21 deletions

config/defaults/production.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ const config = {
6262
standardHeaders: true,
6363
legacyHeaders: false,
6464
},
65+
// Authenticated POST /api/invitations. Stricter prod cap to harden against
66+
// mass-invite abuse once `invitations.userFacing` widens create beyond admins
67+
// (see invitations.development.config.js for the base-layer profile).
68+
invitationsCreate: {
69+
windowMs: 15 * 60 * 1000,
70+
max: 20,
71+
message: { message: 'Too many requests, please try again later.' },
72+
standardHeaders: true,
73+
legacyHeaders: false,
74+
},
6575
},
6676
log: {
6777
format: 'custom',

config/defaults/test.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ const config = {
4343
publicImage: {
4444
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
4545
},
46+
invitationsCreate: {
47+
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
48+
},
4649
},
4750
uploads: {
4851
avatar: {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<title>{{appName}} referral reward</title>
5+
</head>
6+
<body>
7+
<p>Hello {{displayName}},</p>
8+
<p>Great news — someone you invited just joined {{appName}}, and you've earned a referral reward of {{units}} units.</p>
9+
<p>Thanks for helping grow {{appName}}.</p>
10+
<p>The <b>{{appName}}</b> Team.</p>
11+
</body>
12+
</html>

lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import organizationsDevConfig from '../../../modules/organizations/config/organi
66
import billingDevConfig from '../../../modules/billing/config/billing.development.config.js';
77
import authDevConfig from '../../../modules/auth/config/auth.development.config.js';
88
import uploadsDevConfig from '../../../modules/uploads/config/uploads.development.config.js';
9+
import invitationsDevConfig from '../../../modules/invitations/config/invitations.development.config.js';
910

1011
/**
1112
* Config-layering regression guard for the rate-limiter env-gate defect.
@@ -58,4 +59,12 @@ describe('rate-limiter base-layer profiles (env-gate config-layering):', () => {
5859
// the base layer so the limiter is active under every NODE_ENV, not only prod.
5960
expectUsableProfile(uploadsDevConfig.rateLimit.publicImage);
6061
});
62+
63+
test('invitationsCreate profile lives in the invitations base layer (always merges)', () => {
64+
// Guards POST /api/invitations (+ the /api/auth/invitations alias). Once
65+
// `invitations.userFacing` (#3945) widens create beyond admins, an unbounded
66+
// create is a DB-bloat / outbound-email-spam abuse surface — the profile must
67+
// be present under every NODE_ENV, not only prod.
68+
expectUsableProfile(invitationsDevConfig.rateLimit.invitationsCreate);
69+
});
6170
});

modules/auth/controllers/auth.controller.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,17 @@ const signup = async (req, res) => {
220220
AnalyticsService.capture({
221221
distinctId: String(user.id),
222222
event: 'user_signed_up',
223-
properties: { email: user.email, plan: user.plan, createdAt: user.createdAt },
223+
properties: {
224+
email: user.email,
225+
plan: user.plan,
226+
createdAt: user.createdAt,
227+
// #3945: carry invite/referral attribution on the signup event so the
228+
// referral funnel is measurable. `invite` is the resolved (opaque) result
229+
// from the eligibility registry — already in scope, no invitations import.
230+
invited: Boolean(invite),
231+
invitationId: invite ? String(invite.id) : null,
232+
invitedBy: invite?.invitedBy ? String(invite.invitedBy) : null,
233+
},
224234
});
225235
} catch (_) { /* analytics must not break auth */ }
226236

modules/auth/routes/auth.routes.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import InvitationSchema from '../../invitations/models/invitations.schema.js';
2929
*/
3030
export default (app) => {
3131
const authLimiter = limiters.auth;
32+
// #3945: mirrors the same profile applied on the canonical mount
33+
// (invitations.routes.js) — this alias points at the SAME controller.create, so
34+
// leaving it unlimited would let a caller bypass the canonical route's cap.
35+
const createLimiter = limiters.invitationsCreate;
3236

3337
// Signup invitations — DEPRECATION ALIAS for the canonical /api/invitations mount
3438
// (modules/invitations). MUST be declared before the greedy `/api/auth/:strategy`
@@ -42,7 +46,7 @@ export default (app) => {
4246
.route('/api/auth/invitations')
4347
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
4448
.get(invitations.list)
45-
.post(model.isValid(InvitationSchema.Invitation), invitations.create);
49+
.post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create);
4650
app
4751
.route('/api/auth/invitations/:invitationId')
4852
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)

modules/auth/tests/auth.silent.catch.unit.tests.js

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,276 @@ describe('auth.controller signup mass-assignment strip:', () => {
329329
});
330330
});
331331

332+
describe('auth.controller signup analytics: invite/referral attribution (#3945):', () => {
333+
beforeEach(() => {
334+
jest.resetModules();
335+
336+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
337+
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
338+
}));
339+
});
340+
341+
test('user_signed_up carries invited:true + invitationId + invitedBy when the eligibility registry resolved an invite', async () => {
342+
const mockCreate = jest.fn().mockResolvedValue({
343+
id: 'u1', email: 'invitee@y.com', firstName: 'A', lastName: 'B', provider: 'local',
344+
});
345+
346+
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
347+
default: {
348+
create: mockCreate,
349+
getBrut: jest.fn().mockResolvedValue({ id: 'u1' }),
350+
update: jest.fn().mockResolvedValue({}),
351+
remove: jest.fn(),
352+
count: jest.fn().mockResolvedValue(0),
353+
},
354+
}));
355+
356+
// Closed-signup, invite-gated path: the eligibility registry resolves + claims
357+
// the invite and returns { invite, finalize, release } — auth relays it verbatim.
358+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
359+
default: {
360+
registerSignupEligibility: jest.fn(),
361+
assertSignupEligible: jest.fn().mockResolvedValue({
362+
invite: { id: 'inv1', email: 'invitee@y.com', invitedBy: 'inviter1' },
363+
finalize: jest.fn().mockResolvedValue({ id: 'inv1', status: 'accepted' }),
364+
release: jest.fn(),
365+
}),
366+
_reset: jest.fn(),
367+
},
368+
}));
369+
370+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
371+
default: {
372+
handleSignupOrganization: jest.fn().mockResolvedValue({
373+
organization: null, joined: false, pendingJoin: false,
374+
abilities: [], organizationSetupRequired: false,
375+
emailVerificationRequired: false, suggestedOrganization: null,
376+
}),
377+
},
378+
}));
379+
380+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
381+
default: { autoSetCurrentOrganization: jest.fn() },
382+
}));
383+
384+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
385+
default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
386+
}));
387+
388+
jest.unstable_mockModule('../../../config/index.js', () => ({
389+
default: {
390+
sign: { up: false, in: true }, // closed signup — invite is required to open the gate
391+
jwt: { secret: 'test-secret', expiresIn: 3600 },
392+
cookie: { secure: false, sameSite: 'lax' },
393+
organizations: { enabled: false },
394+
app: { title: 'Test', contact: 'test@test.com' },
395+
},
396+
}));
397+
398+
jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
399+
default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
400+
}));
401+
402+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
403+
default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
404+
}));
405+
406+
jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
407+
default: {
408+
success: jest.fn().mockReturnValue(jest.fn()),
409+
error: jest.fn().mockReturnValue(jest.fn()),
410+
},
411+
}));
412+
413+
jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
414+
default: { getMessage: jest.fn().mockReturnValue('error') },
415+
}));
416+
417+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
418+
default: class AppError extends Error {
419+
constructor(msg, opts) {
420+
super(msg);
421+
this.status = opts?.status;
422+
this.code = opts?.code;
423+
this.details = opts?.details;
424+
}
425+
},
426+
}));
427+
428+
jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
429+
default: { User: {}, SignupUser: {} },
430+
}));
431+
432+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
433+
default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
434+
}));
435+
436+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
437+
default: jest.fn().mockReturnValue([]),
438+
}));
439+
440+
jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
441+
default: jest.fn().mockReturnValue('http://localhost:3000'),
442+
}));
443+
444+
const mockCapture = jest.fn();
445+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
446+
default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
447+
}));
448+
449+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
450+
451+
const req = {
452+
body: { email: 'invitee@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' },
453+
query: { inviteToken: 'tok' },
454+
};
455+
const res = {
456+
status: jest.fn().mockReturnThis(),
457+
cookie: jest.fn().mockReturnThis(),
458+
json: jest.fn().mockReturnThis(),
459+
};
460+
461+
await AuthController.signup(req, res);
462+
463+
expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
464+
distinctId: 'u1',
465+
event: 'user_signed_up',
466+
properties: expect.objectContaining({
467+
invited: true,
468+
invitationId: 'inv1',
469+
invitedBy: 'inviter1',
470+
}),
471+
}));
472+
});
473+
474+
test('user_signed_up carries invited:false + null invitationId/invitedBy on a non-invited (open) signup', async () => {
475+
const mockCreate = jest.fn().mockResolvedValue({
476+
id: 'u2', email: 'self@y.com', firstName: 'C', lastName: 'D', provider: 'local',
477+
});
478+
479+
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
480+
default: {
481+
create: mockCreate,
482+
getBrut: jest.fn().mockResolvedValue({ id: 'u2' }),
483+
update: jest.fn().mockResolvedValue({}),
484+
remove: jest.fn(),
485+
count: jest.fn().mockResolvedValue(0),
486+
},
487+
}));
488+
489+
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
490+
default: {
491+
registerSignupEligibility: jest.fn(),
492+
assertSignupEligible: jest.fn().mockResolvedValue(undefined), // no invite opened the gate
493+
_reset: jest.fn(),
494+
},
495+
}));
496+
497+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
498+
default: {
499+
handleSignupOrganization: jest.fn().mockResolvedValue({
500+
organization: null, joined: false, pendingJoin: false,
501+
abilities: [], organizationSetupRequired: false,
502+
emailVerificationRequired: false, suggestedOrganization: null,
503+
}),
504+
},
505+
}));
506+
507+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
508+
default: { autoSetCurrentOrganization: jest.fn() },
509+
}));
510+
511+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
512+
default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
513+
}));
514+
515+
jest.unstable_mockModule('../../../config/index.js', () => ({
516+
default: {
517+
sign: { up: true, in: true }, // open signup — no invite required
518+
jwt: { secret: 'test-secret', expiresIn: 3600 },
519+
cookie: { secure: false, sameSite: 'lax' },
520+
organizations: { enabled: false },
521+
app: { title: 'Test', contact: 'test@test.com' },
522+
},
523+
}));
524+
525+
jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
526+
default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
527+
}));
528+
529+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
530+
default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
531+
}));
532+
533+
jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
534+
default: {
535+
success: jest.fn().mockReturnValue(jest.fn()),
536+
error: jest.fn().mockReturnValue(jest.fn()),
537+
},
538+
}));
539+
540+
jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
541+
default: { getMessage: jest.fn().mockReturnValue('error') },
542+
}));
543+
544+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
545+
default: class AppError extends Error {
546+
constructor(msg, opts) {
547+
super(msg);
548+
this.status = opts?.status;
549+
this.code = opts?.code;
550+
this.details = opts?.details;
551+
}
552+
},
553+
}));
554+
555+
jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
556+
default: { User: {}, SignupUser: {} },
557+
}));
558+
559+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
560+
default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
561+
}));
562+
563+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
564+
default: jest.fn().mockReturnValue([]),
565+
}));
566+
567+
jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
568+
default: jest.fn().mockReturnValue('http://localhost:3000'),
569+
}));
570+
571+
const mockCapture = jest.fn();
572+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
573+
default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
574+
}));
575+
576+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
577+
578+
const req = {
579+
body: { email: 'self@y.com', firstName: 'C', lastName: 'D', password: 'P@ss1234!' },
580+
query: {},
581+
};
582+
const res = {
583+
status: jest.fn().mockReturnThis(),
584+
cookie: jest.fn().mockReturnThis(),
585+
json: jest.fn().mockReturnThis(),
586+
};
587+
588+
await AuthController.signup(req, res);
589+
590+
expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
591+
distinctId: 'u2',
592+
event: 'user_signed_up',
593+
properties: expect.objectContaining({
594+
invited: false,
595+
invitationId: null,
596+
invitedBy: null,
597+
}),
598+
}));
599+
});
600+
});
601+
332602
describe('auth.password.controller silent-catch error logging:', () => {
333603
let mockWarn;
334604
let mockError;

0 commit comments

Comments
 (0)