webspresso 0.0.79 → 0.0.81
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/README.md +104 -6
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/core/auth/hash.js +1 -1
- package/core/auth/index.js +30 -0
- package/core/auth/manager.js +190 -0
- package/core/auth/tokens.js +135 -0
- package/core/kernel/app.js +7 -1
- package/core/orm/json-fields.js +1 -1
- package/core/orm/migrations/scaffold.js +5 -0
- package/core/orm/schema-helpers.js +18 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +32 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/plugins/admin-panel/client/parts/07-model-list.js +18 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +269 -0
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/csrf/index.js +222 -0
- package/plugins/email/admin-component.js +226 -0
- package/plugins/email/api-handlers.js +139 -0
- package/plugins/email/auth-bridge.js +209 -0
- package/plugins/email/bundled-templates.js +68 -0
- package/plugins/email/fs-env.js +101 -0
- package/plugins/email/index.js +186 -0
- package/plugins/email/manifest.js +25 -0
- package/plugins/email/migration-template.js +40 -0
- package/plugins/email/purge.js +28 -0
- package/plugins/email/render.js +154 -0
- package/plugins/email/service.js +244 -0
- package/plugins/email/template-registry.js +134 -0
- package/plugins/email/templates/auth-email-verify.mjml +13 -0
- package/plugins/email/templates/auth-password-reset.mjml +13 -0
- package/plugins/email/templates/auth-welcome.mjml +11 -0
- package/plugins/email/transport.js +71 -0
- package/plugins/index.js +4 -0
- package/plugins/upload/index.js +53 -30
- package/src/file-router.js +75 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
package/README.md
CHANGED
|
@@ -982,6 +982,57 @@ const { app } = createApp({
|
|
|
982
982
|
|
|
983
983
|
Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
|
|
984
984
|
|
|
985
|
+
**Email plugin:**
|
|
986
|
+
- Sends email via **Nodemailer** with **MJML** templates (`{{variable}}` interpolation)
|
|
987
|
+
- Template sources: `templatesDir` (`*.mjml`, **Node.js only** — skipped on edge/workers), `templates` map with inline `{ mjml }`, runtime `registerTemplate`, or inline `send({ mjml, html, data })`
|
|
988
|
+
- Transport: `transport`, `smtp`, or env (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`, `MAIL_FROM`)
|
|
989
|
+
- Optional DB logging to `email_logs` when `db` is passed; prune with `webspresso email:prune --days 90`
|
|
990
|
+
- **Build:** `webspresso build` embeds `emails/*.mjml` (config `emailDir`) + bundled auth templates into `manifest.emailTemplates` and `email-templates.mjs` (Cloudflare). Use `emailPlugin({ manifest })` or `emailPlugin({ emailTemplates: manifest.emailTemplates })` on edge.
|
|
991
|
+
- Admin page at `/_admin/email` (test send, preview, SMTP verify, logs) when `adminPanelPlugin` is loaded
|
|
992
|
+
- Optional **auth bridge**: `authEmails: { enabled: true }` sends password-reset / email-verification mails and can register `POST /api/auth/forgot-password`, `/reset-password`, `/verify-email`, `/resend-verification`
|
|
993
|
+
|
|
994
|
+
```javascript
|
|
995
|
+
const { quickAuth, createAuthTokensTable } = require('webspresso/core/auth');
|
|
996
|
+
const { adminPanelPlugin, emailPlugin } = require('webspresso/plugins');
|
|
997
|
+
|
|
998
|
+
const auth = quickAuth({ db, authTokens: true });
|
|
999
|
+
await createAuthTokensTable(db.knex);
|
|
1000
|
+
|
|
1001
|
+
const { app, pluginManager } = createApp({
|
|
1002
|
+
pagesDir: './pages',
|
|
1003
|
+
db,
|
|
1004
|
+
auth,
|
|
1005
|
+
plugins: [
|
|
1006
|
+
emailPlugin({
|
|
1007
|
+
db,
|
|
1008
|
+
auth,
|
|
1009
|
+
templatesDir: './emails',
|
|
1010
|
+
smtp: {
|
|
1011
|
+
host: process.env.SMTP_HOST,
|
|
1012
|
+
port: 587,
|
|
1013
|
+
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
1014
|
+
},
|
|
1015
|
+
defaults: { from: process.env.MAIL_FROM },
|
|
1016
|
+
authEmails: {
|
|
1017
|
+
enabled: true,
|
|
1018
|
+
baseUrl: process.env.BASE_URL,
|
|
1019
|
+
registerRoutes: true,
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
1022
|
+
adminPanelPlugin({ db }),
|
|
1023
|
+
],
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// From a route handler or another plugin:
|
|
1027
|
+
await pluginManager.getPluginAPI('email').sendTemplate('welcome', {
|
|
1028
|
+
to: 'user@example.com',
|
|
1029
|
+
subject: 'Welcome',
|
|
1030
|
+
data: { name: 'Ali' },
|
|
1031
|
+
});
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
Programmatic API: `ctx.usePlugin('email')` exposes `send`, `sendTemplate`, `preview`, `registerTemplate`, `listTemplates`, `verifyConnection`, `queryLogs`, `purgeLogs`, `getMigrationTemplate()`.
|
|
1035
|
+
|
|
985
1036
|
**ORM cache admin plugin:**
|
|
986
1037
|
- Depends on **`adminPanelPlugin`** and a database instance created with **`createDatabase({ cache: true | { … } })`** so **`db.cache`** is non-null
|
|
987
1038
|
- Registers an **ORM Cache** admin page (metrics, full purge, per-model invalidation, reset counters) backed by **`db.cache`**
|
|
@@ -2137,7 +2188,7 @@ List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed`
|
|
|
2137
2188
|
|
|
2138
2189
|
### File upload plugin
|
|
2139
2190
|
|
|
2140
|
-
Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns
|
|
2191
|
+
Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file(s) via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns public URL paths). Use **`mimeAllowlist`** / **`extensionAllowlist`**, **`maxBytes`** (default **10 MiB**), **`multiple`** (default **`false`**), and **`maxFiles`** (default **10**) on the server; production apps should prefer an explicit MIME allowlist.
|
|
2141
2192
|
|
|
2142
2193
|
**Setup:**
|
|
2143
2194
|
|
|
@@ -2160,6 +2211,8 @@ const { app } = createApp({
|
|
|
2160
2211
|
extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
|
|
2161
2212
|
middleware: [], // optional Express handlers (e.g. session auth)
|
|
2162
2213
|
fieldName: 'file',
|
|
2214
|
+
multiple: false, // set to true to allow multiple file uploads in a single request
|
|
2215
|
+
maxFiles: 10, // max number of files to accept when multiple is true
|
|
2163
2216
|
}),
|
|
2164
2217
|
adminPanelPlugin({
|
|
2165
2218
|
db,
|
|
@@ -2169,13 +2222,14 @@ const { app } = createApp({
|
|
|
2169
2222
|
});
|
|
2170
2223
|
```
|
|
2171
2224
|
|
|
2172
|
-
- **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
|
|
2173
|
-
- **
|
|
2174
|
-
- **Admin:**
|
|
2175
|
-
- **
|
|
2225
|
+
- **ORM (Single File):** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
|
|
2226
|
+
- **ORM (Multiple Files):** `zdb.files({ nullable: true })` — array column that stores a list of file URLs/paths as a JSON field in the database; migrations use `table.json()`.
|
|
2227
|
+
- **Admin forms:** columns with `zdb.file()` or `zdb.files()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Multiple file upload fields (`zdb.files()`) allow adding and removing multiple files concurrently and show preview grids. Image URLs show an inline thumbnail preview; use `ui.accept: 'image/*'` for image-only fields. Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
|
|
2228
|
+
- **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'`, `'files'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string (or an array of URLs for `'files'`).
|
|
2229
|
+
- **Response:** Single: `{ url, publicUrl, key? }`. Multiple: `[{ url, publicUrl, key? }, ...]`.
|
|
2176
2230
|
- **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
|
|
2177
2231
|
|
|
2178
|
-
**Admin model example** (`zdb.file()`
|
|
2232
|
+
**Admin model example** (`zdb.file()` and `zdb.files()` pick up the upload widgets from schema; no extra `customFields` needed):
|
|
2179
2233
|
|
|
2180
2234
|
```javascript
|
|
2181
2235
|
const { defineModel, zdb } = require('webspresso');
|
|
@@ -2191,6 +2245,10 @@ const Post = defineModel({
|
|
|
2191
2245
|
nullable: true,
|
|
2192
2246
|
ui: { label: 'Cover', accept: 'image/*', hint: 'JPEG or PNG' },
|
|
2193
2247
|
}),
|
|
2248
|
+
gallery: zdb.files({
|
|
2249
|
+
nullable: true,
|
|
2250
|
+
ui: { label: 'Gallery Images', accept: 'image/*' },
|
|
2251
|
+
}),
|
|
2194
2252
|
}),
|
|
2195
2253
|
admin: { enabled: true, label: 'Posts' },
|
|
2196
2254
|
});
|
|
@@ -2256,6 +2314,46 @@ const app = createApp({
|
|
|
2256
2314
|
|
|
2257
2315
|
In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
|
|
2258
2316
|
|
|
2317
|
+
### CSRF protection plugin
|
|
2318
|
+
|
|
2319
|
+
Protects mutating requests against Cross-Site Request Forgery (CSRF) using the double-submit token pattern. Supports session storage (when `req.session` is available) and automatically falls back to cookie-based storage.
|
|
2320
|
+
|
|
2321
|
+
**Setup:**
|
|
2322
|
+
|
|
2323
|
+
```javascript
|
|
2324
|
+
const { createApp, csrfPlugin } = require('webspresso');
|
|
2325
|
+
|
|
2326
|
+
const app = createApp({
|
|
2327
|
+
plugins: [
|
|
2328
|
+
csrfPlugin({
|
|
2329
|
+
global: true, // Enable globally for all routes
|
|
2330
|
+
cookie: true, // Use cookie-based storage (default uses session if present)
|
|
2331
|
+
ignorePaths: ['/api/webhook'], // Skip CSRF verification for specific routes
|
|
2332
|
+
}),
|
|
2333
|
+
],
|
|
2334
|
+
});
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
**Route-Specific Middleware:**
|
|
2338
|
+
|
|
2339
|
+
If `global: false` (default), you can protect specific routes by applying the named route middleware `csrf` in your route configuration:
|
|
2340
|
+
|
|
2341
|
+
```javascript
|
|
2342
|
+
// pages/submit.js
|
|
2343
|
+
module.exports = {
|
|
2344
|
+
middleware: ['csrf'],
|
|
2345
|
+
post(req, res) {
|
|
2346
|
+
res.send('Success!');
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
```
|
|
2350
|
+
|
|
2351
|
+
**Template Helpers:**
|
|
2352
|
+
|
|
2353
|
+
The plugin registers two Nunjucks template helpers:
|
|
2354
|
+
- `{{ fsy.csrfToken() }}`: Returns the raw token string (useful for API fetch/headers).
|
|
2355
|
+
- `{{ fsy.csrfInput() }}`: Renders a hidden input field: `<input type="hidden" name="_csrf" value="...">`.
|
|
2356
|
+
|
|
2259
2357
|
## Development
|
|
2260
2358
|
|
|
2261
2359
|
Native addons (**better-sqlite3**, **bcrypt**, **sharp**) are compiled for your current Node ABI. After switching Node major versions (e.g. nvm, fnm, Volta), run **`npm run rebuild:native`** or a clean install: `rm -rf node_modules && npm ci`. **chokidar** is not ABI-tied like those drivers; if file watching misbehaves, reinstall dependencies. The repo includes [`.nvmrc`](.nvmrc) (Node 20 LTS) as a known-good default for this project.
|
package/bin/commands/doctor.js
CHANGED
|
@@ -102,6 +102,89 @@ function registerCommand(program) {
|
|
|
102
102
|
const pagesPath = path.join(cwd, 'pages');
|
|
103
103
|
if (fs.existsSync(pagesPath) && fs.statSync(pagesPath).isDirectory()) {
|
|
104
104
|
line('✓', 'pages/ directory present');
|
|
105
|
+
|
|
106
|
+
console.log('\nRouting and file-system checks');
|
|
107
|
+
console.log('------------------------------');
|
|
108
|
+
|
|
109
|
+
let routingChecksPassed = true;
|
|
110
|
+
|
|
111
|
+
function getGenericSegment(name) {
|
|
112
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
113
|
+
const inner = name.slice(1, -1);
|
|
114
|
+
if (inner.startsWith('...')) {
|
|
115
|
+
return '*';
|
|
116
|
+
}
|
|
117
|
+
return ':param';
|
|
118
|
+
}
|
|
119
|
+
return name;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scanRoutes(dirPath, relativeDir = '', isApi = false) {
|
|
123
|
+
const children = fs.readdirSync(dirPath);
|
|
124
|
+
const kept = [];
|
|
125
|
+
|
|
126
|
+
for (const child of children) {
|
|
127
|
+
const fullPath = path.join(dirPath, child);
|
|
128
|
+
const relPath = relativeDir ? path.join(relativeDir, child) : child;
|
|
129
|
+
const stats = fs.statSync(fullPath);
|
|
130
|
+
|
|
131
|
+
// 1. Check case-sensitivity issues
|
|
132
|
+
if (child !== child.toLowerCase()) {
|
|
133
|
+
line('⚠', `Path "pages/${relPath}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
|
|
134
|
+
warnings += 1;
|
|
135
|
+
routingChecksPassed = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (stats.isDirectory()) {
|
|
139
|
+
if (child === 'api' && relativeDir === '') {
|
|
140
|
+
scanRoutes(fullPath, relPath, true);
|
|
141
|
+
} else {
|
|
142
|
+
scanRoutes(fullPath, relPath, isApi);
|
|
143
|
+
if (!isApi) {
|
|
144
|
+
kept.push({ name: child, isDir: true, original: child });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const ext = path.extname(child);
|
|
149
|
+
if (isApi) {
|
|
150
|
+
if (ext === '.js') {
|
|
151
|
+
const base = path.basename(child, ext);
|
|
152
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
if (ext === '.njk') {
|
|
156
|
+
const base = path.basename(child, ext);
|
|
157
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Check dynamic route collisions
|
|
164
|
+
const groups = new Map();
|
|
165
|
+
for (const item of kept) {
|
|
166
|
+
const normalized = getGenericSegment(item.name);
|
|
167
|
+
if (!groups.has(normalized)) {
|
|
168
|
+
groups.set(normalized, []);
|
|
169
|
+
}
|
|
170
|
+
groups.get(normalized).push(item);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [norm, items] of groups.entries()) {
|
|
174
|
+
if (items.length > 1) {
|
|
175
|
+
const namesList = items.map(item => `"${item.original}"`).join(' and ');
|
|
176
|
+
line('⚠', `Dynamic route collision in "pages/${relativeDir}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
|
|
177
|
+
warnings += 1;
|
|
178
|
+
routingChecksPassed = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
scanRoutes(pagesPath, '');
|
|
184
|
+
|
|
185
|
+
if (routingChecksPassed) {
|
|
186
|
+
line('✓', 'All route paths are lowercase and free of dynamic collisions');
|
|
187
|
+
}
|
|
105
188
|
} else {
|
|
106
189
|
line('⚠', 'pages/ not found (file-based routes may be missing)');
|
|
107
190
|
warnings += 1;
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const sharp =
|
|
8
|
+
const sharp = process.env.NODE_ENV === 'test'
|
|
9
|
+
? require('../../tests/mocks/sharp')
|
|
10
|
+
: require('sharp');
|
|
9
11
|
|
|
10
12
|
const APPLE_SIZES = [57, 60, 72, 76, 114, 120, 144, 152, 180];
|
|
11
13
|
const ANDROID_SIZE = 192;
|
package/core/auth/hash.js
CHANGED
package/core/auth/index.js
CHANGED
|
@@ -8,6 +8,12 @@ const { AuthManager, AuthenticationError, DEFAULT_CONFIG } = require('./manager'
|
|
|
8
8
|
const { PolicyManager, AuthorizationError } = require('./policy');
|
|
9
9
|
const { createAuthMiddleware, setupAuthMiddleware } = require('./middleware');
|
|
10
10
|
const { hash, verify, needsRehash, generateToken, hashToken } = require('./hash');
|
|
11
|
+
const {
|
|
12
|
+
TOKEN_TYPES,
|
|
13
|
+
createAuthTokensTable,
|
|
14
|
+
dropAuthTokensTable,
|
|
15
|
+
createKnexAuthTokensAdapter,
|
|
16
|
+
} = require('./tokens');
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Create authentication instance
|
|
@@ -75,6 +81,8 @@ function createAuth(config) {
|
|
|
75
81
|
* @param {string} [options.passwordField='password'] - Password field
|
|
76
82
|
* @param {Object} [options.session] - Session config
|
|
77
83
|
* @param {boolean} [options.rememberMe=true] - Enable remember me
|
|
84
|
+
* @param {boolean} [options.authTokens=false] - Enable auth_tokens adapter (password reset / email verify)
|
|
85
|
+
* @param {string} [options.verifiedField='email_verified_at'] - Verified timestamp column
|
|
78
86
|
* @returns {AuthManager}
|
|
79
87
|
*/
|
|
80
88
|
function quickAuth(options) {
|
|
@@ -85,6 +93,8 @@ function quickAuth(options) {
|
|
|
85
93
|
passwordField = 'password',
|
|
86
94
|
session = {},
|
|
87
95
|
rememberMe = true,
|
|
96
|
+
authTokens: enableAuthTokens = false,
|
|
97
|
+
verifiedField = 'email_verified_at',
|
|
88
98
|
} = options;
|
|
89
99
|
|
|
90
100
|
if (!db || typeof db.getRepository !== 'function') {
|
|
@@ -106,6 +116,18 @@ function quickAuth(options) {
|
|
|
106
116
|
return null;
|
|
107
117
|
},
|
|
108
118
|
|
|
119
|
+
findUserByIdentifier: async (identifier) => {
|
|
120
|
+
return await UserRepo.findOne({ [identifierField]: identifier });
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
updateUser: async (userId, data) => {
|
|
124
|
+
return await UserRepo.update(userId, data);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
identifierField,
|
|
128
|
+
passwordField,
|
|
129
|
+
verifiedField,
|
|
130
|
+
|
|
109
131
|
session: {
|
|
110
132
|
secret: process.env.SESSION_SECRET || session.secret,
|
|
111
133
|
...session,
|
|
@@ -135,6 +157,10 @@ function quickAuth(options) {
|
|
|
135
157
|
};
|
|
136
158
|
}
|
|
137
159
|
|
|
160
|
+
if (enableAuthTokens) {
|
|
161
|
+
config.authTokens = createKnexAuthTokensAdapter(db);
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
return createAuth(config);
|
|
139
165
|
}
|
|
140
166
|
|
|
@@ -196,6 +222,10 @@ module.exports = {
|
|
|
196
222
|
// Migration helpers
|
|
197
223
|
createRememberTokensTable,
|
|
198
224
|
dropRememberTokensTable,
|
|
225
|
+
createAuthTokensTable,
|
|
226
|
+
dropAuthTokensTable,
|
|
227
|
+
createKnexAuthTokensAdapter,
|
|
228
|
+
TOKEN_TYPES,
|
|
199
229
|
|
|
200
230
|
// Config
|
|
201
231
|
DEFAULT_CONFIG,
|
package/core/auth/manager.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
const { generateToken, hashToken } = require('./hash');
|
|
8
8
|
const { PolicyManager } = require('./policy');
|
|
9
|
+
const {
|
|
10
|
+
TOKEN_TYPES,
|
|
11
|
+
createAuthToken,
|
|
12
|
+
verifyAuthToken,
|
|
13
|
+
consumeAuthToken,
|
|
14
|
+
hash: hashPassword,
|
|
15
|
+
} = require('./tokens');
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
18
|
* Authentication error
|
|
@@ -79,7 +86,18 @@ class AuthManager {
|
|
|
79
86
|
|
|
80
87
|
this.findUserById = config.findUserById;
|
|
81
88
|
this.findUserByCredentials = config.findUserByCredentials;
|
|
89
|
+
this.findUserByIdentifier = config.findUserByIdentifier || null;
|
|
82
90
|
this.rememberTokens = config.rememberTokens || null;
|
|
91
|
+
this.authTokens = config.authTokens || null;
|
|
92
|
+
this.updateUser = config.updateUser || null;
|
|
93
|
+
this.identifierField = config.identifierField || 'email';
|
|
94
|
+
this.verifiedField = config.verifiedField || 'email_verified_at';
|
|
95
|
+
this.passwordField = config.passwordField || 'password';
|
|
96
|
+
this.notifications = {
|
|
97
|
+
passwordReset: config.notifications?.passwordReset || null,
|
|
98
|
+
emailVerification: config.notifications?.emailVerification || null,
|
|
99
|
+
welcome: config.notifications?.welcome || null,
|
|
100
|
+
};
|
|
83
101
|
|
|
84
102
|
// Policy manager instance
|
|
85
103
|
this.policies = new PolicyManager();
|
|
@@ -104,6 +122,14 @@ class AuthManager {
|
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
}
|
|
125
|
+
if (config.authTokens) {
|
|
126
|
+
const required = ['create', 'find', 'delete', 'deleteAllForUser'];
|
|
127
|
+
for (const method of required) {
|
|
128
|
+
if (typeof config.authTokens[method] !== 'function') {
|
|
129
|
+
throw new Error(`authTokens.${method} function is required`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
107
133
|
}
|
|
108
134
|
|
|
109
135
|
/**
|
|
@@ -408,6 +434,170 @@ class AuthManager {
|
|
|
408
434
|
beforePolicy(callback) {
|
|
409
435
|
this.policies.before(callback);
|
|
410
436
|
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Find user by login identifier (email, username, …)
|
|
440
|
+
* @param {string} identifier
|
|
441
|
+
* @returns {Promise<Object|null>}
|
|
442
|
+
*/
|
|
443
|
+
async resolveUserByIdentifier(identifier) {
|
|
444
|
+
if (typeof this.findUserByIdentifier === 'function') {
|
|
445
|
+
return this.findUserByIdentifier(identifier);
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Request password reset — always resolves (no user enumeration)
|
|
452
|
+
* @param {string} identifier
|
|
453
|
+
* @param {Object} [options]
|
|
454
|
+
* @param {number} [options.tokenTtlMs=3600000]
|
|
455
|
+
* @param {Function} [options.buildResetUrl] - (rawToken) => string
|
|
456
|
+
* @returns {Promise<{ sent: boolean }>}
|
|
457
|
+
*/
|
|
458
|
+
async requestPasswordReset(identifier, options = {}) {
|
|
459
|
+
if (!this.authTokens) {
|
|
460
|
+
throw new Error('authTokens adapter is required for password reset');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const user = await this.resolveUserByIdentifier(identifier);
|
|
464
|
+
if (!user) {
|
|
465
|
+
return { sent: false };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const tokenTtlMs = options.tokenTtlMs ?? 60 * 60 * 1000;
|
|
469
|
+
const { rawToken, expiresAt } = await createAuthToken(
|
|
470
|
+
this.authTokens,
|
|
471
|
+
TOKEN_TYPES.PASSWORD_RESET,
|
|
472
|
+
user.id,
|
|
473
|
+
tokenTtlMs
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const resetUrl = typeof options.buildResetUrl === 'function'
|
|
477
|
+
? options.buildResetUrl(rawToken)
|
|
478
|
+
: null;
|
|
479
|
+
|
|
480
|
+
if (typeof this.notifications.passwordReset === 'function') {
|
|
481
|
+
await this.notifications.passwordReset(user, {
|
|
482
|
+
token: rawToken,
|
|
483
|
+
expiresAt,
|
|
484
|
+
resetUrl,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { sent: true };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Complete password reset with token
|
|
493
|
+
* @param {string} rawToken
|
|
494
|
+
* @param {string} newPassword
|
|
495
|
+
* @returns {Promise<{ ok: true }>}
|
|
496
|
+
*/
|
|
497
|
+
async completePasswordReset(rawToken, newPassword) {
|
|
498
|
+
if (!this.authTokens) {
|
|
499
|
+
throw new Error('authTokens adapter is required for password reset');
|
|
500
|
+
}
|
|
501
|
+
if (!this.updateUser) {
|
|
502
|
+
throw new Error('updateUser function is required for password reset');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const verified = await verifyAuthToken(
|
|
506
|
+
this.authTokens,
|
|
507
|
+
TOKEN_TYPES.PASSWORD_RESET,
|
|
508
|
+
rawToken
|
|
509
|
+
);
|
|
510
|
+
if (!verified) {
|
|
511
|
+
throw new AuthenticationError('Invalid or expired reset token', 'INVALID_TOKEN');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const hashed = await hashPassword(newPassword);
|
|
515
|
+
await this.updateUser(verified.userId, { [this.passwordField]: hashed });
|
|
516
|
+
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
517
|
+
await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.PASSWORD_RESET);
|
|
518
|
+
|
|
519
|
+
return { ok: true };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Send email verification token
|
|
524
|
+
* @param {*} userId
|
|
525
|
+
* @param {Object} [options]
|
|
526
|
+
* @param {number} [options.tokenTtlMs=86400000]
|
|
527
|
+
* @param {Function} [options.buildVerifyUrl] - (rawToken) => string
|
|
528
|
+
* @returns {Promise<{ sent: boolean }>}
|
|
529
|
+
*/
|
|
530
|
+
async requestEmailVerification(userId, options = {}) {
|
|
531
|
+
if (!this.authTokens) {
|
|
532
|
+
throw new Error('authTokens adapter is required for email verification');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const user = await this.findUserById(userId);
|
|
536
|
+
if (!user) {
|
|
537
|
+
return { sent: false };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const tokenTtlMs = options.tokenTtlMs ?? 24 * 60 * 60 * 1000;
|
|
541
|
+
const { rawToken, expiresAt } = await createAuthToken(
|
|
542
|
+
this.authTokens,
|
|
543
|
+
TOKEN_TYPES.EMAIL_VERIFY,
|
|
544
|
+
user.id,
|
|
545
|
+
tokenTtlMs
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const verifyUrl = typeof options.buildVerifyUrl === 'function'
|
|
549
|
+
? options.buildVerifyUrl(rawToken)
|
|
550
|
+
: null;
|
|
551
|
+
|
|
552
|
+
if (typeof this.notifications.emailVerification === 'function') {
|
|
553
|
+
await this.notifications.emailVerification(user, {
|
|
554
|
+
token: rawToken,
|
|
555
|
+
expiresAt,
|
|
556
|
+
verifyUrl,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { sent: true };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Verify email with token
|
|
565
|
+
* @param {string} rawToken
|
|
566
|
+
* @returns {Promise<{ ok: true, userId: * }>}
|
|
567
|
+
*/
|
|
568
|
+
async verifyEmail(rawToken) {
|
|
569
|
+
if (!this.authTokens) {
|
|
570
|
+
throw new Error('authTokens adapter is required for email verification');
|
|
571
|
+
}
|
|
572
|
+
if (!this.updateUser) {
|
|
573
|
+
throw new Error('updateUser function is required for email verification');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const verified = await verifyAuthToken(
|
|
577
|
+
this.authTokens,
|
|
578
|
+
TOKEN_TYPES.EMAIL_VERIFY,
|
|
579
|
+
rawToken
|
|
580
|
+
);
|
|
581
|
+
if (!verified) {
|
|
582
|
+
throw new AuthenticationError('Invalid or expired verification token', 'INVALID_TOKEN');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
await this.updateUser(verified.userId, { [this.verifiedField]: new Date() });
|
|
586
|
+
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
587
|
+
|
|
588
|
+
return { ok: true, userId: verified.userId };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Send welcome notification if configured
|
|
593
|
+
* @param {Object} user
|
|
594
|
+
* @returns {Promise<void>}
|
|
595
|
+
*/
|
|
596
|
+
async sendWelcomeNotification(user) {
|
|
597
|
+
if (typeof this.notifications.welcome === 'function') {
|
|
598
|
+
await this.notifications.welcome(user, {});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
411
601
|
}
|
|
412
602
|
|
|
413
603
|
module.exports = {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth token types and helpers (password reset, email verification)
|
|
3
|
+
* @module core/auth/tokens
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { generateToken, hashToken, hash } = require('./hash');
|
|
7
|
+
|
|
8
|
+
const TOKEN_TYPES = {
|
|
9
|
+
PASSWORD_RESET: 'password_reset',
|
|
10
|
+
EMAIL_VERIFY: 'email_verify',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('knex').Knex} knex
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
async function createAuthTokensTable(knex) {
|
|
18
|
+
const exists = await knex.schema.hasTable('auth_tokens');
|
|
19
|
+
if (exists) return;
|
|
20
|
+
|
|
21
|
+
await knex.schema.createTable('auth_tokens', (table) => {
|
|
22
|
+
table.bigIncrements('id').primary();
|
|
23
|
+
table.bigInteger('user_id').unsigned().notNullable();
|
|
24
|
+
table.string('token', 64).notNullable().unique();
|
|
25
|
+
table.string('type', 32).notNullable();
|
|
26
|
+
table.timestamp('expires_at').notNullable();
|
|
27
|
+
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
28
|
+
|
|
29
|
+
table.index(['user_id', 'type']);
|
|
30
|
+
table.index('token');
|
|
31
|
+
table.index('expires_at');
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('knex').Knex} knex
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function dropAuthTokensTable(knex) {
|
|
40
|
+
await knex.schema.dropTableIfExists('auth_tokens');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create Knex auth_tokens adapter
|
|
45
|
+
* @param {Object} db - Database with knex
|
|
46
|
+
* @returns {Object}
|
|
47
|
+
*/
|
|
48
|
+
function createKnexAuthTokensAdapter(db) {
|
|
49
|
+
const knex = db.knex || db;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
create: async (userId, tokenHash, type, expiresAt) => {
|
|
53
|
+
await knex('auth_tokens').insert({
|
|
54
|
+
user_id: userId,
|
|
55
|
+
token: tokenHash,
|
|
56
|
+
type,
|
|
57
|
+
expires_at: expiresAt,
|
|
58
|
+
created_at: new Date(),
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
find: async (tokenHash, type) => {
|
|
62
|
+
return knex('auth_tokens').where({ token: tokenHash, type }).first();
|
|
63
|
+
},
|
|
64
|
+
delete: async (tokenHash) => {
|
|
65
|
+
await knex('auth_tokens').where({ token: tokenHash }).delete();
|
|
66
|
+
},
|
|
67
|
+
deleteAllForUser: async (userId, type) => {
|
|
68
|
+
const q = knex('auth_tokens').where({ user_id: userId });
|
|
69
|
+
if (type) q.where({ type });
|
|
70
|
+
await q.delete();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Object} authTokensAdapter
|
|
77
|
+
* @param {string} type
|
|
78
|
+
* @param {*} userId
|
|
79
|
+
* @param {number} ttlMs
|
|
80
|
+
* @returns {Promise<{ rawToken: string, expiresAt: Date }>}
|
|
81
|
+
*/
|
|
82
|
+
async function createAuthToken(authTokensAdapter, type, userId, ttlMs) {
|
|
83
|
+
const rawToken = generateToken(32);
|
|
84
|
+
const tokenHash = hashToken(rawToken);
|
|
85
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
86
|
+
|
|
87
|
+
await authTokensAdapter.deleteAllForUser(userId, type);
|
|
88
|
+
await authTokensAdapter.create(userId, tokenHash, type, expiresAt);
|
|
89
|
+
|
|
90
|
+
return { rawToken, expiresAt };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Object} authTokensAdapter
|
|
95
|
+
* @param {string} type
|
|
96
|
+
* @param {string} rawToken
|
|
97
|
+
* @returns {Promise<{ userId: *, expiresAt: Date }|null>}
|
|
98
|
+
*/
|
|
99
|
+
async function verifyAuthToken(authTokensAdapter, type, rawToken) {
|
|
100
|
+
if (!rawToken) return null;
|
|
101
|
+
|
|
102
|
+
const tokenHash = hashToken(rawToken);
|
|
103
|
+
const record = await authTokensAdapter.find(tokenHash, type);
|
|
104
|
+
if (!record) return null;
|
|
105
|
+
|
|
106
|
+
if (new Date(record.expires_at) < new Date()) {
|
|
107
|
+
await authTokensAdapter.delete(tokenHash);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
userId: record.user_id,
|
|
113
|
+
expiresAt: new Date(record.expires_at),
|
|
114
|
+
tokenHash,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Object} authTokensAdapter
|
|
120
|
+
* @param {string} tokenHash
|
|
121
|
+
*/
|
|
122
|
+
async function consumeAuthToken(authTokensAdapter, tokenHash) {
|
|
123
|
+
await authTokensAdapter.delete(tokenHash);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
TOKEN_TYPES,
|
|
128
|
+
createAuthTokensTable,
|
|
129
|
+
dropAuthTokensTable,
|
|
130
|
+
createKnexAuthTokensAdapter,
|
|
131
|
+
createAuthToken,
|
|
132
|
+
verifyAuthToken,
|
|
133
|
+
consumeAuthToken,
|
|
134
|
+
hash,
|
|
135
|
+
};
|