Skip to content

Commit 7fe3865

Browse files
feat(home): add SaaS readiness checklist + admin route
- GET /api/admin/readiness (admin-only) returns startup checklist as JSON - validateSaaSReadiness() prints status table at boot (skipped in test) - 7 categories: Config, Security, Auth, Mail, Billing, Analytics, Monitoring - Smart dependencies: Resend configured → skip Nodemailer warning - Non-blocking: warnings only, never prevents startup
1 parent 5a9a6a7 commit 7fe3865

7 files changed

Lines changed: 163 additions & 2 deletions

File tree

lib/app.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const bootstrap = async () => {
8989
/**
9090
* log server configuration
9191
*/
92-
const logConfiguration = () => {
92+
const logConfiguration = async () => {
9393
// Create server URL
9494
const server = `${(config.secure && config.secure.credentials ? 'https://' : 'http://') + config.api.host}:${config.api.port}`;
9595
// Logging initialization
@@ -99,6 +99,22 @@ const logConfiguration = () => {
9999
console.log(chalk.green(`Server: ${server}`));
100100
console.log(chalk.green(`Database: ${config.db.uri}`));
101101
if (config.cors.origin.length > 0) console.log(chalk.green(`Cors: ${config.cors.origin}`));
102+
103+
// SaaS readiness summary (skip in test to keep output clean)
104+
if (process.env.NODE_ENV !== 'test') {
105+
try {
106+
const { default: HomeService } = await import('../modules/home/services/home.service.js');
107+
const checks = HomeService.getReadinessStatus();
108+
console.log();
109+
console.log(chalk.green('SaaS Readiness:'));
110+
checks.forEach((c) => {
111+
const icon = c.status === 'ok' ? chalk.green('OK') : chalk.yellow('WARN');
112+
console.log(` ${icon} ${c.category.padEnd(12)} ${c.message}`);
113+
});
114+
} catch (_err) {
115+
// Non-blocking — readiness check failure should not prevent boot
116+
}
117+
}
102118
};
103119

104120
// Boot up the server
@@ -118,7 +134,7 @@ const start = async () => {
118134
if (config.secure && config.secure.credentials)
119135
http = await nodeHttps.createServer(config.secure.credentials, app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host);
120136
else http = await nodeHttp.createServer(app).setTimeout(config.api.timeout).listen(config.api.port, config.api.host);
121-
logConfiguration();
137+
await logConfiguration();
122138
return {
123139
db,
124140
orm,

lib/middlewares/policy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ const deriveSubjectType = (routePath) => {
201201
if (routePath.startsWith('/api/tasks')) return 'Task';
202202
if (routePath.startsWith('/api/uploads')) return 'Upload';
203203
if (routePath.startsWith('/api/home')) return 'Home';
204+
if (routePath === '/api/admin/readiness') return 'Readiness';
204205
if (routePath.startsWith('/api/admin/organizations')) return 'Organization';
205206
if (routePath.includes('/requests')) return 'Membership';
206207
if (routePath.includes('/members')) return 'Membership';

modules/home/controllers/home.controller.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,26 @@ const health = (req, res) => {
9696
responses.success(res, 'health check')(payload);
9797
};
9898

99+
/**
100+
* @desc Endpoint to return SaaS readiness checks (admin only)
101+
* @param {Object} req - Express request object
102+
* @param {Object} res - Express response object
103+
*/
104+
const readiness = (req, res) => {
105+
try {
106+
const data = HomeService.getReadinessStatus();
107+
responses.success(res, 'readiness check')(data);
108+
} catch (err) {
109+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
110+
}
111+
};
112+
99113
export default {
100114
releases,
101115
changelogs,
102116
team,
103117
page,
104118
pageByName,
105119
health,
120+
readiness,
106121
};

modules/home/policies/home.policy.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
*/
1313
export function homeAbilities(user, membership, { can }) {
1414
can('read', 'Home');
15+
if (Array.isArray(user?.roles) && user.roles.includes('admin')) {
16+
can('read', 'Readiness');
17+
}
1518
}
1619

1720
/**

modules/home/routes/home.route.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export default (app) => {
3535
app.route('/api/home/team').all(policy.isAllowed).get(home.team);
3636
// markdown files
3737
app.route('/api/home/pages/:name').all(policy.isAllowed).get(home.page);
38+
// readiness check — admin only (JWT + CASL)
39+
app.route('/api/admin/readiness').all(passport.authenticate('jwt', { session: false }), policy.isAllowed).get(home.readiness);
3840

3941
// Finish by binding the task middleware
4042
app.param('name', home.pageByName);

modules/home/services/home.service.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ import mongoose from 'mongoose';
1010

1111
import AuthService from '../../auth/services/auth.service.js';
1212
import config from '../../../config/index.js';
13+
import mailer from '../../../lib/helpers/mailer/index.js';
1314
import HomeRepository from '../repositories/home.repository.js';
1415

16+
/**
17+
* @desc Check whether a config value is meaningfully set (truthy, non-empty, not a DEVKIT placeholder).
18+
* @param {*} value - Config value to check
19+
* @returns {boolean} true if set and not a placeholder
20+
*/
21+
const isSet = (value) => !!(value && typeof value === 'string' && value.trim() !== '' && !value.startsWith('DEVKIT_NODE_'));
22+
1523
/**
1624
* @desc Function to get all admin users in db
1725
* @return {Promise} All users
@@ -102,10 +110,80 @@ const getHealthStatus = () => {
102110
};
103111
};
104112

113+
/**
114+
* @desc Run SaaS readiness checks against current configuration.
115+
* Each check returns { category, status, message }.
116+
* @returns {Array<{category: string, status: string, message: string}>}
117+
*/
118+
const getReadinessStatus = () => {
119+
const checks = [];
120+
121+
// config — domain
122+
checks.push({
123+
category: 'config',
124+
status: isSet(config.domain) ? 'ok' : 'warning',
125+
message: isSet(config.domain) ? 'Domain configured' : 'Domain not configured',
126+
});
127+
128+
// security — JWT secret
129+
const jwtDefault = config.jwt?.secret === 'WaosSecretKeyExampleToChnageAbsolutely';
130+
checks.push({
131+
category: 'security',
132+
status: jwtDefault ? 'warning' : 'ok',
133+
message: jwtDefault ? 'JWT secret is default — change it before production' : 'JWT secret is custom',
134+
});
135+
136+
// auth — OAuth providers
137+
const oAuthProviders = [];
138+
if (isSet(config.oAuth?.google?.clientID)) oAuthProviders.push('Google');
139+
if (isSet(config.oAuth?.apple?.clientID)) oAuthProviders.push('Apple');
140+
checks.push({
141+
category: 'auth',
142+
status: oAuthProviders.length > 0 ? 'ok' : 'warning',
143+
message: oAuthProviders.length > 0 ? `OAuth configured (${oAuthProviders.join(', ')})` : 'No OAuth provider configured',
144+
});
145+
146+
// mail — mailer from
147+
const mailConfigured = mailer.isConfigured();
148+
const mailProvider = config.mailer?.provider || 'nodemailer';
149+
checks.push({
150+
category: 'mail',
151+
status: mailConfigured ? 'ok' : 'warning',
152+
message: mailConfigured ? `Mail configured (${mailProvider})` : 'No mail provider configured',
153+
});
154+
155+
// billing — Stripe
156+
const stripeConfigured = isSet(config.stripe?.secretKey);
157+
checks.push({
158+
category: 'billing',
159+
status: stripeConfigured ? 'ok' : 'warning',
160+
message: stripeConfigured ? 'Stripe configured' : 'Stripe not configured',
161+
});
162+
163+
// analytics — PostHog
164+
const posthogConfigured = isSet(config.posthog?.apiKey);
165+
checks.push({
166+
category: 'analytics',
167+
status: posthogConfigured ? 'ok' : 'warning',
168+
message: posthogConfigured ? 'PostHog configured' : 'PostHog not configured',
169+
});
170+
171+
// monitoring — Sentry
172+
const sentryConfigured = isSet(config.sentry?.dsn);
173+
checks.push({
174+
category: 'monitoring',
175+
status: sentryConfigured ? 'ok' : 'warning',
176+
message: sentryConfigured ? 'Sentry configured' : 'Sentry not configured',
177+
});
178+
179+
return checks;
180+
};
181+
105182
export default {
106183
page,
107184
releases,
108185
changelogs,
109186
team,
110187
getHealthStatus,
188+
getReadinessStatus,
111189
};

modules/home/tests/home.integration.tests.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('Home integration tests:', () => {
1818
let agent;
1919
let HomeService;
2020
let adminToken;
21+
let userToken;
2122
let originalOrganizationsEnabled;
2223

2324
// init
@@ -50,6 +51,17 @@ describe('Home integration tests:', () => {
5051
roles: ['admin'],
5152
});
5253
adminToken = jwt.sign({ userId: admin.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn });
54+
55+
// Create regular user and sign JWT for readiness auth tests
56+
const regularUser = await User.create({
57+
firstName: 'Regular',
58+
lastName: 'User',
59+
email: 'regular-readiness@test.com',
60+
password: 'W@os.jsI$Aw3$0m3',
61+
provider: 'local',
62+
roles: ['user'],
63+
});
64+
userToken = jwt.sign({ userId: regularUser.id }, config.jwt.secret, { expiresIn: config.jwt.expiresIn });
5365
} catch (err) {
5466
console.log(err);
5567
expect(err).toBeFalsy();
@@ -186,6 +198,40 @@ describe('Home integration tests:', () => {
186198
});
187199
});
188200

201+
describe('Readiness', () => {
202+
test('should return 401 for unauthenticated user', async () => {
203+
const result = await agent.get('/api/admin/readiness').expect(401);
204+
expect(result.body).toBeDefined();
205+
});
206+
207+
test('should return 403 for regular user', async () => {
208+
const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${userToken}`).expect(403);
209+
expect(result.body.type).toBe('error');
210+
});
211+
212+
test('should return 200 with readiness checks for admin', async () => {
213+
const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200);
214+
expect(result.body.type).toBe('success');
215+
expect(result.body.message).toBe('readiness check');
216+
expect(result.body.data).toBeInstanceOf(Array);
217+
expect(result.body.data.length).toBeGreaterThan(0);
218+
});
219+
220+
test('should return correct shape for each readiness check', async () => {
221+
const result = await agent.get('/api/admin/readiness').set('Cookie', `TOKEN=${adminToken}`).expect(200);
222+
const expectedCategories = ['config', 'security', 'auth', 'mail', 'billing', 'analytics', 'monitoring'];
223+
const categories = result.body.data.map((c) => c.category);
224+
expect(categories).toEqual(expectedCategories);
225+
result.body.data.forEach((item) => {
226+
expect(item).toHaveProperty('category');
227+
expect(item).toHaveProperty('status');
228+
expect(item).toHaveProperty('message');
229+
expect(['ok', 'warning']).toContain(item.status);
230+
expect(typeof item.message).toBe('string');
231+
});
232+
});
233+
});
234+
189235
describe('Errors', () => {
190236
test('should return 422 when team service fails', async () => {
191237
jest.spyOn(HomeService, 'team').mockRejectedValueOnce(new Error('DB error'));

0 commit comments

Comments
 (0)