Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 61 additions & 32 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ const signup = async (req, res) => {
// invited users included; (2) eligibility — public signup open OR a valid
// invite token. The eligibility check is supplied by optional modules via the
// generic registry (auth never imports invitation code). The invitations
// checker resolves the email-pinned invite, atomically CLAIMS it (the replay
// guard, E2), and RETURNS `{ invite, finalize, release }`, which auth relays
// back here verbatim (opaque result) so this controller can canonicalize the
// account email + finalize/release the invite below.
// checker resolves the email-pinned invite, atomically CLAIMS it when required
// (closed signup) or opted into (open signup + `invitations.userFacing`, #3981;
// a lost claim race there downgrades to unclaimed instead of blocking signup —
// see invitations.init.js), and RETURNS `{ invite, claimed, finalize, release }`,
// which auth relays back here verbatim (opaque result) so this controller can
// canonicalize the account email + finalize/release the invite below.
// E4: cap is computed by computeSignupCapacity (single source of truth shared
// with getConfig) — a BLANK cap ('') means UNCAPPED. The old inline Number('')→0
// hard-rejected everyone while getConfig advertised the deployment as open.
Expand All @@ -89,19 +91,33 @@ const signup = async (req, res) => {
// positive ceiling that filled up (which DOES reject invites — they count in the cap).
const capReached = cap != null && cap > 0 && remaining <= 0;
// `signupOpen` tells the checker whether the invite is REQUIRED to open the gate.
// When public signup is open a token may be PRESENTED but is not required — the
// checker must then resolve WITHOUT claiming (E2), so an open-signup signup never
// burns / locks a presented token (preserves the P2 `!config.sign.up` gating).
// When public signup is open a token may be PRESENTED but is not required — by
// default (`invitations.userFacing: false`) the checker resolves WITHOUT claiming
// (E2), so an open-signup signup never burns / locks a presented token (preserves
// the P2 `!config.sign.up` gating). With `userFacing: true` the checker DOES claim
// it (#3981), but open signup's own invariant — a presented token must never be
// able to fail an otherwise-valid signup — still holds: a lost claim race there
// downgrades to unclaimed rather than throwing (see invitations.init.js).
const eligibility = await Eligibility.assertSignupEligible({ email: req.body.email, body: req.body, req, signupOpen: !!config.sign.up });
// null when no optional module opened the gate (registry empty or no valid invite).
const invite = eligibility?.invite || null;
// #3981: whether the invite needs finalize on success / release on failure is
// decided by `eligibility.claimed` — relayed verbatim from the checker that
// actually called claim() (invitations.init.js), the single source of truth for
// "was this atomically claimed". Reading it here rather than re-deriving the
// closed-signup / userFacing condition a second time from config avoids the two
// sides ever drifting out of lockstep (a duplicated condition could finalize an
// invite that was never claimed, or leave a claimed one stuck) — auth stays
// import-free of invitation code either way, since `claimed` is just a boolean on
// the opaque relayed result, not a call into invitations.
const inviteHonored = !!eligibility?.claimed;
if (capReached || (!config.sign.up && !invite)) {
// On the closed-signup path the eligibility checker CLAIMED the invite (E2)
// before the cap was found exhausted — release it so a cap bump later does not
// leave it stuck mid-claim (the lazy sweep would also recover it; this is
// immediate). Only the closed-signup path claims, so gate the release on it to
// avoid a no-op release (+ misleading log) on an open-signup presented token.
if (invite && !config.sign.up) {
// On the closed-signup (or userFacing open-signup) path the eligibility checker
// CLAIMED the invite (E2) before the cap was found exhausted — release it so a
// cap bump later does not leave it stuck mid-claim (the lazy sweep would also
// recover it; this is immediate). Gate the release on `inviteHonored` to avoid a
// no-op release (+ misleading log) on a presented-but-never-claimed token.
if (inviteHonored) {
try {
await eligibility?.release?.();
} catch (releaseErr) {
Expand Down Expand Up @@ -150,13 +166,14 @@ const signup = async (req, res) => {
// stays locked until the 15-min sweep. The most realistic throw is an E11000 from
// the case-insensitive unique-email index (email_ci_unique) when two case-variant signups race the same
// invited email (validation/transient errors land here as well). Mirror the three
// release sites below + the same `!config.sign.up` gating (only the closed-signup
// path claimed). Best-effort: a release failure must not mask the create error.
// release sites below + the same `inviteHonored` gating (only a claimed invite —
// closed signup, or userFacing open signup — needs releasing). Best-effort: a
// release failure must not mask the create error.
let user;
try {
user = await UserService.create(safeBody);
} catch (createErr) {
if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
throw createErr;
}

Expand Down Expand Up @@ -185,9 +202,10 @@ const signup = async (req, res) => {
} catch (verifyErr) {
try { await UserService.remove(user); } catch (_cleanupErr) { /* best-effort */ }
// E2: a claimed invite must be released on a pre-response failure so the token
// is reusable (it was only claimed, never finalized). Only the closed-signup
// path claimed, so gate the release on it (open signup never claimed).
if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
// is reusable (it was only claimed, never finalized). Gate the release on
// `inviteHonored` — only a claimed invite (closed signup, or userFacing open
// signup) needs releasing.
if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
throw verifyErr;
}

Expand All @@ -204,8 +222,8 @@ const signup = async (req, res) => {
// Best-effort cleanup; log but don't mask original error
}
// E2: release the claimed invite on org-provisioning failure so it can retry
// (only the closed-signup path claimed — open signup never did).
if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
// (gate on `inviteHonored` — only a claimed invite needs releasing).
if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } }
throw orgErr;
}

Expand Down Expand Up @@ -234,17 +252,19 @@ const signup = async (req, res) => {
});
} catch (_) { /* analytics must not break auth */ }

// E2 single-use: FINALIZE only when the invite actually opened the gate (signup
// was closed, so the invite was required). When signup is open, a token can be
// presented but is not required — and the checker never claimed it, so there is
// nothing to finalize. finalize burns single-use (usedAt + status:'accepted')
// and records the user; it runs through the closure returned by the eligibility
// checker (invitations module owns it; auth never imports invitation code). This
// is the last pre-response step, and every earlier failure path (create-throw,
// verify-failure, org-failure) already released the claim, so reaching finalize
// means the claim is still ours to burn. finalize itself is best-effort (see
// catch below).
if (invite && !config.sign.up) {
// E2 single-use: FINALIZE only when the invite was actually CLAIMED — closed
// signup (the invite was required), or open signup with `invitations.userFacing`
// on (#3981: a presented token still converts even though it wasn't required —
// closes the open-signup hole documented in the invitations README). Otherwise a
// token can be presented but is not required, and the checker never claimed it,
// so there is nothing to finalize. finalize burns single-use (usedAt +
// status:'accepted') and records the user; it runs through the closure returned
// by the eligibility checker (invitations module owns it; auth never imports
// invitation code). This is the last pre-response step, and every earlier failure
// path (create-throw, verify-failure, org-failure) already released the claim
// under the same `inviteHonored` condition, so reaching finalize means the claim
// is still ours to burn. finalize itself is best-effort (see catch below).
if (inviteHonored) {
try {
await eligibility?.finalize?.(user._id || user.id);
} catch (finalizeErr) {
Expand Down Expand Up @@ -776,6 +796,15 @@ const getConfig = async (req, res) => {
return config.package?.version || 'dev';
})(),
},
// #3981: same top-level, unauthenticated pattern as `sign` above — the invitations
// module's own README (point 3) documents that its Vue Referrals tab already reads
// `sign.up` from this same endpoint to decide whether the open-signup deployment
// can still convert referrals; `userFacing` is the second half of that gate. Expose
// ONLY this boolean — nothing else from `config.invitations` (rate-limit tuning,
// etc.) is public API surface.
invitations: {
userFacing: !!config.invitations?.userFacing,
},
};

// Authenticated users get extended org config and billing config
Expand Down
4 changes: 3 additions & 1 deletion modules/auth/services/auth.eligibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export const registerSignupEligibility = (fn) => {
* Returns the FIRST non-null result collected across all checks (in registration
* order), or null when no check returned one. The result is opaque to auth: it is
* handed straight back to the caller (e.g. the invitations checker returns
* `{ invite, finalize, release }`, which auth relays without importing any invitation code).
* `{ invite, claimed, finalize, release }` — `claimed` (#3981) tells the caller whether
* THIS check actually claimed the invite, the single source of truth for whether a
* finalize/release is meaningful — which auth relays without importing any invitation code).
*
* NOTE for future check authors (P5/P8 will register more): a THROW from ANY
* check aborts the whole chain and blocks signup — even a check that runs AFTER
Expand Down
31 changes: 31 additions & 0 deletions modules/auth/tests/auth.config.controller.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,37 @@ describe('auth.controller getConfig:', () => {
expect(data.billing.equivalences).toBeNull();
});

test('data.invitations.userFacing defaults to false, exposed unauthenticated (#3981, config.invitations undefined)', async () => {
// No `invitations` key on mockConfig at all — must default safely, and must be
// present WITHOUT req.user (same top-level, unauthenticated shape as `sign`).
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');

const req = {}; // no req.user
const res = {};

await AuthController.getConfig(req, res);

const [data] = mockResponses.successCb.mock.calls[0];
expect(data.invitations).toBeDefined();
expect(data.invitations.userFacing).toBe(false);
});

test('data.invitations.userFacing reflects config:true, unauthenticated (#3981)', async () => {
mockConfig.invitations = { userFacing: true };

const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');

const req = {}; // no req.user — must not require auth, unlike billing
const res = {};

await AuthController.getConfig(req, res);

const [data] = mockResponses.successCb.mock.calls[0];
expect(data.invitations.userFacing).toBe(true);
// ONLY the boolean is exposed — no other invitations config (e.g. rate-limit tuning).
expect(Object.keys(data.invitations)).toEqual(['userFacing']);
});

test('data.billing.equivalences is returned verbatim when set in config (authenticated)', async () => {
const equivalences = {
plans: {
Expand Down
Loading
Loading