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.
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +407 -0
- package/README.md +175 -279
- package/SECURITY.md +40 -0
- package/bin/cli.js +310 -202
- package/index.js +4 -3
- package/package.json +7 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/skills/build/SKILL.md +88 -0
- 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 +46 -28
- 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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
|
@@ -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
|
-
|
|
153
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|