Skip to content

Commit 7302eef

Browse files
feat(auth): expose sign.cap + sign.remaining on public auth-config endpoint
Adds computeSignupCapacity helper (auth.signupCapacity.js) that is a no-op when cap is null/undefined/non-numeric (all current deployments), wiring it into the getConfig handler. count() is never called when uncapped, keeping the common path identical to before.
1 parent 6ab44fd commit 7302eef

4 files changed

Lines changed: 106 additions & 38 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken';
77

88
import UserService from '../../users/services/users.service.js';
99
import InvitationService from '../services/auth.invitation.service.js';
10+
import { computeSignupCapacity } from '../services/auth.signupCapacity.js';
1011
import config from '../../../config/index.js';
1112
import model from '../../../lib/middlewares/model.js';
1213
import mails from '../../../lib/helpers/mailer/index.js';
@@ -601,43 +602,50 @@ const signout = (req, res) => {
601602
* @desc Endpoint to expose public auth configuration (sign flags and organizations settings)
602603
* @param {Object} req - Express request object
603604
* @param {Object} res - Express response object
604-
* @returns {void} Sends the public auth configuration in the HTTP response
605+
* @returns {Promise<void>} Sends the public auth configuration in the HTTP response
605606
*/
606-
const getConfig = (req, res) => {
607-
const data = {
608-
sign: {
609-
in: !!config.sign.in,
610-
up: !!config.sign.up,
611-
},
612-
oAuth: {
613-
google: !!config.oAuth?.google?.clientID,
614-
apple: !!config.oAuth?.apple?.clientID,
615-
},
616-
organizations: {
617-
enabled: !!config.organizations?.enabled,
618-
domainMatching: !!config.organizations?.domainMatching,
619-
autoCreate: !!config.organizations?.autoCreate,
620-
},
621-
mail: {
622-
configured: isMailerConfigured(),
623-
},
624-
};
625-
626-
// Authenticated users get extended org config and billing config
627-
if (req.user) {
628-
data.organizations = {
629-
...data.organizations,
630-
roles: config.organizations?.roles || [],
631-
roleDescriptions: config.organizations?.roleDescriptions || {},
632-
};
633-
data.billing = {
634-
enabled: !!config.billing?.enabled,
635-
meterMode: !!config.billing?.meterMode,
636-
equivalences: config.billing?.equivalences ?? null,
607+
const getConfig = async (req, res) => {
608+
try {
609+
const { cap, remaining } = await computeSignupCapacity(config.sign?.cap, UserService.count);
610+
const data = {
611+
sign: {
612+
in: !!config.sign.in,
613+
up: !!config.sign.up,
614+
cap, // null = uncapped; numeric = hard ceiling on total accounts (invited included)
615+
remaining, // null = uncapped; else max(0, cap - totalAccounts)
616+
},
617+
oAuth: {
618+
google: !!config.oAuth?.google?.clientID,
619+
apple: !!config.oAuth?.apple?.clientID,
620+
},
621+
organizations: {
622+
enabled: !!config.organizations?.enabled,
623+
domainMatching: !!config.organizations?.domainMatching,
624+
autoCreate: !!config.organizations?.autoCreate,
625+
},
626+
mail: {
627+
configured: isMailerConfigured(),
628+
},
637629
};
638-
}
639630

640-
responses.success(res, 'Auth config')(data);
631+
// Authenticated users get extended org config and billing config
632+
if (req.user) {
633+
data.organizations = {
634+
...data.organizations,
635+
roles: config.organizations?.roles || [],
636+
roleDescriptions: config.organizations?.roleDescriptions || {},
637+
};
638+
data.billing = {
639+
enabled: !!config.billing?.enabled,
640+
meterMode: !!config.billing?.meterMode,
641+
equivalences: config.billing?.equivalences ?? null,
642+
};
643+
}
644+
645+
responses.success(res, 'Auth config')(data);
646+
} catch (err) {
647+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
648+
}
641649
};
642650

643651
/**
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @desc Compute the public beta-capacity view for the auth config endpoint:
3+
* the configured cap and how many seats remain. Skips the (collection-wide)
4+
* count entirely when uncapped, so the common case stays a no-op.
5+
* @param {number|string|null|undefined} rawCap - config.sign.cap (null/undefined/non-numeric = uncapped; 0 = fully closed, 0 seats)
6+
* @param {() => Promise<number>} countFn - async total-account counter (UserService.count)
7+
* @returns {Promise<{cap: number|null, remaining: number|null}>}
8+
*/
9+
export const computeSignupCapacity = async (rawCap, countFn) => {
10+
const cap = rawCap != null ? Number(rawCap) : null;
11+
if (cap == null || !Number.isFinite(cap)) return { cap: null, remaining: null };
12+
const remaining = Math.max(0, cap - (await countFn()));
13+
return { cap, remaining };
14+
};

modules/auth/tests/auth.config.controller.unit.tests.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,14 @@ describe('auth.controller getConfig:', () => {
106106
const req = {}; // no req.user
107107
const res = {};
108108

109-
AuthController.getConfig(req, res);
109+
await AuthController.getConfig(req, res);
110110

111111
expect(responses.success).toHaveBeenCalledWith(res, 'Auth config');
112112
const [data] = mockResponses.successCb.mock.calls[0];
113113
expect(data.billing).toBeUndefined();
114+
// sign.cap / sign.remaining must be null when config.sign has no cap (uncapped path)
115+
expect(data.sign.cap).toBeNull();
116+
expect(data.sign.remaining).toBeNull();
114117
});
115118

116119
test('data.billing defaults to false/false/null when config.billing is undefined (authenticated)', async () => {
@@ -120,7 +123,7 @@ describe('auth.controller getConfig:', () => {
120123
const req = { user: { id: '1' } };
121124
const res = {};
122125

123-
AuthController.getConfig(req, res);
126+
await AuthController.getConfig(req, res);
124127

125128
const [data] = mockResponses.successCb.mock.calls[0];
126129
expect(data.billing).toBeDefined();
@@ -137,7 +140,7 @@ describe('auth.controller getConfig:', () => {
137140
const req = { user: { id: '1' } };
138141
const res = {};
139142

140-
AuthController.getConfig(req, res);
143+
await AuthController.getConfig(req, res);
141144

142145
const [data] = mockResponses.successCb.mock.calls[0];
143146
expect(data.billing).toBeDefined();
@@ -160,7 +163,7 @@ describe('auth.controller getConfig:', () => {
160163
const req = { user: { id: '1' } };
161164
const res = {};
162165

163-
AuthController.getConfig(req, res);
166+
await AuthController.getConfig(req, res);
164167

165168
const [data] = mockResponses.successCb.mock.calls[0];
166169
expect(data.billing.equivalences).toEqual(equivalences);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { jest } from '@jest/globals';
2+
import { computeSignupCapacity } from '../services/auth.signupCapacity.js';
3+
4+
describe('computeSignupCapacity', () => {
5+
test('uncapped (null) → {cap:null, remaining:null} and count() not called', async () => {
6+
const countFn = jest.fn();
7+
await expect(computeSignupCapacity(null, countFn)).resolves.toEqual({ cap: null, remaining: null });
8+
expect(countFn).not.toHaveBeenCalled();
9+
});
10+
11+
test('uncapped (undefined) → {cap:null, remaining:null}', async () => {
12+
const countFn = jest.fn();
13+
await expect(computeSignupCapacity(undefined, countFn)).resolves.toEqual({ cap: null, remaining: null });
14+
expect(countFn).not.toHaveBeenCalled();
15+
});
16+
17+
test('capped → remaining = cap - count', async () => {
18+
const countFn = jest.fn().mockResolvedValue(10);
19+
await expect(computeSignupCapacity(50, countFn)).resolves.toEqual({ cap: 50, remaining: 40 });
20+
});
21+
22+
test('at/over cap → remaining floored at 0', async () => {
23+
const countFn = jest.fn().mockResolvedValue(60);
24+
await expect(computeSignupCapacity(50, countFn)).resolves.toEqual({ cap: 50, remaining: 0 });
25+
});
26+
27+
test('non-numeric cap → treated as uncapped', async () => {
28+
const countFn = jest.fn();
29+
await expect(computeSignupCapacity('abc', countFn)).resolves.toEqual({ cap: null, remaining: null });
30+
expect(countFn).not.toHaveBeenCalled();
31+
});
32+
33+
test('cap = 0 → fully closed: {cap:0, remaining:0} and count IS called', async () => {
34+
const countFn = jest.fn().mockResolvedValue(0);
35+
await expect(computeSignupCapacity(0, countFn)).resolves.toEqual({ cap: 0, remaining: 0 });
36+
expect(countFn).toHaveBeenCalled();
37+
});
38+
39+
test('numeric string cap ("42") → coerced: {cap:42, remaining:40}', async () => {
40+
const countFn = jest.fn().mockResolvedValue(2);
41+
await expect(computeSignupCapacity('42', countFn)).resolves.toEqual({ cap: 42, remaining: 40 });
42+
});
43+
});

0 commit comments

Comments
 (0)