zyket 1.2.18 → 1.2.20

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 (74) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/AGENTS.md +407 -0
  3. package/README.md +175 -279
  4. package/SECURITY.md +40 -0
  5. package/bin/cli.js +310 -202
  6. package/index.js +4 -3
  7. package/package.json +7 -1
  8. package/plugin/.claude-plugin/plugin.json +2 -2
  9. package/plugin/skills/build/SKILL.md +88 -0
  10. package/src/extensions/bullboard/index.js +14 -3
  11. package/src/extensions/interactive-storage/index.js +25 -9
  12. package/src/extensions/interactive-storage/routes/delete-folder.js +13 -1
  13. package/src/extensions/interactive-storage/routes/delete.js +14 -3
  14. package/src/extensions/interactive-storage/routes/download.js +22 -3
  15. package/src/extensions/interactive-storage/routes/info.js +13 -0
  16. package/src/services/auth/index.js +46 -28
  17. package/src/services/express/Express.js +32 -15
  18. package/src/services/express/RequireAdminMiddleware.js +14 -0
  19. package/src/services/express/RequireAuthMiddleware.js +46 -0
  20. package/src/services/express/index.js +7 -5
  21. package/src/services/socketio/AuthGuard.js +33 -0
  22. package/src/services/socketio/SocketIO.js +3 -1
  23. package/src/services/socketio/index.js +2 -1
  24. package/src/services/template-manager/index.js +1 -0
  25. package/src/templates/api-rest/.env.example +24 -0
  26. package/src/templates/api-rest/README.md +50 -0
  27. package/src/templates/api-rest/index.js +18 -0
  28. package/src/templates/api-rest/src/models/Task.js +18 -0
  29. package/src/templates/api-rest/src/routes/tasks/[id].js +42 -0
  30. package/src/templates/api-rest/src/routes/tasks/index.js +26 -0
  31. package/src/templates/api-rest/src/services/auth/auth.js +9 -0
  32. package/src/templates/api-rest/src/services/auth/index.js +23 -0
  33. package/src/templates/realtime-chat/.env.example +26 -0
  34. package/src/templates/realtime-chat/README.md +38 -0
  35. package/src/templates/realtime-chat/frontend/.env.example +3 -0
  36. package/src/templates/realtime-chat/frontend/index.html +12 -0
  37. package/src/templates/realtime-chat/frontend/main.jsx +18 -0
  38. package/src/templates/realtime-chat/frontend/src/hooks/useAuth.jsx +27 -0
  39. package/src/templates/realtime-chat/frontend/src/hooks/useChatSocket.jsx +29 -0
  40. package/src/templates/realtime-chat/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
  41. package/src/templates/realtime-chat/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
  42. package/src/templates/realtime-chat/frontend/src/store/storeAuth.jsx +11 -0
  43. package/src/templates/realtime-chat/frontend/src/views/AuthView.jsx +70 -0
  44. package/src/templates/realtime-chat/frontend/src/views/ChatView.jsx +69 -0
  45. package/src/templates/realtime-chat/frontend/styles.css +1 -0
  46. package/src/templates/realtime-chat/frontend/vite.config.js +7 -0
  47. package/src/templates/realtime-chat/index.js +14 -0
  48. package/src/templates/realtime-chat/src/guards/auth.js +3 -0
  49. package/src/templates/realtime-chat/src/handlers/connection.js +23 -0
  50. package/src/templates/realtime-chat/src/handlers/message.js +29 -0
  51. package/src/templates/realtime-chat/src/services/auth/auth.js +8 -0
  52. package/src/templates/realtime-chat/src/services/auth/index.js +19 -0
  53. package/src/templates/saas-multitenant/.env.example +22 -0
  54. package/src/templates/saas-multitenant/README.md +71 -0
  55. package/src/templates/saas-multitenant/frontend/.env.example +3 -0
  56. package/src/templates/saas-multitenant/frontend/index.html +12 -0
  57. package/src/templates/saas-multitenant/frontend/main.jsx +18 -0
  58. package/src/templates/saas-multitenant/frontend/src/hooks/useAuth.jsx +27 -0
  59. package/src/templates/saas-multitenant/frontend/src/hooks/useProjects.jsx +41 -0
  60. package/src/templates/saas-multitenant/frontend/src/middlewares/LoggedMiddleware.jsx +12 -0
  61. package/src/templates/saas-multitenant/frontend/src/middlewares/NotLoggedMiddleware.jsx +12 -0
  62. package/src/templates/saas-multitenant/frontend/src/store/storeAuth.jsx +13 -0
  63. package/src/templates/saas-multitenant/frontend/src/views/AuthView.jsx +70 -0
  64. package/src/templates/saas-multitenant/frontend/src/views/DashboardView.jsx +131 -0
  65. package/src/templates/saas-multitenant/frontend/styles.css +1 -0
  66. package/src/templates/saas-multitenant/frontend/vite.config.js +7 -0
  67. package/src/templates/saas-multitenant/index.js +14 -0
  68. package/src/templates/saas-multitenant/src/middlewares/RequireOrganization.js +22 -0
  69. package/src/templates/saas-multitenant/src/models/Project.js +17 -0
  70. package/src/templates/saas-multitenant/src/routes/admin/stats.js +15 -0
  71. package/src/templates/saas-multitenant/src/routes/projects/index.js +34 -0
  72. package/src/templates/saas-multitenant/src/services/auth/auth.js +8 -0
  73. package/src/templates/saas-multitenant/src/services/auth/index.js +43 -0
  74. package/src/utils/EnvManager.js +23 -0
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: build
3
+ description: Build, configure, and run applications with the Zyket framework — start a project from a template, enable services, wire authentication, secure the app, and add features. Use whenever the user wants to use Zyket, create or set up a Zyket project/app, or understand how Zyket works. For scaffolding a single component, use the `generate` skill instead.
4
+ allowed-tools: Read, Write, Edit, Grep, Glob, Bash
5
+ ---
6
+
7
+ # Build with Zyket
8
+
9
+ Use this skill to take a user from zero to a running Zyket application, or to extend an existing one. Zyket is a service-oriented Node.js framework (Express 5 + Socket.IO + better-auth + Sequelize/BullMQ/MinIO) with filesystem auto-discovery and a DI container.
10
+
11
+ ## Golden rules
12
+
13
+ 1. **Read the reference.** After install, the full, authoritative API lives in `node_modules/zyket/AGENTS.md`. Read it before relying on any API; confirm base classes against `node_modules/zyket/` source. Do not invent APIs.
14
+ 2. **Filename = identity.** Routes, handlers, guards, events, schedulers, workers, and models are auto-discovered from `src/<folder>/`; the filename determines the URL/event/name. Use kebab-case and `module.exports = class extends Base`. Never add constructor params to these.
15
+ 3. **Services activate from `.env`.** A feature that needs sockets/queues/db must have the matching `DISABLE_*=false` / `DATABASE_URL` set.
16
+ 4. **Secure by default.** Protect non-public routes/sockets with `RequireAuthMiddleware` / `AuthGuard`. Never weaken cookie/auth settings to "make it work" — use `AUTH_CROSS_DOMAIN` instead.
17
+
18
+ ## Workflow
19
+
20
+ ### A. Starting a new project
21
+
22
+ 1. **Pick a template** by intent:
23
+
24
+ | User wants… | Template |
25
+ |-------------|----------|
26
+ | A clean REST API | `api-rest` |
27
+ | Multi-tenant SaaS (orgs, roles, dashboard) | `saas-multitenant` |
28
+ | Real-time / chat / live updates | `realtime-chat` |
29
+ | A general starter / unsure | `default` |
30
+
31
+ 2. **Scaffold:**
32
+ ```bash
33
+ npm install zyket
34
+ npx zyket init <template>
35
+ ```
36
+ This copies the template, merges its `.env.example` onto sensible defaults, writes `package.json` with the right dependencies, and installs them.
37
+
38
+ 3. **If the template uses auth** (`src/services/auth` exists), create the tables once:
39
+ ```bash
40
+ npx @better-auth/cli migrate
41
+ ```
42
+
43
+ 4. **Run:** `node index.js`. The API is on `PORT` (3000); a frontend, if any, on `VITE_PORT` (5173).
44
+
45
+ ### B. Enabling a capability in an existing project
46
+
47
+ Flip the env flag, then add the component. Confirm details in `AGENTS.md §3–4`.
48
+
49
+ | Capability | `.env` | Then add |
50
+ |-----------|--------|----------|
51
+ | HTTP routes | `DISABLE_EXPRESS=false` | `src/routes/**.js` |
52
+ | Real-time | `DISABLE_SOCKET=false` | `src/handlers/*.js`, `src/guards/*.js` |
53
+ | Database | `DATABASE_URL=...`, `DATABASE_DIALECT=...` | `src/models/*.js` |
54
+ | Background jobs | `DISABLE_BULLMQ=false`, `QUEUES=emails` | `src/workers/*.js` |
55
+ | Cron | `DISABLE_SCHEDULER=false` | `src/schedulers/*.js` |
56
+ | Cache | `CACHE_URL=redis://…` (or in-memory) | use `container.get('cache')` |
57
+ | Object storage | `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | use `container.get('s3')` |
58
+ | Frontend | `DISABLE_VITE=false`, `VITE_ROOT=./frontend` | `frontend/` React app |
59
+
60
+ ### C. Adding authentication
61
+
62
+ 1. Register the auth service in `index.js`: `["auth", require("./src/services/auth"), ["@service_container"]]`.
63
+ 2. Subclass `AuthService` (see `AGENTS.md §6`) to set `organizationEnabled`, `requireEmailVerification`, email senders, etc.
64
+ 3. Require `DATABASE_DIALECT` = `sqlite` or `postgresql`; run `npx @better-auth/cli migrate`.
65
+ 4. Protect routes with `new RequireAuthMiddleware()` (or `['admin']` / `RequireAdminMiddleware`) and sockets with the `AuthGuard` (`module.exports = require('zyket').AuthGuard` in `src/guards/auth.js`; enforce connect-time auth inside `src/handlers/connection.js` via `container.get('auth').getSession(socket.handshake.headers)`).
66
+
67
+ ### D. Building a feature (recipe)
68
+
69
+ 1. Model → `src/models/Thing.js` (Sequelize factory). Create tables with `container.get('database').sync()` after boot, or migrations.
70
+ 2. Routes → `src/routes/things/index.js` (GET/POST) and `src/routes/things/[id].js` (GET/PUT/DELETE), gated by `RequireAuthMiddleware`.
71
+ 3. Read data via `container.get('database').models.Thing`.
72
+ 4. Optional realtime/jobs per the table in B.
73
+ 5. Run and verify.
74
+
75
+ Delegate individual component scaffolding to the **`generate`** skill (e.g. `/zyket:generate route things/[id]`).
76
+
77
+ ## Common pitfalls
78
+
79
+ - **PATCH routes don't dispatch** — only `get/post/put/delete` methods are wired.
80
+ - **Connection guards don't block** — do connect-time auth inside `connection.js` (throw to disconnect).
81
+ - **`bullmq`/`scheduler`/`events` default ON when the `DISABLE_*` var is absent**, but the generated `.env` ships them as `true`. Set them to `false` explicitly to enable.
82
+ - **Auth fails to boot** if `AUTH_SECRET` is missing or a known placeholder — let the framework generate it.
83
+ - **Frontend can't resolve `vite`/`react`/`better-auth`** — the project (not just zyket) must declare those deps; `npx zyket init` does this automatically for templates with a frontend.
84
+ - **Boot crashes with `Cannot read properties of undefined (reading 'BarBarToken')`** — a TypeScript 7 got hoisted into the tree, which `node-dependency-injection`'s bundled `typescript-estree@5` can't read. Zyket pins `typescript` to `^5` and `npx zyket init` writes a matching `overrides` block; on a project predating that, add `"overrides": { "typescript": "^5.9.3" }` to `package.json` and reinstall.
85
+
86
+ ## Security pass before shipping
87
+
88
+ Run through `SECURITY.md`. Minimum: every private route/socket guarded; `BULLBOARD_ADMIN_PASSWORD` set (or the dashboard won't mount); `SWAGGER_PASSWORD` or `DISABLE_SWAGGER=true`; `TRUSTED_ORIGINS` locked; add rate limiting + `helmet` (not bundled).
@@ -7,14 +7,17 @@ const basicAuth = require('express-basic-auth')
7
7
  module.exports = class BullBoardExtension extends Extension {
8
8
  path;
9
9
 
10
- constructor({ path = '/bullboard', basePath = '' } = {}) {
10
+ constructor({ path = '/bullboard', basePath = '', middlewares = [] } = {}) {
11
11
  super("BullBoardExtension");
12
12
  this.path = path || '/bullboard';
13
13
  this.basePath = basePath;
14
+ // Optional Express middlewares (e.g. custom auth) applied before the board.
15
+ this.middlewares = middlewares || [];
14
16
  }
15
17
 
16
18
  load(container) {
17
- if (!container.get('bullmq')) return container.get('logger').warn('BullBoardExtension: bullmq service not found, skipping BullBoard setup');
19
+ const logger = container.get('logger')
20
+ if (!container.get('bullmq')) return logger.warn('BullBoardExtension: bullmq service not found, skipping BullBoard setup');
18
21
  const bull = container.get('bullmq')
19
22
  const serverAdapter = new ExpressAdapter()
20
23
  serverAdapter.setBasePath(this.basePath + this.path)
@@ -25,7 +28,7 @@ module.exports = class BullBoardExtension extends Extension {
25
28
  })
26
29
 
27
30
  const app = container.get('express').app()
28
- const middlewares = []
31
+ const middlewares = [...this.middlewares]
29
32
 
30
33
  if (process.env.BULLBOARD_ADMIN_PASSWORD) {
31
34
  middlewares.push(basicAuth({
@@ -34,6 +37,14 @@ module.exports = class BullBoardExtension extends Extension {
34
37
  }))
35
38
  }
36
39
 
40
+ // Fail closed: never expose the queue dashboard (jobs, payloads, retry/delete)
41
+ // without authentication. Require a password or custom auth middlewares.
42
+ if (middlewares.length === 0) {
43
+ return logger.warn(
44
+ `BullBoardExtension: no authentication configured. Set BULLBOARD_ADMIN_PASSWORD or pass "middlewares" to expose the dashboard. Skipping mount of ${this.path}.`
45
+ )
46
+ }
47
+
37
48
  app.use(this.path, ...middlewares, serverAdapter.getRouter())
38
49
  }
39
50
  }
@@ -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
 
@@ -68,6 +91,10 @@ module.exports = class AuthService extends Service {
68
91
  return true;
69
92
  }
70
93
 
94
+ get requireEmailVerification() {
95
+ return false;
96
+ }
97
+
71
98
  get socialProviders() {
72
99
  return {}
73
100
  }
@@ -106,6 +133,17 @@ module.exports = class AuthService extends Service {
106
133
 
107
134
  get auth() {
108
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
+
109
147
  return betterAuth({
110
148
  hooks: this.hooks,
111
149
  plugins: [
@@ -134,32 +172,12 @@ module.exports = class AuthService extends Service {
134
172
  socialProviders: this.socialProviders,
135
173
  database: this.#getDatabaseConnection(),
136
174
  advanced: {
137
- crossSubDomainCookies: {
138
- enabled: true,
139
- },
140
- cookie: {
141
- sameSite: "none",
142
- secure: true,
143
- path: "/",
144
- state: {
145
- attributes: {
146
- sameSite: "none",
147
- secure: true,
148
- }
149
- }
150
- },
175
+ ...(crossDomain ? { crossSubDomainCookies: { enabled: true } } : {}),
151
176
  defaultCookieAttributes: {
152
- secure: true,
153
- sameSite: "none",
177
+ sameSite: cookieSameSite,
178
+ secure: cookieSecure,
179
+ httpOnly: true,
154
180
  },
155
- cookies: {
156
- state: {
157
- attributes: {
158
- sameSite: "none",
159
- secure: true,
160
- }
161
- }
162
- }
163
181
  },
164
182
  secondaryStorage: {
165
183
  get: async (key) => {
@@ -175,7 +193,7 @@ module.exports = class AuthService extends Service {
175
193
  },
176
194
  emailAndPassword: {
177
195
  enabled: true,
178
- requireEmailVerification: false,
196
+ requireEmailVerification: this.requireEmailVerification,
179
197
  sendResetPassword: async ({ user, url, token }, request) => this.sendResetPasswordEmail({ user, url, token }, request),
180
198
  },
181
199
  emailVerification: {
@@ -195,7 +213,7 @@ module.exports = class AuthService extends Service {
195
213
  updateAge: 60 * 60 * 24
196
214
  },
197
215
  baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
198
- secret: process.env.AUTH_SECRET || 'your-secret-key-change-in-production',
216
+ secret: this.#requireAuthSecret(),
199
217
  trustedOrigins: process.env.TRUSTED_ORIGINS?.split(',') || ['http://localhost:5173', 'http://localhost:6632']
200
218
  })
201
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
+ }