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.
- package/SECURITY.md +40 -0
- package/bin/cli.js +282 -202
- package/index.js +4 -3
- package/package.json +5 -1
- package/src/extensions/bullboard/index.js +14 -3
- package/src/extensions/interactive-storage/index.js +25 -9
- package/src/extensions/interactive-storage/routes/delete-folder.js +13 -1
- package/src/extensions/interactive-storage/routes/delete.js +14 -3
- package/src/extensions/interactive-storage/routes/download.js +22 -3
- package/src/extensions/interactive-storage/routes/info.js +13 -0
- package/src/services/auth/index.js +66 -42
- package/src/services/express/Express.js +32 -15
- package/src/services/express/RequireAdminMiddleware.js +14 -0
- package/src/services/express/RequireAuthMiddleware.js +46 -0
- package/src/services/express/index.js +7 -5
- package/src/services/socketio/AuthGuard.js +33 -0
- package/src/services/socketio/SocketIO.js +3 -1
- package/src/services/socketio/index.js +2 -1
- package/src/services/template-manager/index.js +1 -0
- package/src/templates/api-rest/.env.example +24 -0
- package/src/templates/api-rest/README.md +50 -0
- package/src/templates/api-rest/index.js +18 -0
- package/src/templates/api-rest/src/models/Task.js +18 -0
- package/src/templates/api-rest/src/routes/tasks/[id].js +42 -0
- package/src/templates/api-rest/src/routes/tasks/index.js +26 -0
- package/src/templates/api-rest/src/services/auth/auth.js +9 -0
- package/src/templates/api-rest/src/services/auth/index.js +23 -0
- package/src/templates/realtime-chat/.env.example +26 -0
- package/src/templates/realtime-chat/README.md +38 -0
- package/src/templates/realtime-chat/frontend/.env.example +3 -0
- package/src/templates/realtime-chat/frontend/index.html +12 -0
- package/src/templates/realtime-chat/frontend/main.jsx +18 -0
- package/src/templates/realtime-chat/frontend/src/hooks/useAuth.jsx +27 -0
- package/src/templates/realtime-chat/frontend/src/hooks/useChatSocket.jsx +29 -0
- package/src/templates/realtime-chat/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
- package/src/templates/realtime-chat/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
- package/src/templates/realtime-chat/frontend/src/store/storeAuth.jsx +11 -0
- package/src/templates/realtime-chat/frontend/src/views/AuthView.jsx +70 -0
- package/src/templates/realtime-chat/frontend/src/views/ChatView.jsx +69 -0
- package/src/templates/realtime-chat/frontend/styles.css +1 -0
- package/src/templates/realtime-chat/frontend/vite.config.js +7 -0
- package/src/templates/realtime-chat/index.js +14 -0
- package/src/templates/realtime-chat/src/guards/auth.js +3 -0
- package/src/templates/realtime-chat/src/handlers/connection.js +23 -0
- package/src/templates/realtime-chat/src/handlers/message.js +29 -0
- package/src/templates/realtime-chat/src/services/auth/auth.js +8 -0
- package/src/templates/realtime-chat/src/services/auth/index.js +19 -0
- package/src/templates/saas-multitenant/.env.example +22 -0
- package/src/templates/saas-multitenant/README.md +71 -0
- package/src/templates/saas-multitenant/frontend/.env.example +3 -0
- package/src/templates/saas-multitenant/frontend/index.html +12 -0
- package/src/templates/saas-multitenant/frontend/main.jsx +18 -0
- package/src/templates/saas-multitenant/frontend/src/hooks/useAuth.jsx +27 -0
- package/src/templates/saas-multitenant/frontend/src/hooks/useProjects.jsx +41 -0
- package/src/templates/saas-multitenant/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
- package/src/templates/saas-multitenant/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
- package/src/templates/saas-multitenant/frontend/src/store/storeAuth.jsx +13 -0
- package/src/templates/saas-multitenant/frontend/src/views/AuthView.jsx +70 -0
- package/src/templates/saas-multitenant/frontend/src/views/DashboardView.jsx +131 -0
- package/src/templates/saas-multitenant/frontend/styles.css +1 -0
- package/src/templates/saas-multitenant/frontend/vite.config.js +7 -0
- package/src/templates/saas-multitenant/index.js +14 -0
- package/src/templates/saas-multitenant/src/middlewares/RequireOrganization.js +22 -0
- package/src/templates/saas-multitenant/src/models/Project.js +17 -0
- package/src/templates/saas-multitenant/src/routes/admin/stats.js +15 -0
- package/src/templates/saas-multitenant/src/routes/projects/index.js +34 -0
- package/src/templates/saas-multitenant/src/services/auth/auth.js +8 -0
- package/src/templates/saas-multitenant/src/services/auth/index.js +43 -0
- 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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
152
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
...(this.organizationEnabled ? [
|
|
153
|
+
organization({
|
|
154
|
+
schema: {
|
|
155
|
+
organization: {
|
|
156
|
+
additionalFields: this.organizationAdditionalFields
|
|
157
|
+
},
|
|
158
|
+
member: {
|
|
159
|
+
additionalFields: this.memberAdditionalFields
|
|
160
|
+
}
|
|
114
161
|
},
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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();
|
|
@@ -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
|