webspresso 0.0.80 → 0.0.82

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 (38) hide show
  1. package/README.md +133 -15
  2. package/bin/commands/doctor.js +83 -0
  3. package/bin/commands/favicon-generate.js +3 -1
  4. package/bin/commands/skill.js +6 -4
  5. package/core/auth/hash.js +1 -1
  6. package/core/auth/index.js +30 -0
  7. package/core/auth/manager.js +190 -0
  8. package/core/auth/tokens.js +135 -0
  9. package/core/kernel/app.js +7 -1
  10. package/index.d.ts +32 -0
  11. package/index.js +2 -0
  12. package/package.json +5 -2
  13. package/plugins/admin-panel/app.js +43 -1
  14. package/plugins/admin-panel/core/admin-module.js +156 -16
  15. package/plugins/admin-panel/core/registry.js +3 -0
  16. package/plugins/admin-panel/field-renderers/file-upload.js +48 -2
  17. package/plugins/admin-panel/index.js +17 -1
  18. package/plugins/csrf/index.js +222 -0
  19. package/plugins/email/admin-component.js +226 -0
  20. package/plugins/email/api-handlers.js +139 -0
  21. package/plugins/email/auth-bridge.js +209 -0
  22. package/plugins/email/bundled-templates.js +68 -0
  23. package/plugins/email/fs-env.js +101 -0
  24. package/plugins/email/index.js +186 -0
  25. package/plugins/email/manifest.js +25 -0
  26. package/plugins/email/migration-template.js +40 -0
  27. package/plugins/email/purge.js +28 -0
  28. package/plugins/email/render.js +154 -0
  29. package/plugins/email/service.js +244 -0
  30. package/plugins/email/template-registry.js +134 -0
  31. package/plugins/email/templates/auth-email-verify.mjml +13 -0
  32. package/plugins/email/templates/auth-password-reset.mjml +13 -0
  33. package/plugins/email/templates/auth-welcome.mjml +11 -0
  34. package/plugins/email/transport.js +71 -0
  35. package/plugins/index.js +4 -0
  36. package/src/file-router.js +75 -0
  37. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
  38. package/templates/skills/webspresso-usage/SKILL.md +2 -1
package/README.md CHANGED
@@ -245,9 +245,10 @@ webspresso doctor --strict
245
245
 
246
246
  ### `webspresso skill`
247
247
 
248
- Scaffold a **Cursor Agent Skill**: creates `.cursor/skills/<name>/SKILL.md` with valid YAML frontmatter (`name`, `description`) and a short markdown template for AI tooling. Use `--global` to write under `~/.cursor/skills/` instead of the current project.
248
+ Scaffold an **Agent Skill**: creates `.agents/skills/<name>/SKILL.md` with valid YAML frontmatter (`name`, `description`) and a short markdown template for AI tooling. Use `--global` to write under `~/.agents/skills/` instead of the current project.
249
+
250
+ **Bundled preset:** `--preset webspresso` copies **`SKILL.md`** (short index), **`REFERENCE-framework.md`** (SSR `createApp`, routes, ORM, auth, plugins, CLI), and **`REFERENCE-kernel.md`** (`kernel` event bus / flows) into **`.agents/skills/webspresso-usage/`** — no prompts.
249
251
 
250
- **Bundled preset:** `--preset webspresso` copies **`SKILL.md`** (short index), **`REFERENCE-framework.md`** (SSR `createApp`, routes, ORM, auth, plugins, CLI), and **`REFERENCE-kernel.md`** (`kernel` event bus / flows) into **`.cursor/skills/webspresso-usage/`** — no prompts.
251
252
 
252
253
  ```bash
253
254
  webspresso skill my-workflow
@@ -805,16 +806,18 @@ Options:
805
806
  - `userManagement` - Site-user admin UI (`enabled`, `model` matching ORM user table, optional `fields` map). SPA routes: `/_admin/users`, `/_admin/users/new`, …; APIs: `/_admin/api/users*`. Admin staff still use **`admin_users`** / `/_admin` login; this is separate from **`req.user`** on the public site
806
807
  - `configure` - Callback `(registry) => void` for manual setup
807
808
 
808
- See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.cursor/skills/webspresso-usage/REFERENCE-framework.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
809
+ See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.agents/skills/webspresso-usage/REFERENCE-framework.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
810
+
809
811
 
810
812
  **Custom Admin Pages (registerModule):**
811
813
 
812
- Plugins can add custom admin pages using `registerModule` in `onRoutesReady`:
814
+ Plugins can add custom admin pages using `registerModule` (or `registerPageDir`) in `onRoutesReady`:
813
815
 
814
816
  ```javascript
815
817
  // In your plugin's onRoutesReady(ctx)
816
818
  const adminApi = ctx.usePlugin('admin-panel');
817
819
  if (adminApi) {
820
+ // Option 1: File-based registration with auto Layout wrapping
818
821
  adminApi.registerModule({
819
822
  id: 'my-module',
820
823
 
@@ -824,7 +827,8 @@ if (adminApi) {
824
827
  path: '/reports',
825
828
  icon: 'chart',
826
829
  description: 'View reports',
827
- component: `window.__customPages["reports"] = { view: () => m("div", "My Report") };`, // Mithril.js
830
+ componentFile: path.join(__dirname, 'admin/reports.js'), // JS component file
831
+ layout: true, // Auto-wraps view in Admin Layout (default: true)
828
832
  }],
829
833
 
830
834
  menu: [{ id: 'reports', label: 'Reports', path: '/reports', icon: 'chart', order: 5 }],
@@ -835,15 +839,10 @@ if (adminApi) {
835
839
  { method: 'get', path: '/summary', handler: getSummaryHandler, auth: true },
836
840
  ],
837
841
  },
838
-
839
- widgets: [{
840
- id: 'reports-widget',
841
- title: 'Quick Stats',
842
- dataLoader: async () => ({ count: 42 }),
843
- }],
844
-
845
- menuGroups: [{ id: 'analytics', label: 'Analytics', order: 2 }],
846
842
  });
843
+
844
+ // Option 2: Folder-based auto registration (dir containing page.json + component.js)
845
+ adminApi.registerPageDir(path.join(__dirname, 'admin/pages'));
847
846
  }
848
847
  ```
849
848
 
@@ -851,13 +850,41 @@ if (adminApi) {
851
850
  | Field | Description |
852
851
  |-------|-------------|
853
852
  | `id` | Unique module identifier (required) |
854
- | `pages` | Custom admin pages (each: `id`, `title`, `path`, `icon`, `description`, optional `component`) |
853
+ | `pages` | Custom admin pages (`id`, `title`, `path`, `icon`, `description`, `componentFile`, `component`, `layout`) |
854
+ | `pagesDir` | Path to directory containing page folders/configs for automatic discovery |
855
855
  | `menu` | Sidebar menu items (`id`, `label`, `path`, `icon`, `order`) |
856
856
  | `menuGroups` | Collapsible menu groups (`id`, `label`, `order`) |
857
857
  | `api` | API routes (`prefix`, `routes`: `method`, `path`, `handler`, `auth`) |
858
858
  | `widgets` | Dashboard widgets (`id`, `title`, `dataLoader`) |
859
859
 
860
- For pages with `component`: provide Mithril.js code that assigns to `window.__customPages[pageId]`. Without `component`, the page shows a static placeholder.
860
+ Pages support `componentFile` (path to a `.js` file exporting/returning a Mithril component), `pagesDir` (directory auto-discovery), or `component` (code string). By default (`layout: true`), page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
861
+
862
+ **Example component file (`admin/reports.js`):**
863
+
864
+ ```javascript
865
+ // Clean Mithril.js component object (no manual m(Layout, ...) needed!)
866
+ ({
867
+ oninit: async (vnode) => {
868
+ vnode.state.data = [];
869
+ try {
870
+ const res = await api.get('/reports/summary');
871
+ vnode.state.data = res.data || [];
872
+ m.redraw();
873
+ } catch (err) {
874
+ console.error('Failed to load report data:', err);
875
+ }
876
+ },
877
+ view: (vnode) => {
878
+ const data = vnode.state.data || [];
879
+ return m('div.space-y-4', [
880
+ m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'Report Summary'),
881
+ m('p.text-gray-600.dark:text-slate-400', `Total records: ${data.length}`),
882
+ ]);
883
+ }
884
+ })
885
+ ```
886
+
887
+
861
888
 
862
889
  **Manual registry API** (alternative to registerModule):
863
890
 
@@ -982,6 +1009,57 @@ const { app } = createApp({
982
1009
 
983
1010
  Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
984
1011
 
1012
+ **Email plugin:**
1013
+ - Sends email via **Nodemailer** with **MJML** templates (`{{variable}}` interpolation)
1014
+ - Template sources: `templatesDir` (`*.mjml`, **Node.js only** — skipped on edge/workers), `templates` map with inline `{ mjml }`, runtime `registerTemplate`, or inline `send({ mjml, html, data })`
1015
+ - Transport: `transport`, `smtp`, or env (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`, `MAIL_FROM`)
1016
+ - Optional DB logging to `email_logs` when `db` is passed; prune with `webspresso email:prune --days 90`
1017
+ - **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.
1018
+ - Admin page at `/_admin/email` (test send, preview, SMTP verify, logs) when `adminPanelPlugin` is loaded
1019
+ - 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`
1020
+
1021
+ ```javascript
1022
+ const { quickAuth, createAuthTokensTable } = require('webspresso/core/auth');
1023
+ const { adminPanelPlugin, emailPlugin } = require('webspresso/plugins');
1024
+
1025
+ const auth = quickAuth({ db, authTokens: true });
1026
+ await createAuthTokensTable(db.knex);
1027
+
1028
+ const { app, pluginManager } = createApp({
1029
+ pagesDir: './pages',
1030
+ db,
1031
+ auth,
1032
+ plugins: [
1033
+ emailPlugin({
1034
+ db,
1035
+ auth,
1036
+ templatesDir: './emails',
1037
+ smtp: {
1038
+ host: process.env.SMTP_HOST,
1039
+ port: 587,
1040
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
1041
+ },
1042
+ defaults: { from: process.env.MAIL_FROM },
1043
+ authEmails: {
1044
+ enabled: true,
1045
+ baseUrl: process.env.BASE_URL,
1046
+ registerRoutes: true,
1047
+ },
1048
+ }),
1049
+ adminPanelPlugin({ db }),
1050
+ ],
1051
+ });
1052
+
1053
+ // From a route handler or another plugin:
1054
+ await pluginManager.getPluginAPI('email').sendTemplate('welcome', {
1055
+ to: 'user@example.com',
1056
+ subject: 'Welcome',
1057
+ data: { name: 'Ali' },
1058
+ });
1059
+ ```
1060
+
1061
+ Programmatic API: `ctx.usePlugin('email')` exposes `send`, `sendTemplate`, `preview`, `registerTemplate`, `listTemplates`, `verifyConnection`, `queryLogs`, `purgeLogs`, `getMigrationTemplate()`.
1062
+
985
1063
  **ORM cache admin plugin:**
986
1064
  - Depends on **`adminPanelPlugin`** and a database instance created with **`createDatabase({ cache: true | { … } })`** so **`db.cache`** is non-null
987
1065
  - Registers an **ORM Cache** admin page (metrics, full purge, per-model invalidation, reset counters) backed by **`db.cache`**
@@ -2263,6 +2341,46 @@ const app = createApp({
2263
2341
 
2264
2342
  In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
2265
2343
 
2344
+ ### CSRF protection plugin
2345
+
2346
+ 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.
2347
+
2348
+ **Setup:**
2349
+
2350
+ ```javascript
2351
+ const { createApp, csrfPlugin } = require('webspresso');
2352
+
2353
+ const app = createApp({
2354
+ plugins: [
2355
+ csrfPlugin({
2356
+ global: true, // Enable globally for all routes
2357
+ cookie: true, // Use cookie-based storage (default uses session if present)
2358
+ ignorePaths: ['/api/webhook'], // Skip CSRF verification for specific routes
2359
+ }),
2360
+ ],
2361
+ });
2362
+ ```
2363
+
2364
+ **Route-Specific Middleware:**
2365
+
2366
+ If `global: false` (default), you can protect specific routes by applying the named route middleware `csrf` in your route configuration:
2367
+
2368
+ ```javascript
2369
+ // pages/submit.js
2370
+ module.exports = {
2371
+ middleware: ['csrf'],
2372
+ post(req, res) {
2373
+ res.send('Success!');
2374
+ }
2375
+ };
2376
+ ```
2377
+
2378
+ **Template Helpers:**
2379
+
2380
+ The plugin registers two Nunjucks template helpers:
2381
+ - `{{ fsy.csrfToken() }}`: Returns the raw token string (useful for API fetch/headers).
2382
+ - `{{ fsy.csrfInput() }}`: Renders a hidden input field: `<input type="hidden" name="_csrf" value="...">`.
2383
+
2266
2384
  ## Development
2267
2385
 
2268
2386
  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.
@@ -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 = require('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;
@@ -20,9 +20,10 @@ function validateSkillName(name) {
20
20
  }
21
21
 
22
22
  function skillDir(base, skillName) {
23
- return path.join(base, '.cursor', 'skills', skillName);
23
+ return path.join(base, '.agents', 'skills', skillName);
24
24
  }
25
25
 
26
+
26
27
  function defaultDescription(skillName) {
27
28
  const label = skillName.replace(/-/g, ' ');
28
29
  return `Guides the agent through tasks for ${label}. Use when the user works on ${label} or asks about related workflows.`;
@@ -72,14 +73,15 @@ Example prompt or command
72
73
  function registerCommand(program) {
73
74
  program
74
75
  .command('skill [name]')
75
- .description('Create an Agent Skill folder with SKILL.md (Cursor / AI tools)')
76
- .option('-g, --global', 'Write to ~/.cursor/skills/ instead of ./.cursor/skills/')
76
+ .description('Create an Agent Skill folder with SKILL.md')
77
+ .option('-g, --global', 'Write to ~/.agents/skills/ instead of ./.agents/skills/')
77
78
  .option('-d, --description <text>', 'Skill description (for YAML frontmatter)')
78
79
  .option('-f, --force', 'Overwrite existing SKILL.md')
79
80
  .option(
80
81
  '-p, --preset <name>',
81
- 'Install bundled skill: webspresso → agent reference (SKILL.md + REFERENCE-*.md in .cursor/skills/webspresso-usage/)',
82
+ 'Install bundled skill: webspresso → agent reference (SKILL.md + REFERENCE-*.md in .agents/skills/webspresso-usage/)',
82
83
  )
84
+
83
85
  .action(async (nameArg, options) => {
84
86
  const presetKey = options.preset ? String(options.preset).trim().toLowerCase() : '';
85
87
 
package/core/auth/hash.js CHANGED
@@ -16,7 +16,7 @@ try {
16
16
  * Default bcrypt cost factor
17
17
  * Higher = more secure but slower
18
18
  */
19
- const DEFAULT_ROUNDS = 12;
19
+ const DEFAULT_ROUNDS = process.env.NODE_ENV === 'test' ? 1 : 12;
20
20
 
21
21
  /**
22
22
  * Hash a password using bcrypt
@@ -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,
@@ -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 = {