underpost 2.8.883 → 2.8.885

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -116
  2. package/bin/deploy.js +9 -10
  3. package/bin/file.js +4 -6
  4. package/cli.md +15 -11
  5. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  6. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  7. package/package.json +1 -1
  8. package/src/api/user/user.service.js +3 -10
  9. package/src/cli/cluster.js +21 -0
  10. package/src/cli/cron.js +8 -0
  11. package/src/cli/db.js +63 -1
  12. package/src/cli/deploy.js +156 -3
  13. package/src/cli/env.js +43 -0
  14. package/src/cli/fs.js +94 -0
  15. package/src/cli/image.js +8 -0
  16. package/src/cli/index.js +17 -4
  17. package/src/cli/monitor.js +0 -1
  18. package/src/cli/repository.js +95 -2
  19. package/src/client/components/core/Css.js +16 -0
  20. package/src/client/components/core/Docs.js +5 -13
  21. package/src/client/components/core/Modal.js +57 -39
  22. package/src/client/components/core/Router.js +6 -3
  23. package/src/client/components/core/Worker.js +205 -118
  24. package/src/client/components/default/MenuDefault.js +1 -0
  25. package/src/client.dev.js +6 -3
  26. package/src/db/DataBaseProvider.js +65 -12
  27. package/src/db/mariadb/MariaDB.js +39 -6
  28. package/src/db/mongo/MongooseDB.js +51 -133
  29. package/src/index.js +1 -1
  30. package/src/mailer/EmailRender.js +58 -9
  31. package/src/mailer/MailerProvider.js +98 -25
  32. package/src/runtime/express/Express.js +248 -0
  33. package/src/runtime/lampp/Lampp.js +27 -8
  34. package/src/server/auth.js +82 -43
  35. package/src/server/client-build-live.js +14 -5
  36. package/src/server/client-dev-server.js +21 -8
  37. package/src/server/conf.js +78 -25
  38. package/src/server/peer.js +2 -2
  39. package/src/server/runtime.js +49 -208
  40. package/src/server/start.js +39 -0
  41. package/src/ws/IoInterface.js +132 -39
  42. package/src/ws/IoServer.js +79 -31
  43. package/src/ws/core/core.ws.connection.js +50 -16
  44. package/src/ws/core/core.ws.emit.js +47 -8
  45. package/src/ws/core/core.ws.server.js +62 -10
  46. package/src/runtime/nginx/Nginx.js +0 -3
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A service dedicated to creating and configuring an Express.js application
3
+ * instance based on server configuration data.
4
+ * @module src/runtime/express/Express.js
5
+ * @namespace ExpressService
6
+ */
7
+
8
+ import fs from 'fs-extra';
9
+ import express from 'express';
10
+ import fileUpload from 'express-fileupload';
11
+ import swaggerUi from 'swagger-ui-express';
12
+ import compression from 'compression';
13
+ import { createServer } from 'http';
14
+
15
+ import UnderpostStartUp from '../../server/start.js';
16
+ import { loggerFactory, loggerMiddleware } from '../../server/logger.js';
17
+ import { getCapVariableName, newInstance } from '../../client/components/core/CommonJs.js';
18
+ import { MailerProvider } from '../../mailer/MailerProvider.js';
19
+ import { DataBaseProvider } from '../../db/DataBaseProvider.js';
20
+ import { createPeerServer } from '../../server/peer.js';
21
+ import { createValkeyConnection } from '../../server/valkey.js';
22
+ import { applySecurity, authMiddlewareFactory } from '../../server/auth.js';
23
+ import { ssrMiddlewareFactory } from '../../server/ssr.js';
24
+
25
+ const logger = loggerFactory(import.meta);
26
+
27
+ /**
28
+ * @class ExpressService
29
+ * @description A service dedicated to creating and configuring an Express.js application
30
+ * instance based on server configuration data.
31
+ * @memberof ExpressService
32
+ */
33
+ class ExpressService {
34
+ /**
35
+ * Creates and configures a complete Express application instance for a specific host/path configuration.
36
+ *
37
+ * @method createApp
38
+ * @memberof ExpressService
39
+ * @param {string} config.host - The host name for the instance (e.g., 'www.example.com').
40
+ * @param {string} config.path - The URL path for the instance (e.g., '/', '/api/v1').
41
+ * @param {number} config.port - The primary listening port for the instance.
42
+ * @param {string} config.client - The client associated with the instance (used for SSR/Mailer configuration lookup).
43
+ * @param {string[]} [config.apis] - A list of API names to load and attach routers for.
44
+ * @param {string[]} config.origins - Allowed origins for CORS.
45
+ * @param {string} [config.directory] - The directory for static files (if overriding default).
46
+ * @param {string} [config.ws] - The WebSocket server name to use.
47
+ * @param {object} [config.mailer] - Mailer configuration.
48
+ * @param {object} [config.db] - Database configuration.
49
+ * @param {string} [config.redirect] - URL or flag to indicate an HTTP redirect should be configured.
50
+ * @param {boolean} [config.peer] - Whether to enable the peer server.
51
+ * @param {object} [config.valkey] - Valkey connection configuration.
52
+ * @param {string} [config.apiBaseHost] - Base host for the API (if running separate API).
53
+ * @param {string} config.redirectTarget - The full target URL for redirection (used if `redirect` is true).
54
+ * @param {string} config.rootHostPath - The root path for public host assets (e.g., `/public/hostname`).
55
+ * @param {object} config.confSSR - The SSR configuration object, used to look up Mailer templates.
56
+ * @param {import('prom-client').Counter<string>} config.promRequestCounter - Prometheus request counter instance.
57
+ * @param {import('prom-client').Registry} config.promRegister - Prometheus register instance for metrics.
58
+ * @returns {Promise<{portsUsed: number}>} An object indicating how many additional ports were used (e.g., for PeerServer).
59
+ */
60
+ async createApp({
61
+ host,
62
+ path,
63
+ port,
64
+ client,
65
+ apis,
66
+ origins,
67
+ directory,
68
+ ws,
69
+ mailer,
70
+ db,
71
+ redirect,
72
+ peer,
73
+ valkey,
74
+ apiBaseHost,
75
+ redirectTarget,
76
+ rootHostPath,
77
+ confSSR,
78
+ promRequestCounter,
79
+ promRegister,
80
+ }) {
81
+ let portsUsed = 0;
82
+ const runningData = {
83
+ host,
84
+ path,
85
+ runtime: 'nodejs',
86
+ client,
87
+ meta: import.meta,
88
+ apis,
89
+ };
90
+
91
+ const app = express();
92
+
93
+ if (process.env.NODE_ENV === 'production') app.set('trust proxy', true);
94
+
95
+ app.use((req, res, next) => {
96
+ res.on('finish', () => {
97
+ promRequestCounter.inc({
98
+ instance: `${host}:${port}${path}`,
99
+ method: req.method,
100
+ status_code: res.statusCode,
101
+ });
102
+ });
103
+ return next();
104
+ });
105
+
106
+ // Metrics endpoint
107
+ app.get(`${path === '/' ? '' : path}/metrics`, async (req, res) => {
108
+ res.set('Content-Type', promRegister.contentType);
109
+ return res.end(await promRegister.metrics());
110
+ });
111
+
112
+ // Logging, Compression, and Body Parsers
113
+ app.use(loggerMiddleware(import.meta));
114
+ // Compression filter logic is correctly inlined here
115
+ app.use(compression({ filter: (req, res) => !req.headers['x-no-compression'] && compression.filter(req, res) }));
116
+ app.use(express.json({ limit: '100MB' }));
117
+ app.use(express.urlencoded({ extended: true, limit: '20MB' }));
118
+ app.use(fileUpload());
119
+
120
+ if (process.env.NODE_ENV === 'development') app.set('json spaces', 2);
121
+
122
+ // Language handling middleware
123
+ app.use((req, res, next) => {
124
+ const lang = req.headers['accept-language'] || 'en';
125
+ req.lang = typeof lang === 'string' && lang.toLowerCase().match('es') ? 'es' : 'en';
126
+ return next();
127
+ });
128
+
129
+ // Static file serving
130
+ app.use('/', express.static(directory ? directory : `.${rootHostPath}`));
131
+
132
+ // Handle redirection-only instances
133
+ if (redirect) {
134
+ app.use((req, res, next) => {
135
+ if (process.env.NODE_ENV === 'production' && !req.url.startsWith(`/.well-known/acme-challenge`)) {
136
+ return res.status(302).redirect(redirectTarget + req.url);
137
+ }
138
+ return next();
139
+ });
140
+ await UnderpostStartUp.API.listenPortController(app, port, runningData);
141
+ return { portsUsed };
142
+ }
143
+
144
+ // Create HTTP server for regular instances (required for WebSockets)
145
+ const server = createServer({}, app);
146
+
147
+ if (!apiBaseHost) {
148
+ // Swagger path definition
149
+ const swaggerJsonPath = `./public/${host}${path === '/' ? path : `${path}/`}swagger-output.json`;
150
+ const swaggerPath = `${path === '/' ? `/api-docs` : `${path}/api-docs`}`;
151
+
152
+ // Flag swagger requests before security middleware
153
+ if (fs.existsSync(swaggerJsonPath)) {
154
+ app.use(swaggerPath, (req, res, next) => {
155
+ res.locals.isSwagger = true;
156
+ next();
157
+ });
158
+ }
159
+
160
+ // Swagger UI setup
161
+ if (fs.existsSync(swaggerJsonPath)) {
162
+ const swaggerDoc = JSON.parse(fs.readFileSync(swaggerJsonPath, 'utf8'));
163
+ // Reusing swaggerPath defined outside, removing unnecessary redeclaration
164
+ app.use(swaggerPath, swaggerUi.serve, swaggerUi.setup(swaggerDoc));
165
+ }
166
+
167
+ // Security and CORS
168
+ applySecurity(app, {
169
+ origin: origins,
170
+ });
171
+
172
+ // Database and Valkey connections
173
+ if (db && apis) await DataBaseProvider.load({ apis, host, path, db });
174
+ if (valkey) await createValkeyConnection({ host, path }, valkey);
175
+
176
+ // Mailer setup
177
+ if (mailer) {
178
+ const mailerSsrConf = confSSR[getCapVariableName(client)];
179
+ await MailerProvider.load({
180
+ id: `${host}${path}`,
181
+ meta: `mailer-${host}${path}`,
182
+ host,
183
+ path,
184
+ ...mailer,
185
+ templates: mailerSsrConf ? mailerSsrConf.mailer : {},
186
+ });
187
+ }
188
+
189
+ // API router loading
190
+ if (apis && apis.length > 0) {
191
+ const authMiddleware = authMiddlewareFactory({ host, path });
192
+ const apiPath = `${path === '/' ? '' : path}/${process.env.BASE_API}`;
193
+ for (const api of apis) {
194
+ logger.info(`Build api server`, `${host}${apiPath}/${api}`);
195
+ const { ApiRouter } = await import(`../../api/${api}/${api}.router.js`);
196
+ const router = ApiRouter({ host, path, apiPath, mailer, db, authMiddleware, origins });
197
+ app.use(`${apiPath}/${api}`, router);
198
+ }
199
+ }
200
+
201
+ // WebSocket server setup
202
+ if (ws) {
203
+ const { createIoServer } = await import(`../../ws/${ws}/${ws}.ws.server.js`);
204
+ const { options, meta, ioServer } = await createIoServer(server, { host, path, db, port, origins });
205
+
206
+ // Listen on the main port for the WS server
207
+ await UnderpostStartUp.API.listenPortController(ioServer, port, {
208
+ runtime: 'nodejs',
209
+ client: null,
210
+ host,
211
+ path: options.path,
212
+ meta,
213
+ });
214
+ }
215
+
216
+ // Peer server setup
217
+ if (peer) {
218
+ portsUsed++; // Peer server uses one additional port
219
+ const peerPort = newInstance(port + portsUsed); // portsUsed is 1 here
220
+ const { options, meta, peerServer } = await createPeerServer({
221
+ port: peerPort,
222
+ devPort: port,
223
+ origins,
224
+ host,
225
+ path,
226
+ });
227
+ await UnderpostStartUp.API.listenPortController(peerServer, peerPort, {
228
+ runtime: 'nodejs',
229
+ client: null,
230
+ host,
231
+ path: options.path,
232
+ meta,
233
+ });
234
+ }
235
+ }
236
+
237
+ // SSR middleware loading
238
+ const ssr = await ssrMiddlewareFactory({ app, directory, rootHostPath, path });
239
+ for (const [_, ssrMiddleware] of Object.entries(ssr)) app.use(ssrMiddleware);
240
+
241
+ // Start listening on the main port
242
+ await UnderpostStartUp.API.listenPortController(server, port, runningData);
243
+
244
+ return { portsUsed };
245
+ }
246
+ }
247
+
248
+ export default new ExpressService();
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Exported singleton instance of the LamppService class.
3
+ * This object is used to interact with the Lampp configuration and service.
4
+ * @module src/runtime/lampp/Lampp.js
5
+ * @namespace LamppService
6
+ */
7
+
1
8
  import fs from 'fs-extra';
2
9
  import { getRootDirectory, shellCd, shellExec } from '../../server/process.js';
3
10
  import { loggerFactory } from '../../server/logger.js';
@@ -9,12 +16,14 @@ const logger = loggerFactory(import.meta);
9
16
  * @description Provides utilities for managing the XAMPP (Lampp) service on Linux,
10
17
  * including initialization, router configuration, and virtual host creation.
11
18
  * It manages the server's configuration files and controls the service process.
19
+ * @memberof LamppService
12
20
  */
13
21
  class LamppService {
14
22
  /**
15
23
  * @private
16
24
  * @type {string | undefined}
17
25
  * @description Stores the accumulated Apache virtual host configuration (router definition).
26
+ * @memberof LamppService
18
27
  */
19
28
  router;
20
29
 
@@ -22,12 +31,15 @@ class LamppService {
22
31
  * @public
23
32
  * @type {number[]}
24
33
  * @description A list of ports currently configured and listened to by the Lampp service.
34
+ * @memberof LamppService
25
35
  */
26
36
  ports;
27
37
 
28
38
  /**
29
39
  * Creates an instance of LamppService.
30
40
  * Initializes the router configuration and ports list.
41
+ * @method constructor
42
+ * @memberof LamppService
31
43
  */
32
44
  constructor() {
33
45
  this.router = undefined;
@@ -37,8 +49,11 @@ class LamppService {
37
49
  /**
38
50
  * Checks if the XAMPP (Lampp) service appears to be installed based on the presence of its main configuration file.
39
51
  *
52
+ * @method enabled
40
53
  * @memberof LamppService
41
54
  * @returns {boolean} True if the configuration file exists, indicating Lampp is likely installed.
55
+ * @throws {Error} If the configuration file does not exist.
56
+ * @memberof LamppService
42
57
  */
43
58
  enabled() {
44
59
  return fs.existsSync('/opt/lampp/etc/httpd.conf');
@@ -49,10 +64,11 @@ class LamppService {
49
64
  * This method configures virtual hosts, disables default ports (80/443) in the main config
50
65
  * to avoid conflicts, and starts or stops the service using shell commands.
51
66
  *
52
- * @memberof LamppService.prototype
67
+ * @method initService
53
68
  * @param {object} [options={daemon: false}] - Options for service initialization.
54
69
  * @param {boolean} [options.daemon=false] - Flag to indicate if the service should be run as a daemon (currently unused in logic).
55
70
  * @returns {Promise<void>}
71
+ * @memberof LamppService
56
72
  */
57
73
  async initService(options = { daemon: false }) {
58
74
  let cmd;
@@ -117,9 +133,10 @@ class LamppService {
117
133
  * Appends new Apache VirtualHost configuration content to the internal router string.
118
134
  * If a router config file exists from a previous run, it loads it first.
119
135
  *
120
- * @memberof LamppService.prototype
136
+ * @method appendRouter
121
137
  * @param {string} render - The new VirtualHost configuration string to append.
122
138
  * @returns {string} The complete, updated router configuration string.
139
+ * @memberof LamppService
123
140
  */
124
141
  appendRouter(render) {
125
142
  if (!this.router) {
@@ -135,7 +152,7 @@ class LamppService {
135
152
  /**
136
153
  * Resets the internal router configuration and removes the temporary configuration file.
137
154
  *
138
- * @memberof LamppService.prototype
155
+ * @memberof LamppService
139
156
  * @returns {void}
140
157
  */
141
158
  removeRouter() {
@@ -148,8 +165,9 @@ class LamppService {
148
165
  * This includes downloading the installer, running it, and setting up initial configurations.
149
166
  * Only runs on the 'linux' platform.
150
167
  *
151
- * @memberof LamppService.prototype
168
+ * @method install
152
169
  * @returns {Promise<void>}
170
+ * @memberof LamppService
153
171
  */
154
172
  async install() {
155
173
  if (process.platform === 'linux') {
@@ -199,7 +217,7 @@ class LamppService {
199
217
  * Creates and appends a new Apache VirtualHost entry to the router configuration for a web application.
200
218
  * The router is then applied by calling {@link LamppService#initService}.
201
219
  *
202
- * @memberof LamppService.prototype
220
+ * @method createApp
203
221
  * @param {object} options - Configuration options for the new web application.
204
222
  * @param {number} options.port - The port the VirtualHost should listen on.
205
223
  * @param {string} options.host - The ServerName/host for the VirtualHost.
@@ -210,6 +228,7 @@ class LamppService {
210
228
  * @param {string} [options.redirectTarget] - The target URL for redirection.
211
229
  * @param {boolean} [options.resetRouter] - If true, clears the existing router configuration before appending the new one.
212
230
  * @returns {{disabled: boolean}} An object indicating if the service is disabled.
231
+ * @memberof LamppService
213
232
  */
214
233
  createApp({ port, host, path, directory, rootHostPath, redirect, redirectTarget, resetRouter }) {
215
234
  if (!this.enabled()) return { disabled: true };
@@ -262,13 +281,15 @@ Listen ${port}
262
281
  }
263
282
 
264
283
  /**
265
- * @namespace LamppService
266
284
  * @description Exported singleton instance of the LamppService class.
267
285
  * This object is used to interact with the Lampp configuration and service.
286
+ * @memberof LamppService
268
287
  * @type {LamppService}
269
288
  */
270
289
  const Lampp = new LamppService();
271
290
 
291
+ export { Lampp };
292
+
272
293
  // -- helper info --
273
294
 
274
295
  // ERR too many redirects:
@@ -320,5 +341,3 @@ const Lampp = new LamppService();
320
341
  // RewriteCond %{HTTP_HOST} ^www\. [NC]
321
342
  // RewriteCond %{HTTP_HOST} ^(?:www\.)?(.+)$ [NC]
322
343
  // RewriteRule ^ https://%1%{REQUEST_URI} [L,NE,R=301]
323
-
324
- export { Lampp };
@@ -110,16 +110,22 @@ function jwtIssuerAudienceFactory(options = { host: '', path: '' }) {
110
110
  * @param {object} [options={}] Additional JWT sign options.
111
111
  * @param {string} options.host The host name.
112
112
  * @param {string} options.path The path name.
113
- * @param {number} expireMinutes The token expiration in minutes.
113
+ * @param {number} accessExpireMinutes The access token expiration in minutes.
114
+ * @param {number} refreshExpireMinutes The refresh token expiration in minutes.
114
115
  * @returns {string} The signed JWT.
115
116
  * @throws {Error} If JWT key is not configured.
116
117
  * @memberof Auth
117
118
  */
118
- function jwtSign(payload, options = { host: '', path: '' }, expireMinutes = process.env.ACCESS_EXPIRE_MINUTES) {
119
+ function jwtSign(
120
+ payload,
121
+ options = { host: '', path: '' },
122
+ accessExpireMinutes = process.env.ACCESS_EXPIRE_MINUTES,
123
+ refreshExpireMinutes = process.env.REFRESH_EXPIRE_MINUTES,
124
+ ) {
119
125
  const { issuer, audience } = jwtIssuerAudienceFactory(options);
120
126
  const signOptions = {
121
127
  algorithm: config.jwtAlgorithm,
122
- expiresIn: `${expireMinutes}m`,
128
+ expiresIn: `${accessExpireMinutes}m`,
123
129
  issuer,
124
130
  audience,
125
131
  };
@@ -128,7 +134,11 @@ function jwtSign(payload, options = { host: '', path: '' }, expireMinutes = proc
128
134
 
129
135
  if (!process.env.JWT_SECRET) throw new Error('JWT key not configured');
130
136
 
131
- logger.info('JWT signed', { payload, signOptions, expireMinutes });
137
+ // Add refreshExpiresAt to the payload, which is the same as the token's own expiry.
138
+ const now = Date.now();
139
+ payload.refreshExpiresAt = now + refreshExpireMinutes * 60 * 1000;
140
+
141
+ logger.info('JWT signed', { payload, signOptions, accessExpireMinutes, refreshExpireMinutes });
132
142
 
133
143
  return jwt.sign(payload, process.env.JWT_SECRET, signOptions);
134
144
  }
@@ -170,6 +180,14 @@ const getBearerToken = (req) => {
170
180
  return '';
171
181
  };
172
182
 
183
+ /**
184
+ * Checks if the request is a refresh token request.
185
+ * @param {import('express').Request} req The Express request object.
186
+ * @returns {boolean} True if the request is a refresh token request, false otherwise.
187
+ * @memberof Auth
188
+ */
189
+ const isRefreshTokenReq = (req) => req.method === 'GET' && req.params.id === 'auth';
190
+
173
191
  // ---------- Middleware ----------
174
192
  /**
175
193
  * Creates a middleware to authenticate requests using a JWT Bearer token.
@@ -237,10 +255,7 @@ const authMiddlewareFactory = (options = { host: '', path: '' }) => {
237
255
  return res.status(401).json({ status: 'error', message: 'unauthorized: host or path mismatch' });
238
256
  }
239
257
 
240
- // check session expiresAt
241
- const isRefreshTokenReq = req.method === 'GET' && req.params.id === 'auth';
242
-
243
- if (!isRefreshTokenReq && session.expiresAt < new Date()) {
258
+ if (!isRefreshTokenReq(req) && session.expiresAt < new Date()) {
244
259
  return res.status(401).json({ status: 'error', message: 'unauthorized: session expired' });
245
260
  }
246
261
  }
@@ -310,53 +325,32 @@ const validatePasswordMiddleware = (req) => {
310
325
  /**
311
326
  * Creates cookie options for the refresh token.
312
327
  * @param {import('express').Request} req The Express request object.
328
+ * @param {string} host The host name.
313
329
  * @returns {object} Cookie options.
314
330
  * @memberof Auth
315
331
  */
316
- const cookieOptionsFactory = (req) => {
332
+ const cookieOptionsFactory = (req, host) => {
317
333
  const isProduction = process.env.NODE_ENV === 'production';
318
334
 
319
- // Determine hostname safely:
320
- // Prefer origin header if present (it contains protocol + host)
321
- let candidateHost = undefined;
322
- try {
323
- if (req.headers && req.headers.origin) {
324
- candidateHost = new URL(req.headers.origin).hostname;
325
- }
326
- } catch (e) {
327
- /* ignore parse error */
328
- logger.error(e);
329
- }
330
-
331
- // fallback to req.hostname (Express sets this; ensure trust proxy if behind proxy)
332
- if (!candidateHost) candidateHost = (req.hostname || '').split(':')[0];
333
-
334
- candidateHost = (candidateHost || '').trim().replace(/^www\./i, '');
335
-
336
- // Do not set domain for localhost, 127.x.x.x, or plain IPs
337
- const isIpOrLocal = /^(localhost|127(?:\.\d+){0,2}\.\d+|\[::1\]|\d+\.\d+\.\d+\.\d+)$/i.test(candidateHost);
338
- const domain = isProduction && candidateHost && !isIpOrLocal ? `.${candidateHost}` : undefined;
339
-
340
335
  // Determine if request is secure: respect X-Forwarded-Proto when behind proxy
341
336
  const forwardedProto = (req.headers && req.headers['x-forwarded-proto']) || '';
342
337
  const reqIsSecure = Boolean(req.secure || forwardedProto.split(',')[0] === 'https');
343
338
 
344
339
  // secure must be true for SameSite=None to work across sites
345
- const secure = isProduction ? reqIsSecure : false;
340
+ const secure = isProduction ? reqIsSecure : req.protocol === 'https';
346
341
  const sameSite = secure ? 'None' : 'Lax';
347
342
 
348
343
  // Safe parse of maxAge minutes
349
- const minutes = Number.parseInt(process.env.REFRESH_EXPIRE_MINUTES, 10);
350
- const maxAge = Number.isFinite(minutes) && minutes > 0 ? minutes * 60 * 1000 : undefined;
344
+ const maxAge = parseInt(process.env.ACCESS_EXPIRE_MINUTES) * 60 * 1000;
351
345
 
352
346
  const opts = {
353
347
  httpOnly: true,
354
348
  secure,
355
349
  sameSite,
356
350
  path: '/',
351
+ domain: process.env.NODE_ENV === 'production' ? host : 'localhost',
352
+ maxAge,
357
353
  };
358
- if (typeof maxAge !== 'undefined') opts.maxAge = maxAge;
359
- if (domain) opts.domain = domain;
360
354
 
361
355
  return opts;
362
356
  };
@@ -394,11 +388,53 @@ async function createSessionAndUserToken(user, User, req, res, options = { host:
394
388
  const jwtid = session._id.toString();
395
389
 
396
390
  // Secure cookie settings
397
- res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
391
+ res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host));
398
392
 
399
393
  return { jwtid };
400
394
  }
401
395
 
396
+ /**
397
+ * Removes a session by its ID for a given user.
398
+ * @param {import('mongoose').Model} User The Mongoose User model.
399
+ * @param {string} userId The ID of the user.
400
+ * @param {string} sessionId The ID of the session to remove.
401
+ * @returns {Promise<void>}
402
+ * @memberof Auth
403
+ */
404
+ async function removeSession(User, userId, sessionId) {
405
+ return await User.updateOne({ _id: userId }, { $pull: { activeSessions: { _id: sessionId } } });
406
+ }
407
+
408
+ /**
409
+ * Logs out a user session by removing it from the database and clearing the refresh token cookie.
410
+ * @param {import('mongoose').Model} User The Mongoose User model.
411
+ * @param {import('express').Request} req The Express request object.
412
+ * @param {import('express').Response} res The Express response object.
413
+ * @returns {Promise<boolean>} True if a session was found and removed, false otherwise.
414
+ * @memberof Auth
415
+ */
416
+ async function logoutSession(User, req, res) {
417
+ const refreshToken = req.cookies?.refreshToken;
418
+
419
+ if (!refreshToken) {
420
+ return false;
421
+ }
422
+
423
+ const user = await User.findOne({ 'activeSessions.tokenHash': refreshToken });
424
+
425
+ if (!user) {
426
+ return false;
427
+ }
428
+
429
+ const session = user.activeSessions.find((s) => s.tokenHash === refreshToken);
430
+
431
+ const result = await removeSession(User, user._id, session._id);
432
+
433
+ res.clearCookie('refreshToken', { path: '/' });
434
+
435
+ return result.modifiedCount > 0;
436
+ }
437
+
402
438
  /**
403
439
  * Create user and immediate session + access token
404
440
  * @param {import('express').Request} req The Express request object.
@@ -455,6 +491,7 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
455
491
 
456
492
  if (!user) {
457
493
  // Possible token reuse: look up user by some other signals? If not possible, log and throw.
494
+ // TODO: on cors requests, this will throw an error, because the cookie is not sent.
458
495
  logger.warn('Refresh token reuse or invalid token detected');
459
496
  // Optional: revoke by clearing cookie and returning unauthorized
460
497
  res.clearCookie('refreshToken', { path: '/' });
@@ -470,12 +507,10 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
470
507
  }
471
508
 
472
509
  // Check expiry
473
- if (session.expiresAt && session.expiresAt < new Date()) {
474
- // remove expired session
475
- user.activeSessions.id(session._id).remove();
476
- await user.save({ validateBeforeSave: false });
477
- res.clearCookie('refreshToken', { path: '/' });
478
- throw new Error('Refresh token expired');
510
+ if (!isRefreshTokenReq(req) && session.expiresAt && session.expiresAt < new Date()) {
511
+ const result = await removeSession(User, user._id, session._id);
512
+ if (result) throw new Error('Refresh token expired');
513
+ else throw new Error('Session not found');
479
514
  }
480
515
 
481
516
  // Rotate: generate new token, update stored hash and metadata
@@ -488,7 +523,7 @@ async function refreshSessionAndToken(req, res, User, options = { host: '', path
488
523
 
489
524
  logger.warn('Refreshed session for user ' + user.email);
490
525
 
491
- res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req));
526
+ res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host));
492
527
 
493
528
  return jwtSign(
494
529
  UserDto.auth.payload(user, session._id.toString(), req.ip, req.headers['user-agent'], options.host, options.path),
@@ -608,6 +643,7 @@ function applySecurity(app, opts = {}) {
608
643
  maxAge: 600,
609
644
  }),
610
645
  );
646
+ logger.info('Cors origin', origin);
611
647
 
612
648
  // Rate limiting + slow down
613
649
  const limiter = rateLimit({
@@ -646,5 +682,8 @@ export {
646
682
  createSessionAndUserToken,
647
683
  createUserAndSession,
648
684
  refreshSessionAndToken,
685
+ logoutSession,
686
+ removeSession,
649
687
  applySecurity,
688
+ isRefreshTokenReq,
650
689
  };
@@ -8,18 +8,20 @@ const logger = loggerFactory(import.meta);
8
8
  const clientLiveBuild = async () => {
9
9
  if (fs.existsSync(`./tmp/client.build.json`)) {
10
10
  const deployId = process.argv[2];
11
-
11
+ const subConf = process.argv[3];
12
12
  let clientId = 'default';
13
13
  let host = 'default.net';
14
14
  let path = '/';
15
15
  let baseHost = `${host}${path === '/' ? '' : path}`;
16
16
  let views = Config.default.client[clientId].views;
17
+ let apiBaseHost;
18
+ let apiBaseProxyPath;
17
19
 
18
20
  if (
19
21
  deployId &&
20
22
  (fs.existsSync(`./engine-private/conf/${deployId}`) || fs.existsSync(`./engine-private/replica/${deployId}`))
21
23
  ) {
22
- loadConf(deployId);
24
+ loadConf(deployId, subConf);
23
25
  const confClient = JSON.parse(
24
26
  fs.readFileSync(
25
27
  fs.existsSync(`./engine-private/replica/${deployId}`)
@@ -32,7 +34,9 @@ const clientLiveBuild = async () => {
32
34
  );
33
35
  const confServer = JSON.parse(
34
36
  fs.readFileSync(
35
- fs.existsSync(`./engine-private/replica/${deployId}`)
37
+ fs.existsSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`)
38
+ ? `./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`
39
+ : fs.existsSync(`./engine-private/replica/${deployId}`)
36
40
  ? `./engine-private/replica/${deployId}/conf.server.json`
37
41
  : fs.existsSync(`./engine-private/conf/${deployId}/conf.server.json`)
38
42
  ? `./engine-private/conf/${deployId}/conf.server.json`
@@ -40,20 +44,25 @@ const clientLiveBuild = async () => {
40
44
  'utf8',
41
45
  ),
42
46
  );
43
- host = process.argv[3];
44
- path = process.argv[4];
47
+ host = process.argv[4];
48
+ path = process.argv[5];
45
49
  clientId = confServer[host][path].client;
46
50
  views = confClient[clientId].views;
47
51
  baseHost = `${host}${path === '/' ? '' : path}`;
52
+ apiBaseHost = confServer[host][path].apiBaseHost;
53
+ apiBaseProxyPath = confServer[host][path].apiBaseProxyPath;
48
54
  }
49
55
 
50
56
  logger.info('Live build config', {
51
57
  deployId,
58
+ subConf,
52
59
  host,
53
60
  path,
54
61
  clientId,
55
62
  baseHost,
56
63
  views: views.length,
64
+ apiBaseHost,
65
+ apiBaseProxyPath,
57
66
  });
58
67
 
59
68
  const updates = JSON.parse(fs.readFileSync(`./tmp/client.build.json`, 'utf8'));