-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
479 lines (432 loc) · 17.9 KB
/
Copy pathserver.js
File metadata and controls
479 lines (432 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
#!/usr/bin/env node
/**
* Pica — Time Management
* Entry point. Boots the HTTP server, wires the router, serves static assets.
*
* Milestone 2b: authentication wired up. The app boots into setup mode
* until the first employer account is created, then into the login flow.
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './src/config.js';
import { createLogger } from './src/logger.js';
import { createRouter } from './src/router.js';
import { parseBody, BodyTooLargeError, BadBodyError } from './src/http/body.js';
import { parseCookies } from './src/http/cookies.js';
import { enhance } from './src/http/responses.js';
import { serveStatic } from './src/http/static.js';
import { createSecurityHeaders } from './src/http/security-headers.js';
import { initMasterKey } from './src/crypto/masterkey.js';
import { deriveSessionKey } from './src/auth/sessions.js';
import { createUsersStore } from './src/auth/users.js';
import { createRBAC } from './src/auth/rbac.js';
import { createRateLimiter } from './src/auth/rate-limit.js';
import { registerAuthRoutes } from './src/routes/auth.js';
import { registerSetupRoutes } from './src/routes/setup.js';
import { registerPageRoutes } from './src/routes/pages.js';
import { registerEmployeeRoutes } from './src/routes/employees.js';
import { registerPunchRoutes } from './src/routes/punches.js';
import { registerLeaveRoutes } from './src/routes/leaves.js';
import { registerCorrectionRoutes } from './src/routes/corrections.js';
import { registerReportRoutes } from './src/routes/reports.js';
import { approxDaysOff } from './src/storage/reports.js';
import { registerSettingsRoutes } from './src/routes/settings.js';
import { registerBackupRoutes } from './src/routes/backups.js';
import { registerSecurityRoutes } from './src/routes/security.js';
import { registerMailRoutes } from './src/routes/mail.js';
import { makeMailer } from './src/mail/mailer.js';
import { startBackupScheduler } from './src/scheduler/backup-scheduler.js';
import { makeReminderScheduler } from './src/scheduler/reminder-scheduler.js';
import { createEmployeesStore } from './src/storage/employees.js';
import { createPunchesStore } from './src/storage/punches.js';
import { createLeavesStore, LEAVE_TYPES_LIST } from './src/storage/leaves.js';
import { createCorrectionsStore } from './src/storage/corrections.js';
import { createUserPrefsStore } from './src/storage/user-prefs.js';
import { createOrgSettingsStore } from './src/storage/org-settings.js';
import { createCompanyLogoStore } from './src/storage/company-logo.js';
import { createBackupsStore } from './src/storage/backups.js';
import { createAuditStore } from './src/storage/audit.js';
import { createMailConfigStore } from './src/storage/mail-config.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Read our own package metadata once at startup. Used by /api/version
// and the footer that every page renders.
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
// ----------------------------------------------------------------------------
// Startup
// ----------------------------------------------------------------------------
const config = loadConfig(path.join(__dirname, 'config.json'));
const log = createLogger(config.logLevel);
for (const dir of [config.dataDir, config.backupDir]) {
fs.mkdirSync(dir, { recursive: true });
}
const publicDir = path.join(__dirname, 'public');
const configPath = path.join(__dirname, 'config.json');
// ----------------------------------------------------------------------------
// Master key — derived before we start accepting requests. Anything that
// touches encrypted storage gets `masterKey` passed in explicitly.
// ----------------------------------------------------------------------------
let masterKey;
let mustResetPassphrase = false;
try {
({ masterKey, mustResetPassphrase } = await initMasterKey(config, configPath, log));
} catch (err) {
log.error(err.message);
process.exit(1);
}
// Session signing key is deterministic from the master key — no extra state.
const sessionKey = deriveSessionKey(masterKey);
// ----------------------------------------------------------------------------
// Stores, middleware, routes
// ----------------------------------------------------------------------------
const usersStore = createUsersStore(config.dataDir);
const employeesStore = createEmployeesStore(config.dataDir, masterKey);
const punchesStore = createPunchesStore(config.dataDir, masterKey);
const leavesStore = createLeavesStore(config.dataDir, masterKey);
const correctionsStore = createCorrectionsStore(config.dataDir, masterKey);
const userPrefsStore = createUserPrefsStore(config.dataDir);
const orgSettingsStore = createOrgSettingsStore(config.dataDir);
const companyLogoStore = createCompanyLogoStore(config.dataDir, masterKey);
const backupsStore = createBackupsStore({
dataDir: config.dataDir,
backupsDir: config.backupDir,
configPath,
masterKey,
});
// Audit log: append-only, encrypted NDJSON. Records sensitive
// actions (logins, password ops, employee CRUD, leave/correction
// decisions, backups, restores). Wired into routes that need it.
const auditStore = createAuditStore({
dataDir: config.dataDir,
masterKey,
logger: log,
});
// SMTP config: AES-256-GCM blob in config.json, decrypted with the DEK.
// Lives in config.json (not data/) so it stays out of backups; reads/writes
// atomically via writeConfigAtomic. Never throws on absent/garbage blob.
const mailConfigStore = createMailConfigStore(configPath, masterKey, log);
// Warn once at startup when mail is enabled but the SMTP config is
// incomplete (e.g. operator flipped enabled:true via Settings but left
// host/user/pass/from blank). Mail then stays disabled — non-fatal.
if (mailConfigStore.read().enabled && !mailConfigStore.isConfigured()) {
log.warn('mail enabled but SMTP config is incomplete; mail disabled');
}
// Email mailer — M14. Constructed after all stores it depends on exist.
// notify() never throws, never rejects. Callers use `void mailer.notify(...)`.
const mailer = makeMailer({
mailConfigStore,
logger: log,
audit: auditStore,
usersStore,
employeesStore,
userPrefsStore,
orgSettingsStore,
});
// Process-wide lockdown flags.
// restoreCompleted: flips true after a restore; cleared ONLY by a process
// restart (the in-memory stores are stale until then).
// rotateCompleted: flips true after a key rotation; like restoreCompleted
// it requires a process restart (in-memory stores hold the old key).
// passphraseResetRequired: true when the server unlocked via the recovery
// code; the /api/security/passphrase handler clears it in-process once a
// new passphrase is set, so normal operation resumes without a restart.
const serverState = { restoreCompleted: false, rotateCompleted: false, passphraseResetRequired: mustResetPassphrase };
const loginLimiter = createRateLimiter({ max: 10, windowSeconds: 60 });
// Password operations (self-service change + employer-initiated reset).
// 5 per hour per key — tight enough to slow brute force on the current
// password verification, loose enough not to annoy a legitimate user.
const passwordLimiter = createRateLimiter({ max: 5, windowSeconds: 3600 });
// Security operations (passphrase change, recovery-code add/remove, key rotate).
// Heavy crypto per call (scrypt N=2^17; /rotate re-encrypts the whole tree), so
// cap them even for the trusted employer — 10/hour/actor (M17 S15).
const securityLimiter = createRateLimiter({ max: 10, windowSeconds: 3600 });
// M17 S5: periodically prune the rate-limiter maps so they can't grow unbounded
// under source-IP rotation (each limiter only prunes a key when it's next hit).
// Unref'd so it never holds the process open; 5-minute cadence is ample here.
const limiterSweep = setInterval(() => {
loginLimiter.sweep();
passwordLimiter.sweep();
securityLimiter.sweep();
}, 5 * 60 * 1000);
limiterSweep.unref();
const rbac = createRBAC({ sessionKey, usersStore });
const isProduction = process.env.NODE_ENV === 'production';
// Pre-compute the CSP (and the static security headers it joins). The
// CSP includes a SHA-256 of the canonical inline theme bootstrap; doing
// this at startup means we never have to manually bump the hash when
// editing the bootstrap.
const applySecurityHeaders = createSecurityHeaders({ publicDir, isProduction });
const router = createRouter();
// Liveness probe — unauthenticated, safe to expose.
router.get('/api/health', (req, res) => {
res.json({ ok: true, name: 'pica', version: pkg.version });
});
// Public version metadata — used by the footer on every page.
router.get('/api/version', (req, res) => {
res.json({
version: pkg.version,
releaseDate: pkg.releaseDate ?? null,
repository: pkg.repository ?? null,
});
});
registerSetupRoutes(router, { usersStore, sessionKey, isProduction, auditStore });
registerAuthRoutes(router, {
usersStore,
employeesStore,
sessionKey,
loginLimiter,
passwordLimiter,
requireAuth: rbac.requireAuth,
isProduction,
auditStore,
});
registerEmployeeRoutes(router, {
usersStore,
employeesStore,
punchesStore,
leavesStore,
correctionsStore,
orgSettingsStore,
passwordLimiter,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
requireOwnerOrEmployer: rbac.requireOwnerOrEmployer,
auditStore,
mailer,
});
registerPunchRoutes(router, {
punchesStore,
usersStore,
auditStore,
requireAuth: rbac.requireAuth,
requireOwnerOrEmployer: rbac.requireOwnerOrEmployer,
});
registerLeaveRoutes(router, {
leavesStore,
usersStore,
employeesStore,
orgSettingsStore,
leaveTypes: LEAVE_TYPES_LIST,
daysOf: approxDaysOff,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
auditStore,
mailer,
});
registerCorrectionRoutes(router, {
correctionsStore,
punchesStore,
usersStore,
employeesStore,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
auditStore,
mailer,
});
registerReportRoutes(router, {
punchesStore,
leavesStore,
usersStore,
employeesStore,
orgSettingsStore,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
requireOwnerOrEmployer: rbac.requireOwnerOrEmployer,
});
registerSettingsRoutes(router, {
userPrefsStore,
orgSettingsStore,
companyLogoStore,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
auditStore,
mailConfigStore,
});
registerBackupRoutes(router, {
backupsStore,
serverState,
requireRole: rbac.requireRole,
auditStore,
logger: log,
});
registerSecurityRoutes(router, {
configPath,
masterKey,
dataDir: config.dataDir,
serverState,
requireAuth: rbac.requireAuth,
requireRole: rbac.requireRole,
auditStore,
logger: log,
securityLimiter,
});
// Mail routes — employer-only config probe (POST /api/mail/test).
// Registered after all other /api/* routes; before page routes (first-match-wins).
registerMailRoutes(router, {
mailer,
requireRole: rbac.requireRole,
});
registerPageRoutes(router, {
publicDir,
usersStore,
userPrefsStore,
authenticate: rbac.authenticate,
});
// Start the backup scheduler. Reads org-settings.backups every 5
// minutes; if a backup is due, makes one and prunes to retention.
// The handle returned has a stop() method we don't currently use
// (the process doesn't have a clean-shutdown hook beyond SIGINT,
// which terminates the timer naturally).
startBackupScheduler({
backupsStore,
orgSettingsStore,
serverState,
logger: log,
});
// Start the reminder scheduler — M14. Every 5 minutes, finds approved
// leaves within 24 h of their start and fires a leaveReminder notification.
// One reminder per leave ever (persisted via leavesStore.markReminderSent).
// Like the backup scheduler, the stop() handle is unused — SIGINT is enough.
makeReminderScheduler({ leavesStore, mailer, logger: log }).start();
// ----------------------------------------------------------------------------
// Request handler
// ----------------------------------------------------------------------------
/**
* True for any path that's an API endpoint (not a page load and not
* a static asset). Used by the post-restore lockdown to differentiate
* "user trying to look at the settings page" from "anything that
* depends on store state".
*/
function isApiEndpoint(pathname) {
return pathname.startsWith('/api/');
}
async function handle(nodeReq, nodeRes) {
const start = Date.now();
enhance(nodeRes);
// Apply security headers to EVERY response. Done here rather than
// per-route so we can't forget. Headers must be set before the body
// is sent — which is fine: routes haven't run yet at this point.
applySecurityHeaders(nodeReq, nodeRes);
// Parse URL + query + cookies up front.
const parsedUrl = new URL(nodeReq.url, `http://${nodeReq.headers.host || 'localhost'}`);
nodeReq.path = parsedUrl.pathname;
nodeReq.query = Object.fromEntries(parsedUrl.searchParams);
nodeReq.cookies = parseCookies(nodeReq.headers.cookie);
// Post-restore lockdown. Once a restore completes, the in-memory
// stores are stale and we don't want to serve anything that depends
// on them. The allowlist below lets the user see the settings page
// (with its "restart Pica" banner) and call /api/backups/status to
// detect the state, but every other API call gets 503.
if (serverState.restoreCompleted && isApiEndpoint(nodeReq.path)) {
const allowed = nodeReq.path === '/api/backups/status'
|| nodeReq.path === '/api/logout';
if (!allowed) {
return nodeRes.serviceUnavailable(
'Restore is complete — please restart Pica to use the restored data.',
{ errorCode: 'restore_pending_restart' },
);
}
}
if (serverState.rotateCompleted && isApiEndpoint(nodeReq.path)) {
const allowed = nodeReq.path === '/api/logout';
if (!allowed) {
return nodeRes.serviceUnavailable(
'Key rotation complete — restart Pica, then set a new recovery code.',
{ errorCode: 'rotate_pending_restart' },
);
}
}
// Recovery-code unlock lockdown: the master key is live but the passphrase
// slot is no longer trusted. Force the operator to set a new passphrase
// before anything else runs. Allowlist includes /api/login so the operator
// can authenticate first (they have no session on a fresh boot), /api/me
// so the frontend can identify the session, /api/logout, and the passphrase-
// set endpoint itself. Cleared in-process by /api/security/passphrase on
// success — no restart needed.
if (serverState.passphraseResetRequired && isApiEndpoint(nodeReq.path)) {
const allowed = nodeReq.path === '/api/login'
|| nodeReq.path === '/api/security/passphrase'
|| nodeReq.path === '/api/logout'
|| nodeReq.path === '/api/me';
if (!allowed) {
return nodeRes.serviceUnavailable(
'Unlocked via recovery code — set a new passphrase to continue.',
{ errorCode: 'passphrase_reset_required' },
);
}
}
try {
// Parse body for methods that typically have one. The restore
// endpoint accepts uploads up to backupMaxBytes (200 MB by
// default); everything else stays under maxBodyBytes (5 MB).
// DELETE is included because some endpoints (e.g. recovery-code removal)
// require a credential in the body. Existing DELETE routes ignore req.body,
// so parsing one is harmless; the per-path size cap still applies.
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(nodeReq.method)) {
const isLeaveUpload = nodeReq.path === '/api/leaves'
|| nodeReq.path.endsWith('/attachment');
const cap = nodeReq.path === '/api/backups/restore'
? config.backupMaxBytes
: isLeaveUpload
? config.attachmentMaxBytes
: config.maxBodyBytes;
nodeReq.body = await parseBody(nodeReq, { maxBytes: cap });
} else {
nodeReq.body = {};
}
const match = router.match(nodeReq.method, nodeReq.path);
if (match && match.handler) {
nodeReq.params = match.params;
await match.handler(nodeReq, nodeRes);
return;
}
if (match && match.methodNotAllowed) {
nodeRes.writeHead(405, { 'Content-Type': 'application/json' });
nodeRes.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
// Fall back to static files on GET only.
if (nodeReq.method === 'GET') {
const served = await serveStatic(nodeReq.path, nodeRes, publicDir, nodeReq);
if (served) return;
}
nodeRes.notFound();
} catch (err) {
if (err instanceof BodyTooLargeError) {
nodeRes.writeHead(413, { 'Content-Type': 'application/json' });
nodeRes.end(JSON.stringify({ error: err.message }));
return;
}
if (err instanceof BadBodyError) {
nodeRes.badRequest(err.message);
return;
}
log.error(`Unhandled error on ${nodeReq.method} ${nodeReq.url}:`, err);
if (!nodeRes.headersSent) nodeRes.serverError();
} finally {
const ms = Date.now() - start;
const status = nodeRes.statusCode;
// 4xx/5xx go through warn so they stand out.
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
log[level](`${nodeReq.method} ${nodeReq.url} ${status} ${ms}ms`);
}
}
// ----------------------------------------------------------------------------
// Server lifecycle
// ----------------------------------------------------------------------------
const server = http.createServer(handle);
server.listen(config.port, config.host, () => {
log.info(`Pica listening on http://${config.host}:${config.port}`);
log.info(`Data dir: ${config.dataDir}`);
log.info(`Backup dir: ${config.backupDir}`);
});
// Graceful shutdown on Ctrl-C / kill.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
log.info(`Received ${signal}, shutting down…`);
server.close(() => process.exit(0));
// Force-exit if shutdown stalls (e.g., long-running request).
setTimeout(() => process.exit(1), 5000).unref();
});
}