From 0f6856cec1cfdfe41f20a2adc47450d9d675e05d Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 24 Jul 2026 20:07:28 +0200 Subject: [PATCH 1/3] 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 --- config/templates/referral-reward-earned.html | 12 + modules/auth/controllers/auth.controller.js | 12 +- .../tests/auth.silent.catch.unit.tests.js | 270 ++++++++++++++++++ .../services/billing.referral.service.js | 50 ++++ .../billing.referral.service.unit.tests.js | 67 ++++- modules/invitations/README.md | 18 +- .../config/invitations.development.config.js | 13 + .../controllers/invitations.controller.js | 13 +- .../policies/invitations.policy.js | 27 +- .../services/invitations.service.js | 18 +- .../invitations.controller.unit.tests.js | 32 ++- .../tests/invitations.policy.unit.tests.js | 57 +++- .../tests/invitations.service.unit.tests.js | 41 +++ modules/users/models/users.model.mongoose.js | 6 +- 14 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 config/templates/referral-reward-earned.html diff --git a/config/templates/referral-reward-earned.html b/config/templates/referral-reward-earned.html new file mode 100644 index 000000000..aff8b4a3c --- /dev/null +++ b/config/templates/referral-reward-earned.html @@ -0,0 +1,12 @@ + + + + + + +

Hello {{displayName}},

+

Great news — someone you invited just joined {{appName}}, and you've earned a referral reward of {{units}} units.

+

Thanks for helping grow {{appName}}.

+

The {{appName}} Team.

+ + diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 391b2fcb2..bf195cc2f 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -220,7 +220,17 @@ const signup = async (req, res) => { AnalyticsService.capture({ distinctId: String(user.id), event: 'user_signed_up', - properties: { email: user.email, plan: user.plan, createdAt: user.createdAt }, + properties: { + email: user.email, + plan: user.plan, + createdAt: user.createdAt, + // #3945: carry invite/referral attribution on the signup event so the + // referral funnel is measurable. `invite` is the resolved (opaque) result + // from the eligibility registry — already in scope, no invitations import. + invited: Boolean(invite), + invitationId: invite ? String(invite.id) : null, + invitedBy: invite?.invitedBy ? String(invite.invitedBy) : null, + }, }); } catch (_) { /* analytics must not break auth */ } diff --git a/modules/auth/tests/auth.silent.catch.unit.tests.js b/modules/auth/tests/auth.silent.catch.unit.tests.js index e1accd48f..9b89cf3ff 100644 --- a/modules/auth/tests/auth.silent.catch.unit.tests.js +++ b/modules/auth/tests/auth.silent.catch.unit.tests.js @@ -329,6 +329,276 @@ describe('auth.controller signup mass-assignment strip:', () => { }); }); +describe('auth.controller signup analytics: invite/referral attribution (#3945):', () => { + beforeEach(() => { + jest.resetModules(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + }); + + test('user_signed_up carries invited:true + invitationId + invitedBy when the eligibility registry resolved an invite', async () => { + const mockCreate = jest.fn().mockResolvedValue({ + id: 'u1', email: 'invitee@y.com', firstName: 'A', lastName: 'B', provider: 'local', + }); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: mockCreate, + getBrut: jest.fn().mockResolvedValue({ id: 'u1' }), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + + // Closed-signup, invite-gated path: the eligibility registry resolves + claims + // the invite and returns { invite, finalize, release } — auth relays it verbatim. + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue({ + invite: { id: 'inv1', email: 'invitee@y.com', invitedBy: 'inviter1' }, + finalize: jest.fn().mockResolvedValue({ id: 'inv1', status: 'accepted' }), + release: jest.fn(), + }), + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { + handleSignupOrganization: jest.fn().mockResolvedValue({ + organization: null, joined: false, pendingJoin: false, + abilities: [], organizationSetupRequired: false, + emailVerificationRequired: false, suggestedOrganization: null, + }), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: false, in: true }, // closed signup — invite is required to open the gate + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.status = opts?.status; + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + const mockCapture = jest.fn(); + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { + body: { email: 'invitee@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, + query: { inviteToken: 'tok' }, + }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.signup(req, res); + + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'u1', + event: 'user_signed_up', + properties: expect.objectContaining({ + invited: true, + invitationId: 'inv1', + invitedBy: 'inviter1', + }), + })); + }); + + test('user_signed_up carries invited:false + null invitationId/invitedBy on a non-invited (open) signup', async () => { + const mockCreate = jest.fn().mockResolvedValue({ + id: 'u2', email: 'self@y.com', firstName: 'C', lastName: 'D', provider: 'local', + }); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: mockCreate, + getBrut: jest.fn().mockResolvedValue({ id: 'u2' }), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), // no invite opened the gate + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { + handleSignupOrganization: jest.fn().mockResolvedValue({ + organization: null, joined: false, pendingJoin: false, + abilities: [], organizationSetupRequired: false, + emailVerificationRequired: false, suggestedOrganization: null, + }), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, // open signup — no invite required + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.status = opts?.status; + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + const mockCapture = jest.fn(); + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { + body: { email: 'self@y.com', firstName: 'C', lastName: 'D', password: 'P@ss1234!' }, + query: {}, + }; + const res = { + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await AuthController.signup(req, res); + + expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({ + distinctId: 'u2', + event: 'user_signed_up', + properties: expect.objectContaining({ + invited: false, + invitationId: null, + invitedBy: null, + }), + })); + }); +}); + describe('auth.password.controller silent-catch error logging:', () => { let mockWarn; let mockError; diff --git a/modules/billing/services/billing.referral.service.js b/modules/billing/services/billing.referral.service.js index 33898f1e0..943ad2341 100644 --- a/modules/billing/services/billing.referral.service.js +++ b/modules/billing/services/billing.referral.service.js @@ -3,6 +3,7 @@ */ import config from '../../../config/index.js'; import logger from '../../../lib/services/logger.js'; +import mailer from '../../../lib/helpers/mailer/index.js'; import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; /** @@ -145,6 +146,47 @@ const grantSide = async ({ userId, units, key, expiresAt }) => { return { applied: false, reason: result.reason ?? 'duplicate_grant', organizationId }; }; +/** + * @function notifyReferrer + * @description Best-effort email to the referrer once their referral reward is + * CREDITED (#3945) — today the grant is a silent ledger credit; this closes + * that gap by reusing the stack's EXISTING mailer abstraction (mirrors + * org-request-approved / org-member-added in + * organizations.membership.service.js), not a new notification channel. + * Called ONLY when grantSide reports `applied:true` for the referrer side — + * the repository's atomic refId dedup guard means that happens exactly ONCE + * per invitation, so a reconcile-cron back-fill or a replayed event can never + * double-notify. Self-guarded: a mailer/lookup failure is logged and never + * propagates — the grant itself already landed and must not be affected. + * @param {string} userId - The referrer's user id. + * @param {number} units - Units credited to the referrer (config-driven). + * @returns {Promise} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const notifyReferrer = async (userId, units) => { + if (!mailer.isConfigured()) return; + try { + const { default: UserService } = await import('../../users/services/users.service.js'); + const user = await UserService.getBrut({ id: String(userId) }); + if (!user?.email) return; + await mailer.sendMail({ + to: user.email, + subject: `You earned a referral reward — ${config.app.title}`, + template: 'referral-reward-earned', + params: { + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + units, + appName: config.app.title, + }, + }); + } catch (err) { + logger.warn('[billing.referral] referrer notification failed (non-fatal)', { + userId: String(userId), + message: err?.message, + }); + } +}; + /** * @function grantForInvitation * @description Apply the standard referral grant for one accepted invitation: @@ -159,6 +201,9 @@ const grantSide = async ({ userId, units, key, expiresAt }) => { * reconcile cron) — the branches below only label WHY an absent side was * skipped (observability). Each side is keyed * `referral::` so replays can never double-credit. + * A freshly APPLIED referrer grant (first successful credit for this + * invitation) also triggers a best-effort referrer notification email + * (#3945, notifyReferrer) — never on a duplicate/idempotent replay. * May reject on infrastructure errors — callers own their failure handling * (the listener self-guards, the cron counts errors). * @param {Object} payload - The `invitation.accepted` payload (or its cron reconstruction). @@ -195,6 +240,11 @@ const grantForInvitation = async ({ invitationId, invitedBy, acceptedUserId } = // Referrer side — skip actor-less invites and trivial self-referrals (#3833 owns the full guard). if (expected.has('referrer')) { result.referrer = await grantSide({ userId: invitedBy, units: cfg.referrerUnits, key: expected.get('referrer'), expiresAt }); + // #3945: notify ONLY on a freshly applied grant — never on an idempotent replay + // (grantSide's atomic dedup guard means `applied:true` fires exactly once). + if (result.referrer.applied) { + await notifyReferrer(invitedBy, cfg.referrerUnits); + } } else if (!invitedBy) { result.referrer = { applied: false, reason: 'no_inviter' }; } else if (String(invitedBy) === String(acceptedUserId)) { diff --git a/modules/billing/tests/billing.referral.service.unit.tests.js b/modules/billing/tests/billing.referral.service.unit.tests.js index 3d7b2ec89..a145db886 100644 --- a/modules/billing/tests/billing.referral.service.unit.tests.js +++ b/modules/billing/tests/billing.referral.service.unit.tests.js @@ -23,6 +23,7 @@ describe('billing.referral.service unit tests:', () => { let mockUserService; let mockMembershipRepository; let mockLogger; + let mockMailer; const invitationId = '64b2f0000000000000000001'; const inviterId = '64b2f0000000000000000010'; @@ -34,6 +35,7 @@ describe('billing.referral.service unit tests:', () => { jest.resetModules(); mockConfig = { + app: { title: 'Test App' }, billing: { referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500, expiryDays: 365 }, }, @@ -46,7 +48,7 @@ describe('billing.referral.service unit tests:', () => { // Users keyed by id — getBrut({ id }) resolves from this map. const users = { - [inviterId]: { _id: inviterId, currentOrganization: inviterOrgId }, + [inviterId]: { _id: inviterId, currentOrganization: inviterOrgId, email: 'inviter@example.com', firstName: 'In', lastName: 'Viter' }, [refereeId]: { _id: refereeId, currentOrganization: refereeOrgId }, }; mockUserService = { @@ -56,9 +58,13 @@ describe('billing.referral.service unit tests:', () => { mockMembershipRepository = { findOne: jest.fn().mockResolvedValue(null) }; mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + // Mailer OFF by default (matches the mailer.isConfigured() gate default in most + // deployments) — tests that need to assert notifyReferrer's email flip it ON. + mockMailer = { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn().mockResolvedValue({}) }; jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig })); jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ default: mockMailer })); jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ default: mockRepository })); jest.unstable_mockModule('../../users/services/users.service.js', () => ({ default: mockUserService })); jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({ @@ -259,4 +265,63 @@ describe('billing.referral.service unit tests:', () => { expect(result).toEqual({ skipped: 'no_invitation_id' }); expect(mockRepository.creditGrant).not.toHaveBeenCalled(); }); + + describe('notifyReferrer (#3945 referrer email on a freshly applied grant)', () => { + test('mailer OFF (default in this suite) → no email lookup, no send', async () => { + await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('mailer ON + referrer grant freshly applied → sends the referral-reward-earned email to the referrer', async () => { + mockMailer.isConfigured.mockReturnValue(true); + + await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(mockMailer.sendMail).toHaveBeenCalledTimes(1); + const mail = mockMailer.sendMail.mock.calls[0][0]; + expect(mail.to).toBe('inviter@example.com'); + expect(mail.template).toBe('referral-reward-earned'); + expect(mail.params).toMatchObject({ units: 1000, appName: 'Test App', displayName: 'In Viter' }); + }); + + test('mailer ON but referrer side not applied (e.g. self-referral) → no email', async () => { + mockMailer.isConfigured.mockReturnValue(true); + + await BillingReferralService.grantForInvitation({ invitationId, invitedBy: refereeId, acceptedUserId: refereeId }); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('mailer ON but referrer grant is a duplicate replay (applied:false) → no email (never double-notifies)', async () => { + mockMailer.isConfigured.mockReturnValue(true); + mockRepository.creditGrant.mockResolvedValue({ doc: null, applied: false, reason: 'duplicate_grant' }); + + await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('mailer ON but the referrer user has no email → no send, no throw', async () => { + mockMailer.isConfigured.mockReturnValue(true); + mockUserService._users[inviterId] = { _id: inviterId, currentOrganization: inviterOrgId }; // no email + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + expect(result.referrer).toMatchObject({ applied: true }); + }); + + test('a mailer send failure is swallowed — the grant result is unaffected, error is logged', async () => { + mockMailer.isConfigured.mockReturnValue(true); + mockMailer.sendMail.mockRejectedValue(new Error('SMTP down')); + + const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId }); + + expect(result.referrer).toMatchObject({ applied: true, organizationId: inviterOrgId }); + expect(mockLogger.warn).toHaveBeenCalledWith( + '[billing.referral] referrer notification failed (non-fatal)', + expect.objectContaining({ userId: inviterId, message: 'SMTP down' }), + ); + }); + }); }); diff --git a/modules/invitations/README.md b/modules/invitations/README.md index fe077c32e..4a944bd6b 100644 --- a/modules/invitations/README.md +++ b/modules/invitations/README.md @@ -141,9 +141,17 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts service: admins read the platform-global list; any other caller reads only the invitations they sent (`invitedBy`-scoped — the `{ invitedBy: 1 }` index covers it; a caller with no resolvable id gets `[]`, never the `invitedBy:null` - admin-created rows). CASL still grants the route to admins only — widening the - `Invitation` abilities to regular users is the referral phase's flip; the scoping - ships first so that flip can never leak invitee emails (PII) platform-wide. + admin-created rows). **CASL widening — SHIPPED (#3945), config-gated**: + `config.invitations.userFacing` (stack default OFF — preserves existing + deployments) flips `invitationAbilities` to grant any authenticated user + `create` + `read` on `Invitation` (still TYPE-level; the real invitedBy-scoping + is the service list() above — see `invitations.policy.js` for why no + document-subject is registered). `resend` stays explicitly admin-gated in the + controller regardless of the flag (the POST→`create` method-mapping collision + with new-invitation creation). Also shipped: an `invitation_redeemed` analytics + event on accept, invite/referral properties on `user_signed_up`, and a + best-effort referrer notification email (`referral-reward-earned` template) + when a referral reward is freshly credited. 2. **Self-referral guard — SHIPPED (#3833), with a known residual**: `create()` rejects 422 "You cannot invite yourself" when the invitee email equals the inviter's own, before the E9 registered-email check. The grant-side floor @@ -160,7 +168,9 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts `billing.referral` there is a silent no-op. The Vue Referrals tab reads `GET /api/auth/config` (`sign.up`) and replaces the invite form with an informational state when signup is open (the referrals list stays read-only). -4. **Index `referredBy`** alongside the first real referral query. +4. **Index `referredBy` — SHIPPED (#3945)**: `{ referredBy: 1 }` on the `User` model, + added alongside the CASL widening above (the account Referrals view is the first + real referral query). ## UI diff --git a/modules/invitations/config/invitations.development.config.js b/modules/invitations/config/invitations.development.config.js index b9b42e12d..314d531d3 100644 --- a/modules/invitations/config/invitations.development.config.js +++ b/modules/invitations/config/invitations.development.config.js @@ -11,6 +11,19 @@ const config = { sign: { inviteExpiresInDays: 14, // signup invite link validity (days) }, + invitations: { + /** + * User-facing referral invitations (#3945) — stack default OFF, preserves + * existing deployments' admin-only behavior. When true, invitationAbilities + * grants any authenticated user `create` on Invitation (their own referral + * link) and `read` (scoped server-side to invitations THEY sent — see + * InvitationsService.list(), #3833); platform admins keep `manage all` + * regardless. Pair with `billing.referral.enabled` (billing.development.config.js) + * to actually reward accepted referrals — this flag only controls WHO can see/ + * create invitations, not whether a reward is granted. + */ + userFacing: false, + }, }; export default config; diff --git a/modules/invitations/controllers/invitations.controller.js b/modules/invitations/controllers/invitations.controller.js index ee3a1304f..f19d22797 100644 --- a/modules/invitations/controllers/invitations.controller.js +++ b/modules/invitations/controllers/invitations.controller.js @@ -71,14 +71,23 @@ const remove = async (req, res) => { }; /** - * @desc Admin: re-send the invitation email for a pending invitation (existing token) + * @desc Admin: re-send the invitation email for a pending invitation (existing token). + * Explicitly admin-gated regardless of `config.invitations.userFacing` (#3945): CASL + * has no per-document Invitation subject registered, and this POST route resolves to + * the SAME 'create' action (methodToAction) as new-invitation creation — a widened + * non-admin 'create' grant would otherwise also open resend to ANY invitation, not just + * the caller's own. This is the defense-in-depth mirror (see invitations.policy.js). * @param {Object} req - Express request object + * @param {Object} req.user - Authenticated caller * @param {Object} req.invitation - Loaded invitation document (set by invitationByID middleware) * @param {string} req.invitation.id - Invitation id * @param {Object} res - Express response object - * @returns {Promise} Sends HTTP 200 with the invitation, 409 when not pending, or 422 on error + * @returns {Promise} Sends HTTP 200 with the invitation, 403 when not admin, 409 when not pending, or 422 on error */ const resend = async (req, res) => { + if (!Array.isArray(req.user?.roles) || !req.user.roles.includes('admin')) { + return responses.error(res, 403, 'Forbidden', 'Only platform admins can resend invitations')(); + } try { const invitation = await InvitationService.resend(req.invitation.id); responses.success(res, 'invitation resent')(invitation); diff --git a/modules/invitations/policies/invitations.policy.js b/modules/invitations/policies/invitations.policy.js index 36f40a208..a348de4f2 100644 --- a/modules/invitations/policies/invitations.policy.js +++ b/modules/invitations/policies/invitations.policy.js @@ -1,6 +1,7 @@ /** * Signup-invitation abilities for CASL document/path authorization. */ +import config from '../../../config/index.js'; /** * Register the invitations path → subject mapping. @@ -17,7 +18,22 @@ export function invitationSubjectRegistration({ registerPathSubject }) { } /** - * Only platform admins can manage signup invitations. + * Platform admins keep full management of signup invitations. When + * `config.invitations.userFacing` is on (#3945, default OFF — preserves existing + * deployments' admin-only behavior), any other authenticated user can create their + * OWN invitation (a referral link) and read invitations THEY sent. + * + * Both grants below are TYPE-level (collection) CASL rules: no Invitation + * document-subject is registered (invitationSubjectRegistration only registers a + * path-subject), so a per-document `invitedBy` condition would never be evaluated — + * adding one here would be decorative and misleading. The REAL invitedBy-scoping for + * `read` is enforced downstream in InvitationsService.list() (#3833, deliberately + * mirrored there). This is also WHY no document-subject is registered: the + * `/invitations/:id/resend` route is a POST, which the generic HTTP-method→CASL-action + * map (methodToAction) resolves to the SAME 'create' action as new-invitation creation — + * granting 'create' at the type level would otherwise also open resend to any invitee. + * invitations.controller.js's `resend` explicitly stays admin-gated to close that gap; + * `remove` (revoke) is safe as-is because non-admins are never granted 'delete'. * @param {Object} user * @param {Object|null} membership * @param {Object} builder @@ -25,5 +41,12 @@ export function invitationSubjectRegistration({ registerPathSubject }) { * @returns {void} */ export function invitationAbilities(user, membership, { can }) { - if (Array.isArray(user?.roles) && user.roles.includes('admin')) can('manage', 'all'); + if (Array.isArray(user?.roles) && user.roles.includes('admin')) { + can('manage', 'all'); + return; + } + if (config.invitations?.userFacing) { + can('create', 'Invitation'); + can('read', 'Invitation'); + } } diff --git a/modules/invitations/services/invitations.service.js b/modules/invitations/services/invitations.service.js index 5dc6e3510..014d54b52 100644 --- a/modules/invitations/services/invitations.service.js +++ b/modules/invitations/services/invitations.service.js @@ -12,6 +12,7 @@ import mails from '../../../lib/helpers/mailer/index.js'; import getBaseUrl from '../../../lib/helpers/getBaseUrl.js'; import logger from '../../../lib/services/logger.js'; import AppError from '../../../lib/helpers/AppError.js'; +import AnalyticsService from '../../../lib/services/analytics.js'; /** * @desc Build the signup-invite email payload for an invitation. Shared by @@ -250,7 +251,9 @@ const finalize = async (id, userId) => { * via UserService.updateById (raw update — bypasses the client whitelist + Zod, * so this is the ONLY way the field is ever written; never from a client body). * invitations already depends on users (the E9 guard), so this keeps auth import-free. - * 3. emits `invitation.accepted` so optional consumers (the billing #3842 credit-grant) + * 3. captures an `invitation_redeemed` analytics event (#3945, best-effort, never + * breaks accept), + * 4. emits `invitation.accepted` so optional consumers (the billing #3842 credit-grant) * can react fire-and-forget. * * Referral substrate — NO credit-grant logic here; this only wires the field + event @@ -299,6 +302,19 @@ const accept = async (invite, userId) => { }); } } + // Analytics — fire-and-forget, never break accept (#3945). Mirrors the try/catch + // convention already used around AnalyticsService calls elsewhere (auth.controller, + // billing.init) even though the service's own capture() never throws — belt+suspenders. + try { + AnalyticsService.capture({ + distinctId: String(userId), + event: 'invitation_redeemed', + properties: { + invitationId: String(invite.id), + invitedBy: invitedBy ? String(invitedBy) : null, + }, + }); + } catch (_) { /* analytics must never break accept */ } // Always emit on accept (invitedBy may be null) so the event is the single canonical // "invite consumed" signal. Guard the emit so a synchronous listener throw cannot // escape into the signup flow (an emitted 'error' would crash without the init listener). diff --git a/modules/invitations/tests/invitations.controller.unit.tests.js b/modules/invitations/tests/invitations.controller.unit.tests.js index 9162b23a3..bc749c5f6 100644 --- a/modules/invitations/tests/invitations.controller.unit.tests.js +++ b/modules/invitations/tests/invitations.controller.unit.tests.js @@ -79,21 +79,23 @@ describe('invitations.controller.remove', () => { }); describe('invitations.controller.resend', () => { + const admin = { roles: ['admin'] }; + test('resends via the service and responds success', async () => { mockService.resend.mockResolvedValue({ id: 'i1', status: 'pending' }); - await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes()); expect(mockService.resend).toHaveBeenCalledWith('i1'); expect(success).toHaveBeenCalledWith(expect.anything(), 'invitation resent'); expect(successInner).toHaveBeenCalledWith({ id: 'i1', status: 'pending' }); }); test('threads a 409 (non-pending) as Conflict', async () => { mockService.resend.mockRejectedValue(Object.assign(new Error('Only pending invitations can be resent'), { status: 409 })); - await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes()); expect(error).toHaveBeenCalledWith(expect.anything(), 409, 'Conflict', expect.any(String)); }); test('maps a 422 (mailer unconfigured) as Unprocessable Entity', async () => { mockService.resend.mockRejectedValue(Object.assign(new Error('mailer is not configured'), { status: 422 })); - await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes()); expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String)); }); // #3966 hardening: a bare transport rejection (no `.status`, unlike the @@ -103,7 +105,7 @@ describe('invitations.controller.resend', () => { test('mail-transport failure (no .status) responds with a generic message, never the raw provider error, and logs server-side', async () => { const providerError = new Error('Resend API error: 401 Unauthorized — invalid API key sk_live_abc123'); mockService.resend.mockRejectedValue(providerError); - await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes()); expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String)); const clientMessage = error.mock.calls[0][3]; @@ -116,6 +118,28 @@ describe('invitations.controller.resend', () => { message: providerError.message, })); }); + + // #3945: resend stays admin-only even though invitationAbilities can widen 'create' + // to authenticated users — see invitations.policy.js / invitations.controller.js + // comments for why CASL alone cannot close this gap (POST→'create' method-mapping + // collision with new-invitation creation). + describe('#3945 admin-only gate (defense-in-depth, independent of CASL)', () => { + test('403s a non-admin caller and never calls the service', async () => { + await controller.resend({ invitation: { id: 'i1' }, user: { roles: ['user'] } }, makeRes()); + expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', 'Only platform admins can resend invitations'); + expect(mockService.resend).not.toHaveBeenCalled(); + }); + test('403s a caller with no roles array and never calls the service', async () => { + await controller.resend({ invitation: { id: 'i1' }, user: {} }, makeRes()); + expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', expect.any(String)); + expect(mockService.resend).not.toHaveBeenCalled(); + }); + test('403s when req.user is missing entirely and never calls the service', async () => { + await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', expect.any(String)); + expect(mockService.resend).not.toHaveBeenCalled(); + }); + }); }); describe('invitations.controller.verify', () => { diff --git a/modules/invitations/tests/invitations.policy.unit.tests.js b/modules/invitations/tests/invitations.policy.unit.tests.js index 6f1b3ab4d..3700aa5af 100644 --- a/modules/invitations/tests/invitations.policy.unit.tests.js +++ b/modules/invitations/tests/invitations.policy.unit.tests.js @@ -1,7 +1,17 @@ import { jest } from '@jest/globals'; +// Mutable mock config object — invitationAbilities reads config.invitations?.userFacing +// at CALL time (not import time), so tests toggle this same object reference between +// flag states instead of re-mocking the module per test. +const mockConfig = { invitations: { userFacing: false } }; +jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig })); + const { invitationSubjectRegistration, invitationAbilities } = await import('../policies/invitations.policy.js'); +beforeEach(() => { + mockConfig.invitations.userFacing = false; +}); + describe('invitationSubjectRegistration', () => { test('registers a single path-subject predicate → Invitation', () => { const registerPathSubject = jest.fn(); @@ -28,19 +38,60 @@ describe('invitationSubjectRegistration', () => { }); describe('invitationAbilities', () => { - test('grants manage all for admin', () => { + test('grants manage all for admin regardless of the userFacing flag (OFF)', () => { + const can = jest.fn(); + invitationAbilities({ roles: ['admin'] }, null, { can }); + expect(can).toHaveBeenCalledWith('manage', 'all'); + expect(can).toHaveBeenCalledTimes(1); + }); + + test('grants manage all for admin regardless of the userFacing flag (ON)', () => { + mockConfig.invitations.userFacing = true; const can = jest.fn(); invitationAbilities({ roles: ['admin'] }, null, { can }); expect(can).toHaveBeenCalledWith('manage', 'all'); + // Admin returns early — never also gets the non-admin create/read grants. + expect(can).toHaveBeenCalledTimes(1); }); - test('grants nothing for non-admin', () => { + + test('userFacing OFF (default): grants nothing for a non-admin', () => { const can = jest.fn(); invitationAbilities({ roles: ['user'] }, null, { can }); expect(can).not.toHaveBeenCalled(); }); - test('grants nothing when roles is absent', () => { + + test('userFacing OFF (default): grants nothing when roles is absent', () => { const can = jest.fn(); invitationAbilities({}, null, { can }); expect(can).not.toHaveBeenCalled(); }); + + test('userFacing ON (#3945): grants create + read (type-level) for a non-admin', () => { + mockConfig.invitations.userFacing = true; + const can = jest.fn(); + invitationAbilities({ id: 'u1', roles: ['user'] }, null, { can }); + expect(can).toHaveBeenCalledWith('create', 'Invitation'); + expect(can).toHaveBeenCalledWith('read', 'Invitation'); + expect(can).toHaveBeenCalledTimes(2); + }); + + test('userFacing ON: still grants create/read to a user-shaped caller with no roles array', () => { + mockConfig.invitations.userFacing = true; + const can = jest.fn(); + invitationAbilities({ id: 'u1' }, null, { can }); + // No roles array → not admin (falls through), but userFacing is ON so the + // non-admin grant still fires — any caller reaching this function is + // presumed authenticated by the policy middleware upstream. + expect(can).toHaveBeenCalledWith('create', 'Invitation'); + expect(can).toHaveBeenCalledWith('read', 'Invitation'); + }); + + test('userFacing ON does not grant delete/manage/update to a non-admin (resend/revoke stay out of scope)', () => { + mockConfig.invitations.userFacing = true; + const can = jest.fn(); + invitationAbilities({ id: 'u1', roles: ['user'] }, null, { can }); + expect(can).not.toHaveBeenCalledWith('delete', 'Invitation'); + expect(can).not.toHaveBeenCalledWith('manage', expect.anything()); + expect(can).not.toHaveBeenCalledWith('update', 'Invitation'); + }); }); diff --git a/modules/invitations/tests/invitations.service.unit.tests.js b/modules/invitations/tests/invitations.service.unit.tests.js index b04203e80..d9ac455bd 100644 --- a/modules/invitations/tests/invitations.service.unit.tests.js +++ b/modules/invitations/tests/invitations.service.unit.tests.js @@ -39,6 +39,11 @@ jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, })); +const mockAnalytics = { capture: jest.fn() }; +jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: mockAnalytics, +})); + const InvitationRepository = (await import('../repositories/invitations.repository.js')).default; const InvitationService = (await import('../services/invitations.service.js')).default; // Real events singleton — the service emits on it; we spy to assert the payload. @@ -280,6 +285,42 @@ describe('InvitationService.accept (P8a — referral substrate seam)', () => { expect(mockUserService.updateById).not.toHaveBeenCalled(); expect(emitSpy).not.toHaveBeenCalledWith('invitation.accepted', expect.anything()); }); + + // #3945: analytics — an accepted invite captures a redemption event. + test('captures an invitation_redeemed analytics event with invitationId + invitedBy', async () => { + const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' }; + await InvitationService.accept(invite, 'u1'); + expect(mockAnalytics.capture).toHaveBeenCalledWith({ + distinctId: 'u1', + event: 'invitation_redeemed', + properties: { invitationId: 'i1', invitedBy: 'inviter1' }, + }); + }); + + test('invitedBy null (admin-created invite): invitation_redeemed still fires with invitedBy:null', async () => { + const invite = { id: 'i2', email: 'c@d.co', invitedBy: null }; + await InvitationService.accept(invite, 'u2'); + expect(mockAnalytics.capture).toHaveBeenCalledWith({ + distinctId: 'u2', + event: 'invitation_redeemed', + properties: { invitationId: 'i2', invitedBy: null }, + }); + }); + + test('does NOT capture invitation_redeemed when finalize() returns null (no side-effects fire)', async () => { + InvitationRepository.finalize.mockResolvedValue(null); + const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' }; + await InvitationService.accept(invite, 'u1'); + expect(mockAnalytics.capture).not.toHaveBeenCalled(); + }); + + test('best-effort: an analytics capture throw is swallowed (still emits, still returns the doc)', async () => { + mockAnalytics.capture.mockImplementationOnce(() => { throw new Error('analytics boom'); }); + const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' }; + const result = await InvitationService.accept(invite, 'u1'); + expect(result).toMatchObject({ status: 'accepted' }); + expect(emitSpy).toHaveBeenCalledWith('invitation.accepted', expect.objectContaining({ acceptedUserId: 'u1' })); + }); }); describe('InvitationService.sweepStaleClaims (E2)', () => { diff --git a/modules/users/models/users.model.mongoose.js b/modules/users/models/users.model.mongoose.js index 89184c812..5aaee807b 100644 --- a/modules/users/models/users.model.mongoose.js +++ b/modules/users/models/users.model.mongoose.js @@ -58,7 +58,8 @@ const UserMongoose = new Schema( // client signup/update body. It is intentionally absent from the Zod schemas // and from every users update whitelist, so a client cannot self-assign a // referrer. null for self-serve signups and admin-created invites with no - // inviter. No index yet (default:null, no referral-list query in P8a). + // inviter. Indexed (#3945) — the user-facing Referrals view queries "who did I + // refer" (`{ referredBy: }`). referredBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', @@ -87,6 +88,9 @@ UserMongoose.index( { unique: true, name: 'email_ci_unique', collation: { locale: 'en', strength: 2 } }, ); +// #3945 — supports the user-facing Referrals view's "who did I refer" query. +UserMongoose.index({ referredBy: 1 }); + function addID() { return this._id.toHexString(); } From 92c0244e573f04efc13eae913bd89dda1fa2e92f Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 24 Jul 2026 20:26:17 +0200 Subject: [PATCH 2/3] 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. --- config/defaults/production.config.js | 10 ++++++++++ config/defaults/test.config.js | 3 +++ .../tests/rateLimiter.baseLayer.unit.tests.js | 9 +++++++++ modules/auth/routes/auth.routes.js | 6 +++++- .../billing/services/billing.referral.service.js | 2 +- .../config/invitations.development.config.js | 16 ++++++++++++++++ modules/invitations/routes/invitations.routes.js | 8 +++++++- 7 files changed, 51 insertions(+), 3 deletions(-) diff --git a/config/defaults/production.config.js b/config/defaults/production.config.js index ba1094646..f5769e37d 100644 --- a/config/defaults/production.config.js +++ b/config/defaults/production.config.js @@ -62,6 +62,16 @@ const config = { standardHeaders: true, legacyHeaders: false, }, + // Authenticated POST /api/invitations. Stricter prod cap to harden against + // mass-invite abuse once `invitations.userFacing` widens create beyond admins + // (see invitations.development.config.js for the base-layer profile). + invitationsCreate: { + windowMs: 15 * 60 * 1000, + max: 20, + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, }, log: { format: 'custom', diff --git a/config/defaults/test.config.js b/config/defaults/test.config.js index 0a835c017..f6f35572e 100644 --- a/config/defaults/test.config.js +++ b/config/defaults/test.config.js @@ -43,6 +43,9 @@ const config = { publicImage: { max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests }, + invitationsCreate: { + max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests + }, }, uploads: { avatar: { diff --git a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js index c63fa361e..1644673b5 100644 --- a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js +++ b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js @@ -6,6 +6,7 @@ import organizationsDevConfig from '../../../modules/organizations/config/organi import billingDevConfig from '../../../modules/billing/config/billing.development.config.js'; import authDevConfig from '../../../modules/auth/config/auth.development.config.js'; import uploadsDevConfig from '../../../modules/uploads/config/uploads.development.config.js'; +import invitationsDevConfig from '../../../modules/invitations/config/invitations.development.config.js'; /** * 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):', () => { // the base layer so the limiter is active under every NODE_ENV, not only prod. expectUsableProfile(uploadsDevConfig.rateLimit.publicImage); }); + + test('invitationsCreate profile lives in the invitations base layer (always merges)', () => { + // Guards POST /api/invitations (+ the /api/auth/invitations alias). Once + // `invitations.userFacing` (#3945) widens create beyond admins, an unbounded + // create is a DB-bloat / outbound-email-spam abuse surface — the profile must + // be present under every NODE_ENV, not only prod. + expectUsableProfile(invitationsDevConfig.rateLimit.invitationsCreate); + }); }); diff --git a/modules/auth/routes/auth.routes.js b/modules/auth/routes/auth.routes.js index 8d78b9090..ef82302ef 100644 --- a/modules/auth/routes/auth.routes.js +++ b/modules/auth/routes/auth.routes.js @@ -29,6 +29,10 @@ import InvitationSchema from '../../invitations/models/invitations.schema.js'; */ export default (app) => { const authLimiter = limiters.auth; + // #3945: mirrors the same profile applied on the canonical mount + // (invitations.routes.js) — this alias points at the SAME controller.create, so + // leaving it unlimited would let a caller bypass the canonical route's cap. + const createLimiter = limiters.invitationsCreate; // Signup invitations — DEPRECATION ALIAS for the canonical /api/invitations mount // (modules/invitations). MUST be declared before the greedy `/api/auth/:strategy` @@ -42,7 +46,7 @@ export default (app) => { .route('/api/auth/invitations') .all(passport.authenticate('jwt', { session: false }), policy.isAllowed) .get(invitations.list) - .post(model.isValid(InvitationSchema.Invitation), invitations.create); + .post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create); app .route('/api/auth/invitations/:invitationId') .all(passport.authenticate('jwt', { session: false }), policy.isAllowed) diff --git a/modules/billing/services/billing.referral.service.js b/modules/billing/services/billing.referral.service.js index 943ad2341..db62c6802 100644 --- a/modules/billing/services/billing.referral.service.js +++ b/modules/billing/services/billing.referral.service.js @@ -164,8 +164,8 @@ const grantSide = async ({ userId, units, key, expiresAt }) => { */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik const notifyReferrer = async (userId, units) => { - if (!mailer.isConfigured()) return; try { + if (!mailer.isConfigured()) return; const { default: UserService } = await import('../../users/services/users.service.js'); const user = await UserService.getBrut({ id: String(userId) }); if (!user?.email) return; diff --git a/modules/invitations/config/invitations.development.config.js b/modules/invitations/config/invitations.development.config.js index 314d531d3..81c5d24d2 100644 --- a/modules/invitations/config/invitations.development.config.js +++ b/modules/invitations/config/invitations.development.config.js @@ -24,6 +24,22 @@ const config = { */ userFacing: false, }, + // #3945: POST /api/invitations already had no rate limiter under admin-only access + // (a trusted caller); with `invitations.userFacing` able to widen `create` to any + // authenticated user, an unbounded create is a DB-bloat / outbound-email-spam + // abuse surface (mirrors the verify/:token route, which already uses `limiters.auth`). + // Lives in this base layer so the profile is present — and the limiter active — + // under EVERY env, not only the literal `production`; a missing profile means a + // no-op limiter. Stricter cap applied in config/defaults/production.config.js. + rateLimit: { + invitationsCreate: { + windowMs: 15 * 60 * 1000, // 15 min + max: 200, // lenient in dev; production overrides to a stricter cap + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, + }, }; export default config; diff --git a/modules/invitations/routes/invitations.routes.js b/modules/invitations/routes/invitations.routes.js index c0d757103..3ebaf7e62 100644 --- a/modules/invitations/routes/invitations.routes.js +++ b/modules/invitations/routes/invitations.routes.js @@ -19,6 +19,12 @@ import InvitationSchema from '../models/invitations.schema.js'; */ export default (app) => { const authLimiter = limiters.auth; + // #3945: create is no longer admin-only-by-trust once `invitations.userFacing` + // widens it — rate-limit it the same way verify/:token already is (mass-create + // is a DB-bloat / outbound-email-spam abuse surface). Passthrough no-op if the + // `invitationsCreate` profile is absent from config (mirrors every other named + // limiter in this stack). + const createLimiter = limiters.invitationsCreate; // Public: report whether a token is a valid invite (+ prefill email). app.route('/api/invitations/verify/:token').get(authLimiter, invitations.verify); @@ -33,7 +39,7 @@ export default (app) => { .route('/api/invitations') .all(passport.authenticate('jwt', { session: false }), policy.isAllowed) .get(invitations.list) - .post(model.isValid(InvitationSchema.Invitation), invitations.create); + .post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create); // Admin CRUD — revoke. app From 4d63fff40b418a4a6896ce7bc45aa53812cd1c94 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 24 Jul 2026 20:34:20 +0200 Subject: [PATCH 3/3] fix(invitations): non-empty in referral-reward-earned template CodeRabbit (HTMLHint): title-require. Mirrors the existing app-name pattern used elsewhere in this template's own copy. --- config/templates/referral-reward-earned.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/templates/referral-reward-earned.html b/config/templates/referral-reward-earned.html index aff8b4a3c..71b49828a 100644 --- a/config/templates/referral-reward-earned.html +++ b/config/templates/referral-reward-earned.html @@ -1,7 +1,7 @@ <!doctype html> <html lang="en"> <head> - <title> + {{appName}} referral reward

Hello {{displayName}},