zyket 1.2.17 → 1.2.19

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 (69) hide show
  1. package/SECURITY.md +40 -0
  2. package/bin/cli.js +282 -202
  3. package/index.js +4 -3
  4. package/package.json +5 -1
  5. package/src/extensions/bullboard/index.js +14 -3
  6. package/src/extensions/interactive-storage/index.js +25 -9
  7. package/src/extensions/interactive-storage/routes/delete-folder.js +13 -1
  8. package/src/extensions/interactive-storage/routes/delete.js +14 -3
  9. package/src/extensions/interactive-storage/routes/download.js +22 -3
  10. package/src/extensions/interactive-storage/routes/info.js +13 -0
  11. package/src/services/auth/index.js +66 -42
  12. package/src/services/express/Express.js +32 -15
  13. package/src/services/express/RequireAdminMiddleware.js +14 -0
  14. package/src/services/express/RequireAuthMiddleware.js +46 -0
  15. package/src/services/express/index.js +7 -5
  16. package/src/services/socketio/AuthGuard.js +33 -0
  17. package/src/services/socketio/SocketIO.js +3 -1
  18. package/src/services/socketio/index.js +2 -1
  19. package/src/services/template-manager/index.js +1 -0
  20. package/src/templates/api-rest/.env.example +24 -0
  21. package/src/templates/api-rest/README.md +50 -0
  22. package/src/templates/api-rest/index.js +18 -0
  23. package/src/templates/api-rest/src/models/Task.js +18 -0
  24. package/src/templates/api-rest/src/routes/tasks/[id].js +42 -0
  25. package/src/templates/api-rest/src/routes/tasks/index.js +26 -0
  26. package/src/templates/api-rest/src/services/auth/auth.js +9 -0
  27. package/src/templates/api-rest/src/services/auth/index.js +23 -0
  28. package/src/templates/realtime-chat/.env.example +26 -0
  29. package/src/templates/realtime-chat/README.md +38 -0
  30. package/src/templates/realtime-chat/frontend/.env.example +3 -0
  31. package/src/templates/realtime-chat/frontend/index.html +12 -0
  32. package/src/templates/realtime-chat/frontend/main.jsx +18 -0
  33. package/src/templates/realtime-chat/frontend/src/hooks/useAuth.jsx +27 -0
  34. package/src/templates/realtime-chat/frontend/src/hooks/useChatSocket.jsx +29 -0
  35. package/src/templates/realtime-chat/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
  36. package/src/templates/realtime-chat/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
  37. package/src/templates/realtime-chat/frontend/src/store/storeAuth.jsx +11 -0
  38. package/src/templates/realtime-chat/frontend/src/views/AuthView.jsx +70 -0
  39. package/src/templates/realtime-chat/frontend/src/views/ChatView.jsx +69 -0
  40. package/src/templates/realtime-chat/frontend/styles.css +1 -0
  41. package/src/templates/realtime-chat/frontend/vite.config.js +7 -0
  42. package/src/templates/realtime-chat/index.js +14 -0
  43. package/src/templates/realtime-chat/src/guards/auth.js +3 -0
  44. package/src/templates/realtime-chat/src/handlers/connection.js +23 -0
  45. package/src/templates/realtime-chat/src/handlers/message.js +29 -0
  46. package/src/templates/realtime-chat/src/services/auth/auth.js +8 -0
  47. package/src/templates/realtime-chat/src/services/auth/index.js +19 -0
  48. package/src/templates/saas-multitenant/.env.example +22 -0
  49. package/src/templates/saas-multitenant/README.md +71 -0
  50. package/src/templates/saas-multitenant/frontend/.env.example +3 -0
  51. package/src/templates/saas-multitenant/frontend/index.html +12 -0
  52. package/src/templates/saas-multitenant/frontend/main.jsx +18 -0
  53. package/src/templates/saas-multitenant/frontend/src/hooks/useAuth.jsx +27 -0
  54. package/src/templates/saas-multitenant/frontend/src/hooks/useProjects.jsx +41 -0
  55. package/src/templates/saas-multitenant/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
  56. package/src/templates/saas-multitenant/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
  57. package/src/templates/saas-multitenant/frontend/src/store/storeAuth.jsx +13 -0
  58. package/src/templates/saas-multitenant/frontend/src/views/AuthView.jsx +70 -0
  59. package/src/templates/saas-multitenant/frontend/src/views/DashboardView.jsx +131 -0
  60. package/src/templates/saas-multitenant/frontend/styles.css +1 -0
  61. package/src/templates/saas-multitenant/frontend/vite.config.js +7 -0
  62. package/src/templates/saas-multitenant/index.js +14 -0
  63. package/src/templates/saas-multitenant/src/middlewares/RequireOrganization.js +22 -0
  64. package/src/templates/saas-multitenant/src/models/Project.js +17 -0
  65. package/src/templates/saas-multitenant/src/routes/admin/stats.js +15 -0
  66. package/src/templates/saas-multitenant/src/routes/projects/index.js +34 -0
  67. package/src/templates/saas-multitenant/src/services/auth/auth.js +8 -0
  68. package/src/templates/saas-multitenant/src/services/auth/index.js +43 -0
  69. package/src/utils/EnvManager.js +23 -0
@@ -15,13 +15,15 @@ module.exports = class InteractiveStorageExtension extends Extension {
15
15
  path;
16
16
  maxFileSize;
17
17
  middlewares;
18
+ maxDeleteBatch;
18
19
 
19
- constructor({ path = '/storage', bucketName = 'dropbox', maxFileSize = 100 * 1024 * 1024, middlewares = [] } = {}) {
20
+ constructor({ path = '/storage', bucketName = 'dropbox', maxFileSize = 100 * 1024 * 1024, middlewares = [], maxDeleteBatch = 100 } = {}) {
20
21
  super("InteractiveStorageExtension");
21
22
  this.path = path || '/storage';
22
23
  InteractiveStorageExtension.bucketName = bucketName;
23
24
  this.maxFileSize = maxFileSize;
24
25
  this.middlewares = middlewares || [];
26
+ this.maxDeleteBatch = maxDeleteBatch;
25
27
  }
26
28
 
27
29
  load(container) {
@@ -47,15 +49,24 @@ module.exports = class InteractiveStorageExtension extends Extension {
47
49
  new BrowseRoute(`${this.path}/browse`, s3, InteractiveStorageExtension.bucketName, normalizePath, listFilesAndFolders),
48
50
  new DownloadRoute(`${this.path}/download/:fileName`, s3, InteractiveStorageExtension.bucketName, getFileStat),
49
51
  new InfoRoute(`${this.path}/info/:fileName`, s3, InteractiveStorageExtension.bucketName, getFileStat),
50
- new DeleteRoute(`${this.path}/delete`, s3, InteractiveStorageExtension.bucketName),
52
+ new DeleteRoute(`${this.path}/delete`, s3, InteractiveStorageExtension.bucketName, this.maxDeleteBatch),
51
53
  new CreateFolderRoute(`${this.path}/create-folder`, s3, InteractiveStorageExtension.bucketName, normalizePath),
52
- new DeleteFolderRoute(`${this.path}/delete-folder`, s3, InteractiveStorageExtension.bucketName, normalizePath, listFiles)
54
+ new DeleteFolderRoute(`${this.path}/delete-folder`, s3, InteractiveStorageExtension.bucketName, normalizePath, listFiles, this.maxDeleteBatch)
53
55
  ];
54
56
 
55
- // Add multer middleware to upload route
56
- routes[0].middlewares = {
57
- post: [new MulterMiddleware(this.maxFileSize)]
58
- };
57
+ // Apply the user-provided middlewares (e.g. authentication) to every
58
+ // storage route and method. They run before the route handler.
59
+ const userMiddlewares = this.middlewares || [];
60
+ for (const route of routes) {
61
+ route.middlewares = {
62
+ get: [...userMiddlewares],
63
+ post: [...userMiddlewares],
64
+ delete: [...userMiddlewares]
65
+ };
66
+ }
67
+
68
+ // Add multer middleware to the upload route, after any auth middlewares.
69
+ routes[0].middlewares.post = [...userMiddlewares, new MulterMiddleware(this.maxFileSize)];
59
70
 
60
71
  // Register routes using the express service pattern
61
72
  express.registerRoutes(routes);
@@ -148,8 +159,13 @@ module.exports = class InteractiveStorageExtension extends Extension {
148
159
  }
149
160
 
150
161
  #normalizePath(path) {
151
- // Remove leading/trailing slashes and normalize
152
- return path.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/');
162
+ // Normalize slashes and strip any path-traversal segments so a crafted
163
+ // "folder"/"folderPath" value cannot escape the intended prefix.
164
+ return String(path)
165
+ .replace(/\\/g, '/')
166
+ .split('/')
167
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
168
+ .join('/');
153
169
  }
154
170
 
155
171
  async #getFileStat(s3, fileName) {
@@ -5,13 +5,15 @@ module.exports = class DeleteFolderRoute extends Route {
5
5
  bucketName;
6
6
  normalizePath;
7
7
  listFiles;
8
+ maxDeleteBatch;
8
9
 
9
- constructor(path, s3, bucketName, normalizePath, listFiles) {
10
+ constructor(path, s3, bucketName, normalizePath, listFiles, maxDeleteBatch = 100) {
10
11
  super(path);
11
12
  this.s3 = s3;
12
13
  this.bucketName = bucketName;
13
14
  this.normalizePath = normalizePath;
14
15
  this.listFiles = listFiles;
16
+ this.maxDeleteBatch = maxDeleteBatch;
15
17
  }
16
18
 
17
19
  async delete({ container, request }) {
@@ -36,6 +38,16 @@ module.exports = class DeleteFolderRoute extends Route {
36
38
  };
37
39
  }
38
40
 
41
+ // Cap the batch size to avoid mass-deletion / resource-exhaustion when a
42
+ // prefix contains a very large number of objects.
43
+ if (files.length > this.maxDeleteBatch) {
44
+ return {
45
+ success: false,
46
+ message: `Folder contains too many files (${files.length}). Maximum allowed per request is ${this.maxDeleteBatch}`,
47
+ status: 400
48
+ };
49
+ }
50
+
39
51
  // Delete all files
40
52
  const deletePromises = files.map(file =>
41
53
  this.s3.removeFile(this.bucketName, file.name).catch(err => ({ error: err.message, fileName: file.name }))
@@ -3,24 +3,35 @@ const { Route } = require('../../../services/express');
3
3
  module.exports = class DeleteRoute extends Route {
4
4
  s3;
5
5
  bucketName;
6
+ maxDeleteBatch;
6
7
 
7
- constructor(path, s3, bucketName) {
8
+ constructor(path, s3, bucketName, maxDeleteBatch = 100) {
8
9
  super(path);
9
10
  this.s3 = s3;
10
11
  this.bucketName = bucketName;
12
+ this.maxDeleteBatch = maxDeleteBatch;
11
13
  }
12
14
 
13
15
  async delete({ container, request }) {
14
16
  const logger = container.get('logger');
15
17
  const { fileName, fileNames } = request.body;
16
-
18
+
17
19
  // Support both single file and multiple files
18
20
  const filesToDelete = fileNames || (fileName ? [fileName] : []);
19
-
21
+
20
22
  if (!Array.isArray(filesToDelete) || filesToDelete.length === 0) {
21
23
  return { success: false, message: 'fileName or fileNames array is required', status: 400 };
22
24
  }
23
25
 
26
+ // Cap the batch size to avoid mass-deletion / resource-exhaustion in a single request.
27
+ if (filesToDelete.length > this.maxDeleteBatch) {
28
+ return {
29
+ success: false,
30
+ message: `Too many files in a single request. Maximum allowed is ${this.maxDeleteBatch}`,
31
+ status: 400
32
+ };
33
+ }
34
+
24
35
  const deletePromises = filesToDelete.map(file =>
25
36
  this.s3.removeFile(this.bucketName, file).catch(err => ({ error: err.message, fileName: file }))
26
37
  );
@@ -1,4 +1,13 @@
1
1
  const { Route } = require('../../../services/express');
2
+ const path = require('path');
3
+
4
+ // Reject object keys that try to escape the bucket prefix or are malformed,
5
+ // before they reach the S3 client.
6
+ function isUnsafeKey(key) {
7
+ if (typeof key !== 'string' || key.length === 0) return true;
8
+ if (key.includes('\\') || key.startsWith('/')) return true;
9
+ return key.split('/').some((segment) => segment === '..' || segment === '.');
10
+ }
2
11
 
3
12
  module.exports = class DownloadRoute extends Route {
4
13
  s3;
@@ -15,14 +24,24 @@ module.exports = class DownloadRoute extends Route {
15
24
  async get({ container, request, response }) {
16
25
  const logger = container.get('logger');
17
26
  const { fileName } = request.params;
18
-
27
+
28
+ if (isUnsafeKey(fileName)) {
29
+ return { success: false, message: 'Invalid file name', status: 400 };
30
+ }
31
+
19
32
  try {
20
33
  // Get file info first
21
34
  const stat = await this.getFileStat(this.s3, fileName);
22
35
 
23
- // Set response headers
36
+ // Set response headers. Sanitize the filename to prevent header
37
+ // injection (CRLF / quote breakout) from the user-controlled key.
38
+ const baseName = path.posix.basename(fileName);
39
+ const asciiName = baseName.replace(/[^\w.\- ]/g, '_') || 'download';
24
40
  response.setHeader('Content-Type', stat.metaData?.['content-type'] || 'application/octet-stream');
25
- response.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
41
+ response.setHeader(
42
+ 'Content-Disposition',
43
+ `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(baseName)}`
44
+ );
26
45
  response.setHeader('Content-Length', stat.size);
27
46
 
28
47
  // Stream the file
@@ -1,5 +1,13 @@
1
1
  const { Route } = require('../../../services/express');
2
2
 
3
+ // Reject object keys that try to escape the bucket prefix or are malformed,
4
+ // before they reach the S3 client.
5
+ function isUnsafeKey(key) {
6
+ if (typeof key !== 'string' || key.length === 0) return true;
7
+ if (key.includes('\\') || key.startsWith('/')) return true;
8
+ return key.split('/').some((segment) => segment === '..' || segment === '.');
9
+ }
10
+
3
11
  module.exports = class InfoRoute extends Route {
4
12
  s3;
5
13
  bucketName;
@@ -17,6 +25,11 @@ module.exports = class InfoRoute extends Route {
17
25
 
18
26
  try {
19
27
  const { fileName } = request.params;
28
+
29
+ if (isUnsafeKey(fileName)) {
30
+ return { success: false, message: 'Invalid file name', status: 400 };
31
+ }
32
+
20
33
  const stat = await this.getFileStat(this.s3, fileName);
21
34
 
22
35
  return {
@@ -4,6 +4,7 @@ const { betterAuth } = require("better-auth");
4
4
  const { admin, bearer, organization } = require("better-auth/plugins");
5
5
  const { Pool } = require("pg");
6
6
  const path = require("path");
7
+ const crypto = require("crypto");
7
8
 
8
9
  module.exports = class AuthService extends Service {
9
10
  #container;
@@ -24,13 +25,26 @@ module.exports = class AuthService extends Service {
24
25
  express.regiterRawAllRoutes("/api/auth/*splat", toNodeHandler(this.auth));
25
26
  }
26
27
 
28
+ // Resolve the current better-auth session from raw Node request headers
29
+ // (e.g. `request.headers` or `socket.handshake.headers`). Lets application
30
+ // code check sessions without importing better-auth directly.
31
+ async getSession(headers) {
32
+ const { fromNodeHeaders } = require('better-auth/node');
33
+ return this.client.api.getSession({ headers: fromNodeHeaders(headers) });
34
+ }
35
+
27
36
  #addAuthEnvVariables() {
28
37
  const EnvManager = require('../../utils/EnvManager');
29
38
  const envPath = path.join(process.cwd(), '.env');
30
39
 
31
- const secretAdded = EnvManager.addEnvVariable(envPath, 'AUTH_SECRET', 'change-this-secret-in-production');
40
+ // Generate a strong, unique secret per project instead of a static value.
41
+ const generatedSecret = crypto.randomBytes(32).toString('hex');
42
+ const secretAdded = EnvManager.addEnvVariable(envPath, 'AUTH_SECRET', generatedSecret);
32
43
  if (secretAdded) {
33
- this.#container.get('logger').info('Added AUTH_SECRET to .env file');
44
+ // dotenv already loaded before this file was written, so make the freshly
45
+ // generated value available for this first run too.
46
+ process.env.AUTH_SECRET = generatedSecret;
47
+ this.#container.get('logger').info('Generated a random AUTH_SECRET and added it to .env file');
34
48
  }
35
49
 
36
50
  const originsAdded = EnvManager.addEnvVariable(envPath, 'TRUSTED_ORIGINS', 'http://localhost:5173,http://localhost:3000');
@@ -44,6 +58,15 @@ module.exports = class AuthService extends Service {
44
58
  }
45
59
  }
46
60
 
61
+ #requireAuthSecret() {
62
+ const secret = process.env.AUTH_SECRET;
63
+ // Fail closed: never fall back to a hard-coded/shared secret.
64
+ if (!secret || secret === 'change-this-secret-in-production' || secret === 'your-secret-key-change-in-production') {
65
+ throw new Error('AUTH_SECRET is missing or insecure. Set a strong, unique AUTH_SECRET in your .env file.');
66
+ }
67
+ return secret;
68
+ }
69
+
47
70
  #getDatabaseConnection() {
48
71
  const dialect = process.env.DATABASE_DIALECT;
49
72
 
@@ -64,6 +87,14 @@ module.exports = class AuthService extends Service {
64
87
  return [];
65
88
  }
66
89
 
90
+ get organizationEnabled() {
91
+ return true;
92
+ }
93
+
94
+ get requireEmailVerification() {
95
+ return false;
96
+ }
97
+
67
98
  get socialProviders() {
68
99
  return {}
69
100
  }
@@ -102,58 +133,51 @@ module.exports = class AuthService extends Service {
102
133
 
103
134
  get auth() {
104
135
  const cache = this.#container.get('cache');
136
+
137
+ // Environment-aware cookie security:
138
+ // - Cross-domain front/back (different sites): set AUTH_CROSS_DOMAIN=true ->
139
+ // sameSite "none" + secure + cross-subdomain (requires HTTPS).
140
+ // - Otherwise (local dev, or same-site/same-domain prod): sameSite "lax" and
141
+ // secure only in production, so cookies work over http://localhost in dev.
142
+ const crossDomain = process.env.AUTH_CROSS_DOMAIN === 'true';
143
+ const isProduction = process.env.NODE_ENV === 'production';
144
+ const cookieSameSite = crossDomain ? 'none' : 'lax';
145
+ const cookieSecure = crossDomain || isProduction;
146
+
105
147
  return betterAuth({
106
148
  hooks: this.hooks,
107
149
  plugins: [
108
150
  admin(),
109
151
  bearer(),
110
- organization({
111
- schema: {
112
- organization: {
113
- additionalFields: this.organizationAdditionalFields
152
+ ...(this.organizationEnabled ? [
153
+ organization({
154
+ schema: {
155
+ organization: {
156
+ additionalFields: this.organizationAdditionalFields
157
+ },
158
+ member: {
159
+ additionalFields: this.memberAdditionalFields
160
+ }
114
161
  },
115
- member: {
116
- additionalFields: this.memberAdditionalFields
162
+ allowUserToCreateOrganization: async (user) => {
163
+ return await this.allowUserToCreateOrganization(user);
164
+ },
165
+ sendInvitationEmail: async (data) => {
166
+ return await this.sendInvitationEmail(data);
117
167
  }
118
- },
119
- allowUserToCreateOrganization: async (user) => {
120
- return await this.allowUserToCreateOrganization(user);
121
- },
122
- sendInvitationEmail: async (data) => {
123
- return await this.sendInvitationEmail(data);
124
- }
125
- }),
168
+ })
169
+ ] : []),
126
170
  ...this.plugins,
127
171
  ],
128
172
  socialProviders: this.socialProviders,
129
173
  database: this.#getDatabaseConnection(),
130
174
  advanced: {
131
- crossSubDomainCookies: {
132
- enabled: true,
133
- },
134
- cookie: {
135
- sameSite: "none",
136
- secure: true,
137
- path: "/",
138
- state: {
139
- attributes: {
140
- sameSite: "none",
141
- secure: true,
142
- }
143
- }
144
- },
175
+ ...(crossDomain ? { crossSubDomainCookies: { enabled: true } } : {}),
145
176
  defaultCookieAttributes: {
146
- secure: true,
147
- sameSite: "none",
177
+ sameSite: cookieSameSite,
178
+ secure: cookieSecure,
179
+ httpOnly: true,
148
180
  },
149
- cookies: {
150
- state: {
151
- attributes: {
152
- sameSite: "none",
153
- secure: true,
154
- }
155
- }
156
- }
157
181
  },
158
182
  secondaryStorage: {
159
183
  get: async (key) => {
@@ -169,7 +193,7 @@ module.exports = class AuthService extends Service {
169
193
  },
170
194
  emailAndPassword: {
171
195
  enabled: true,
172
- requireEmailVerification: false,
196
+ requireEmailVerification: this.requireEmailVerification,
173
197
  sendResetPassword: async ({ user, url, token }, request) => this.sendResetPasswordEmail({ user, url, token }, request),
174
198
  },
175
199
  emailVerification: {
@@ -189,7 +213,7 @@ module.exports = class AuthService extends Service {
189
213
  updateAge: 60 * 60 * 24
190
214
  },
191
215
  baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
192
- secret: process.env.AUTH_SECRET || 'your-secret-key-change-in-production',
216
+ secret: this.#requireAuthSecret(),
193
217
  trustedOrigins: process.env.TRUSTED_ORIGINS?.split(',') || ['http://localhost:5173', 'http://localhost:6632']
194
218
  })
195
219
  }
@@ -29,19 +29,36 @@ module.exports = class Express extends Service {
29
29
  this.#httpServer = httpServer;
30
30
  this.#app = express();
31
31
 
32
- this.#app.use(express.json({ limit: `100mb` }))
32
+ // Configurable JSON body limit (defaults to 10mb) to reduce DoS surface.
33
+ this.#app.use(express.json({ limit: process.env.HTTP_JSON_LIMIT || '10mb' }))
33
34
 
34
35
  const corsOptions = await this.#loadCorsOrCreateDefault();
35
36
 
36
37
  if(corsOptions) this.#app.use(cors(corsOptions));
37
38
 
38
- // Swagger setup
39
- const swaggerOptions = {
40
- ...(await this.#loadSwaggerOrCreateDefault()),
41
- };
39
+ // Swagger setup (optional). Can be fully disabled, or protected with
40
+ // HTTP Basic auth by setting SWAGGER_PASSWORD.
41
+ if (process.env.DISABLE_SWAGGER !== 'true') {
42
+ const swaggerOptions = {
43
+ ...(await this.#loadSwaggerOrCreateDefault()),
44
+ };
45
+
46
+ const swaggerDocs = swaggerJsDoc(swaggerOptions);
47
+ const swaggerPath = process?.env?.SWAGGER_PATH || "/docs";
48
+ const swaggerMiddlewares = [];
42
49
 
43
- const swaggerDocs = swaggerJsDoc(swaggerOptions);
44
- this.#app.use(process?.env?.SWAGGER_PATH || "/docs", swaggerUi.serve, swaggerUi.setup(swaggerDocs));
50
+ if (process.env.SWAGGER_PASSWORD) {
51
+ const basicAuth = require("express-basic-auth");
52
+ swaggerMiddlewares.push(basicAuth({
53
+ users: { [process.env.SWAGGER_USER || 'admin']: process.env.SWAGGER_PASSWORD },
54
+ challenge: true,
55
+ }));
56
+ } else {
57
+ this.#container.get('logger').warn(`Swagger docs are exposed without authentication at ${swaggerPath}. Set SWAGGER_PASSWORD to protect them or DISABLE_SWAGGER=true to disable.`);
58
+ }
59
+
60
+ this.#app.use(swaggerPath, ...swaggerMiddlewares, swaggerUi.serve, swaggerUi.setup(swaggerDocs));
61
+ }
45
62
 
46
63
  const routes = await this.#loadRoutesFromFolder(path.join(process.cwd(), "src", "routes"));
47
64
 
@@ -64,8 +81,8 @@ module.exports = class Express extends Service {
64
81
  try {
65
82
  await mw.handle({ container: this.#container, request: req, response: res, next })
66
83
  } catch (error) {
67
- this.#container.get('logger').error(`Error in middleware for route [${methodName}] ${route.path}: ${error.message}`);
68
- return res.status(500).json({ success: false, message: error.message || 'Internal Server Error' });
84
+ this.#container.get('logger').error(`Error in middleware for route [${methodName}] ${route.path}: ${error.stack || error.message}`);
85
+ return res.status(500).json({ success: false, message: 'Internal Server Error' });
69
86
  }
70
87
  }),
71
88
  async (req, res) => {
@@ -87,8 +104,8 @@ module.exports = class Express extends Service {
87
104
  success: routeResponse?.success !== false,
88
105
  });
89
106
  } catch (error) {
90
- this.#container.get('logger').error(`Error in route [${methodName}] ${route.path}: ${error.message}`);
91
- return res.status(500).json({ success: false, message: error.message || 'Internal Server Error' });
107
+ this.#container.get('logger').error(`Error in route [${methodName}] ${route.path}: ${error.stack || error.message}`);
108
+ return res.status(500).json({ success: false, message: 'Internal Server Error' });
92
109
  }
93
110
  });
94
111
  }
@@ -120,8 +137,8 @@ module.exports = class Express extends Service {
120
137
  try {
121
138
  await mw.handle({ container: this.#container, request: req, response: res, next })
122
139
  } catch (error) {
123
- this.#container.get('logger').error(`Error in middleware for route [${methodName}] ${route.path}: ${error.message}`);
124
- return res.status(500).json({ success: false, message: error.message || 'Internal Server Error' });
140
+ this.#container.get('logger').error(`Error in middleware for route [${methodName}] ${route.path}: ${error.stack || error.message}`);
141
+ return res.status(500).json({ success: false, message: 'Internal Server Error' });
125
142
  }
126
143
  }),
127
144
  async (req, res) => {
@@ -143,8 +160,8 @@ module.exports = class Express extends Service {
143
160
  success: routeResponse?.success !== false,
144
161
  });
145
162
  } catch (error) {
146
- this.#container.get('logger').error(`Error in route [${methodName}] ${route.path}: ${error.message}`);
147
- return res.status(500).json({ success: false, message: error.message || 'Internal Server Error' });
163
+ this.#container.get('logger').error(`Error in route [${methodName}] ${route.path}: ${error.stack || error.message}`);
164
+ return res.status(500).json({ success: false, message: 'Internal Server Error' });
148
165
  }
149
166
  });
150
167
  }
@@ -0,0 +1,14 @@
1
+ const RequireAuthMiddleware = require("./RequireAuthMiddleware");
2
+
3
+ /**
4
+ * Convenience middleware that requires an authenticated user with the
5
+ * `admin` role (better-auth admin plugin).
6
+ *
7
+ * Usage in a Route:
8
+ * middlewares = { delete: [ new RequireAdminMiddleware() ] }
9
+ */
10
+ module.exports = class RequireAdminMiddleware extends RequireAuthMiddleware {
11
+ constructor(adminRole = 'admin') {
12
+ super([adminRole]);
13
+ }
14
+ };
@@ -0,0 +1,46 @@
1
+ const Middleware = require("./Middleware");
2
+ const { fromNodeHeaders } = require("better-auth/node");
3
+
4
+ /**
5
+ * Blocks the request unless there is a valid better-auth session.
6
+ * Optionally restricts access to a set of roles (admin plugin).
7
+ *
8
+ * On success it attaches `request.user` and `request.session`.
9
+ *
10
+ * Usage in a Route:
11
+ * middlewares = { get: [ new RequireAuthMiddleware() ] }
12
+ * middlewares = { post: [ new RequireAuthMiddleware(['admin']) ] }
13
+ */
14
+ module.exports = class RequireAuthMiddleware extends Middleware {
15
+ constructor(roles = []) {
16
+ super();
17
+ this.roles = Array.isArray(roles) ? roles : [roles].filter(Boolean);
18
+ }
19
+
20
+ async handle({ container, request, response, next }) {
21
+ const auth = container.get('auth');
22
+ if (!auth || !auth.client) {
23
+ return response.status(500).json({ success: false, message: 'Auth service not available' });
24
+ }
25
+
26
+ const session = await auth.client.api.getSession({
27
+ headers: fromNodeHeaders(request.headers)
28
+ });
29
+
30
+ if (!session || !session.user) {
31
+ return response.status(401).json({ success: false, message: 'Unauthorized' });
32
+ }
33
+
34
+ if (this.roles.length > 0) {
35
+ const userRoles = String(session.user.role || '').split(',').map((r) => r.trim());
36
+ const allowed = this.roles.some((role) => userRoles.includes(role));
37
+ if (!allowed) {
38
+ return response.status(403).json({ success: false, message: 'Forbidden' });
39
+ }
40
+ }
41
+
42
+ request.user = session.user;
43
+ request.session = session.session;
44
+ next();
45
+ }
46
+ };
@@ -1,5 +1,7 @@
1
- module.exports = {
2
- Express: require("./Express"),
3
- Route: require("./Route"),
4
- Middleware: require("./Middleware"),
5
- }
1
+ module.exports = {
2
+ Express: require("./Express"),
3
+ Route: require("./Route"),
4
+ Middleware: require("./Middleware"),
5
+ RequireAuthMiddleware: require("./RequireAuthMiddleware"),
6
+ RequireAdminMiddleware: require("./RequireAdminMiddleware"),
7
+ }
@@ -0,0 +1,33 @@
1
+ const Guard = require("./Guard");
2
+ const { fromNodeHeaders } = require("better-auth/node");
3
+
4
+ /**
5
+ * Socket.IO guard that rejects the connection/event unless there is a valid
6
+ * better-auth session in the handshake. Throwing here blocks the event
7
+ * (see SocketIO service guard handling).
8
+ *
9
+ * On success it attaches the user to `socket.data.user`.
10
+ *
11
+ * Usage: create `src/guards/auth.js` with:
12
+ * module.exports = require('zyket').AuthGuard;
13
+ * and reference it from a handler: `guards = ["auth"];`
14
+ */
15
+ module.exports = class AuthGuard extends Guard {
16
+ async handle({ container, socket }) {
17
+ const auth = container.get('auth');
18
+ if (!auth || !auth.client) {
19
+ throw new Error('Auth service not available');
20
+ }
21
+
22
+ const session = await auth.client.api.getSession({
23
+ headers: fromNodeHeaders(socket.handshake.headers)
24
+ });
25
+
26
+ if (!session || !session.user) {
27
+ throw new Error('Unauthorized');
28
+ }
29
+
30
+ socket.data.user = session.user;
31
+ socket.data.session = session.session;
32
+ }
33
+ };
@@ -29,7 +29,9 @@ module.exports = class SocketIO extends Service {
29
29
  this.#container.get('logger').debug(`Guards: ${guards.map(mdl => mdl.name).join(", ")}`);
30
30
  this.#container.get('logger').debug(`Handlers: ${handlers.map(hdl => hdl.event).join(", ")}`);
31
31
 
32
- this.io = new Server({ cors: { origin: "*" }, maxHttpBufferSize: 10 * 1024 * 1024 });
32
+ // Configurable max payload size (defaults to 10mb) to reduce DoS surface.
33
+ const maxHttpBufferSize = Number(process.env.SOCKET_MAX_HTTP_BUFFER_SIZE) || 10 * 1024 * 1024;
34
+ this.io = new Server({ cors: { origin: "*" }, maxHttpBufferSize });
33
35
 
34
36
  if (process.env.REDIS_URL) {
35
37
  await this.#attachRedisAdapter();
@@ -1,5 +1,6 @@
1
1
  module.exports = {
2
2
  SocketIO: require("./SocketIO"),
3
3
  Handler: require("./Handler"),
4
- Guard: require("./Guard")
4
+ Guard: require("./Guard"),
5
+ AuthGuard: require("./AuthGuard")
5
6
  }
@@ -13,6 +13,7 @@ module.exports = class TemplateManager extends Service {
13
13
  async boot() {
14
14
  const zyketTemplates = await fg(['**/*'], {
15
15
  cwd: path.join(__dirname, '../../templates'),
16
+ dot: true, // include dotfiles like .env.example
16
17
  });
17
18
  console.log('Found templates:', zyketTemplates);
18
19
  for (const template of zyketTemplates) {
@@ -0,0 +1,24 @@
1
+ # --- api-rest template ---
2
+ # Zyket auto-generates a .env on first run; these are the relevant values.
3
+
4
+ DEBUG=true
5
+ PORT=3000
6
+
7
+ # Backend only: no socket, no frontend
8
+ DISABLE_EXPRESS=false
9
+ DISABLE_SOCKET=true
10
+ DISABLE_VITE=true
11
+
12
+ # Database (auth requires postgresql or sqlite)
13
+ DATABASE_URL=./database.sqlite
14
+ DATABASE_DIALECT=sqlite
15
+
16
+ # Payload limits (see SECURITY.md)
17
+ HTTP_JSON_LIMIT=10mb
18
+
19
+ # Protect Swagger docs in production
20
+ # SWAGGER_PASSWORD=change-me
21
+
22
+ # Auth — AUTH_SECRET is generated automatically on first run.
23
+ # BETTER_AUTH_URL=http://localhost:3000
24
+ # TRUSTED_ORIGINS=http://localhost:3000