millas 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -0
- package/bin/millas.js +6 -0
- package/package.json +56 -0
- package/src/admin/Admin.js +617 -0
- package/src/admin/index.js +13 -0
- package/src/admin/resources/AdminResource.js +317 -0
- package/src/auth/Auth.js +254 -0
- package/src/auth/AuthController.js +188 -0
- package/src/auth/AuthMiddleware.js +67 -0
- package/src/auth/Hasher.js +51 -0
- package/src/auth/JwtDriver.js +74 -0
- package/src/auth/RoleMiddleware.js +44 -0
- package/src/cache/Cache.js +231 -0
- package/src/cache/drivers/FileDriver.js +152 -0
- package/src/cache/drivers/MemoryDriver.js +158 -0
- package/src/cache/drivers/NullDriver.js +27 -0
- package/src/cache/index.js +8 -0
- package/src/cli.js +27 -0
- package/src/commands/make.js +61 -0
- package/src/commands/migrate.js +174 -0
- package/src/commands/new.js +50 -0
- package/src/commands/queue.js +92 -0
- package/src/commands/route.js +93 -0
- package/src/commands/serve.js +50 -0
- package/src/container/Application.js +177 -0
- package/src/container/Container.js +281 -0
- package/src/container/index.js +13 -0
- package/src/controller/Controller.js +367 -0
- package/src/errors/HttpError.js +29 -0
- package/src/events/Event.js +39 -0
- package/src/events/EventEmitter.js +151 -0
- package/src/events/Listener.js +46 -0
- package/src/events/index.js +15 -0
- package/src/index.js +93 -0
- package/src/mail/Mail.js +210 -0
- package/src/mail/MailMessage.js +196 -0
- package/src/mail/TemplateEngine.js +150 -0
- package/src/mail/drivers/LogDriver.js +36 -0
- package/src/mail/drivers/MailgunDriver.js +84 -0
- package/src/mail/drivers/SendGridDriver.js +97 -0
- package/src/mail/drivers/SmtpDriver.js +67 -0
- package/src/mail/index.js +19 -0
- package/src/middleware/AuthMiddleware.js +46 -0
- package/src/middleware/CorsMiddleware.js +59 -0
- package/src/middleware/LogMiddleware.js +61 -0
- package/src/middleware/Middleware.js +36 -0
- package/src/middleware/MiddlewarePipeline.js +94 -0
- package/src/middleware/ThrottleMiddleware.js +61 -0
- package/src/orm/drivers/DatabaseManager.js +135 -0
- package/src/orm/fields/index.js +132 -0
- package/src/orm/index.js +19 -0
- package/src/orm/migration/MigrationRunner.js +216 -0
- package/src/orm/migration/ModelInspector.js +338 -0
- package/src/orm/migration/SchemaBuilder.js +173 -0
- package/src/orm/model/Model.js +371 -0
- package/src/orm/query/QueryBuilder.js +197 -0
- package/src/providers/AdminServiceProvider.js +40 -0
- package/src/providers/AuthServiceProvider.js +53 -0
- package/src/providers/CacheStorageServiceProvider.js +71 -0
- package/src/providers/DatabaseServiceProvider.js +45 -0
- package/src/providers/EventServiceProvider.js +34 -0
- package/src/providers/MailServiceProvider.js +51 -0
- package/src/providers/ProviderRegistry.js +82 -0
- package/src/providers/QueueServiceProvider.js +52 -0
- package/src/providers/ServiceProvider.js +45 -0
- package/src/queue/Job.js +135 -0
- package/src/queue/Queue.js +147 -0
- package/src/queue/drivers/DatabaseDriver.js +194 -0
- package/src/queue/drivers/SyncDriver.js +72 -0
- package/src/queue/index.js +16 -0
- package/src/queue/workers/QueueWorker.js +140 -0
- package/src/router/MiddlewareRegistry.js +82 -0
- package/src/router/Route.js +255 -0
- package/src/router/RouteGroup.js +19 -0
- package/src/router/RouteRegistry.js +55 -0
- package/src/router/Router.js +138 -0
- package/src/router/index.js +15 -0
- package/src/scaffold/generator.js +34 -0
- package/src/scaffold/maker.js +272 -0
- package/src/scaffold/templates.js +350 -0
- package/src/storage/Storage.js +170 -0
- package/src/storage/drivers/LocalDriver.js +215 -0
- package/src/storage/index.js +6 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AdminResource
|
|
5
|
+
*
|
|
6
|
+
* Defines how a Model is represented in the admin panel.
|
|
7
|
+
* Subclass this and override properties/methods to customise.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* class UserResource extends AdminResource {
|
|
11
|
+
* static model = User;
|
|
12
|
+
* static label = 'Users';
|
|
13
|
+
* static icon = '👤';
|
|
14
|
+
* static perPage = 25;
|
|
15
|
+
* static searchable = ['name', 'email'];
|
|
16
|
+
* static sortable = ['id', 'name', 'email', 'created_at'];
|
|
17
|
+
*
|
|
18
|
+
* static fields() {
|
|
19
|
+
* return [
|
|
20
|
+
* AdminField.id('id'),
|
|
21
|
+
* AdminField.text('name').label('Full Name').sortable(),
|
|
22
|
+
* AdminField.email('email').sortable(),
|
|
23
|
+
* AdminField.badge('role').colors({ admin: 'red', user: 'blue' }),
|
|
24
|
+
* AdminField.boolean('active'),
|
|
25
|
+
* AdminField.datetime('created_at').label('Registered'),
|
|
26
|
+
* ];
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* static filters() {
|
|
30
|
+
* return [
|
|
31
|
+
* AdminFilter.select('role', ['admin', 'user']),
|
|
32
|
+
* AdminFilter.boolean('active'),
|
|
33
|
+
* AdminFilter.dateRange('created_at'),
|
|
34
|
+
* ];
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* Admin.register(UserResource);
|
|
39
|
+
*/
|
|
40
|
+
class AdminResource {
|
|
41
|
+
/** @type {typeof import('../orm/model/Model')} The Millas Model class */
|
|
42
|
+
static model = null;
|
|
43
|
+
|
|
44
|
+
/** Display name (plural) shown in sidebar and page title */
|
|
45
|
+
static label = null;
|
|
46
|
+
|
|
47
|
+
/** Singular label */
|
|
48
|
+
static labelSingular = null;
|
|
49
|
+
|
|
50
|
+
/** Emoji or icon string for the sidebar */
|
|
51
|
+
static icon = '📋';
|
|
52
|
+
|
|
53
|
+
/** Records per page */
|
|
54
|
+
static perPage = 20;
|
|
55
|
+
|
|
56
|
+
/** Columns to search across (SQL LIKE) */
|
|
57
|
+
static searchable = [];
|
|
58
|
+
|
|
59
|
+
/** Columns users can click to sort */
|
|
60
|
+
static sortable = ['id', 'created_at'];
|
|
61
|
+
|
|
62
|
+
/** Whether to show a Create button */
|
|
63
|
+
static canCreate = true;
|
|
64
|
+
|
|
65
|
+
/** Whether to show Edit buttons */
|
|
66
|
+
static canEdit = true;
|
|
67
|
+
|
|
68
|
+
/** Whether to show Delete buttons */
|
|
69
|
+
static canDelete = true;
|
|
70
|
+
|
|
71
|
+
/** URL-safe slug used in routes */
|
|
72
|
+
static get slug() {
|
|
73
|
+
return (this.label || this.model?.name || 'resource')
|
|
74
|
+
.toLowerCase().replace(/\s+/g, '-');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Define the fields shown in list + detail views.
|
|
79
|
+
* Must return an array of AdminField instances.
|
|
80
|
+
*/
|
|
81
|
+
static fields() {
|
|
82
|
+
// Default: infer from model.fields if available
|
|
83
|
+
if (!this.model?.fields) return [];
|
|
84
|
+
return Object.entries(this.model.fields).map(([name, def]) =>
|
|
85
|
+
AdminField.fromModelField(name, def)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Define filter controls shown in the sidebar.
|
|
91
|
+
* Must return an array of AdminFilter instances.
|
|
92
|
+
*/
|
|
93
|
+
static filters() {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Override to customise how records are fetched.
|
|
99
|
+
* Receives { page, perPage, search, sort, order, filters }
|
|
100
|
+
*/
|
|
101
|
+
static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {} } = {}) {
|
|
102
|
+
const limit = perPage || this.perPage;
|
|
103
|
+
const offset = (page - 1) * limit;
|
|
104
|
+
const db = this.model._db();
|
|
105
|
+
|
|
106
|
+
let query = db.orderBy(sort, order);
|
|
107
|
+
|
|
108
|
+
// Search
|
|
109
|
+
if (search && this.searchable.length) {
|
|
110
|
+
query = query.where(function () {
|
|
111
|
+
for (const col of this.constructor.searchable || []) {
|
|
112
|
+
this.orWhere(col, 'like', `%${search}%`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Filters
|
|
118
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
119
|
+
if (value !== '' && value !== null && value !== undefined) {
|
|
120
|
+
query = query.where(key, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [rows, countResult] = await Promise.all([
|
|
125
|
+
query.clone().limit(limit).offset(offset),
|
|
126
|
+
query.clone().count('* as count').first(),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const total = Number(countResult?.count || 0);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
data: rows.map(r => this.model._hydrate(r)),
|
|
133
|
+
total,
|
|
134
|
+
page: Number(page),
|
|
135
|
+
perPage: limit,
|
|
136
|
+
lastPage: Math.ceil(total / limit),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Fetch a single record by id.
|
|
142
|
+
*/
|
|
143
|
+
static async fetchOne(id) {
|
|
144
|
+
return this.model.findOrFail(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a new record from form data.
|
|
149
|
+
*/
|
|
150
|
+
static async create(data) {
|
|
151
|
+
return this.model.create(this._sanitise(data));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Update a record from form data.
|
|
156
|
+
*/
|
|
157
|
+
static async update(id, data) {
|
|
158
|
+
const record = await this.model.findOrFail(id);
|
|
159
|
+
return record.update(this._sanitise(data));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Delete a record.
|
|
164
|
+
*/
|
|
165
|
+
static async destroy(id) {
|
|
166
|
+
return this.model.destroy(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
static _sanitise(data) {
|
|
172
|
+
// Remove private/system fields
|
|
173
|
+
const clean = { ...data };
|
|
174
|
+
delete clean.id;
|
|
175
|
+
delete clean._method;
|
|
176
|
+
delete clean._token;
|
|
177
|
+
return clean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
static _getLabel() {
|
|
181
|
+
return this.label || (this.model?.name ? this.model.name + 's' : 'Records');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
static _getLabelSingular() {
|
|
185
|
+
return this.labelSingular || this.model?.name || 'Record';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── AdminField ────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
class AdminField {
|
|
192
|
+
constructor(name, type) {
|
|
193
|
+
this._name = name;
|
|
194
|
+
this._type = type;
|
|
195
|
+
this._label = name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
196
|
+
this._sortable = false;
|
|
197
|
+
this._hidden = false;
|
|
198
|
+
this._listOnly = false;
|
|
199
|
+
this._detailOnly = false;
|
|
200
|
+
this._colors = {};
|
|
201
|
+
this._format = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Field types ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
static id(name = 'id') { return new AdminField(name, 'id'); }
|
|
207
|
+
static text(name) { return new AdminField(name, 'text'); }
|
|
208
|
+
static email(name) { return new AdminField(name, 'email'); }
|
|
209
|
+
static number(name) { return new AdminField(name, 'number'); }
|
|
210
|
+
static boolean(name) { return new AdminField(name, 'boolean'); }
|
|
211
|
+
static badge(name) { return new AdminField(name, 'badge'); }
|
|
212
|
+
static datetime(name) { return new AdminField(name, 'datetime'); }
|
|
213
|
+
static date(name) { return new AdminField(name, 'date'); }
|
|
214
|
+
static image(name) { return new AdminField(name, 'image'); }
|
|
215
|
+
static textarea(name) { return new AdminField(name, 'textarea'); }
|
|
216
|
+
static select(name, options) { const f = new AdminField(name, 'select'); f._options = options; return f; }
|
|
217
|
+
static password(name) { return new AdminField(name, 'password'); }
|
|
218
|
+
static json(name) { return new AdminField(name, 'json'); }
|
|
219
|
+
|
|
220
|
+
// ─── Fluent modifiers ───────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
label(l) { this._label = l; return this; }
|
|
223
|
+
sortable() { this._sortable = true; return this; }
|
|
224
|
+
hidden() { this._hidden = true; return this; }
|
|
225
|
+
listOnly() { this._listOnly = true; return this; }
|
|
226
|
+
detailOnly() { this._detailOnly = true; return this; }
|
|
227
|
+
colors(map) { this._colors = map; return this; }
|
|
228
|
+
format(fn) { this._format = fn; return this; }
|
|
229
|
+
placeholder(p) { this._placeholder = p; return this; }
|
|
230
|
+
help(h) { this._help = h; return this; }
|
|
231
|
+
readonly() { this._readonly = true; return this; }
|
|
232
|
+
|
|
233
|
+
// ─── Serialise for rendering ─────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
toJSON() {
|
|
236
|
+
return {
|
|
237
|
+
name: this._name,
|
|
238
|
+
type: this._type,
|
|
239
|
+
label: this._label,
|
|
240
|
+
sortable: this._sortable,
|
|
241
|
+
hidden: this._hidden,
|
|
242
|
+
listOnly: this._listOnly,
|
|
243
|
+
detailOnly: this._detailOnly,
|
|
244
|
+
colors: this._colors,
|
|
245
|
+
options: this._options,
|
|
246
|
+
placeholder: this._placeholder,
|
|
247
|
+
help: this._help,
|
|
248
|
+
readonly: this._readonly,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Format a raw value for display.
|
|
254
|
+
*/
|
|
255
|
+
display(value) {
|
|
256
|
+
if (this._format) return this._format(value);
|
|
257
|
+
if (value === null || value === undefined) return '—';
|
|
258
|
+
if (this._type === 'boolean') return value ? '✓' : '✗';
|
|
259
|
+
if (this._type === 'datetime' && value) {
|
|
260
|
+
return new Date(value).toLocaleString();
|
|
261
|
+
}
|
|
262
|
+
if (this._type === 'date' && value) {
|
|
263
|
+
return new Date(value).toLocaleDateString();
|
|
264
|
+
}
|
|
265
|
+
if (this._type === 'password') return '••••••••';
|
|
266
|
+
if (this._type === 'json') return JSON.stringify(value);
|
|
267
|
+
return String(value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
static fromModelField(name, fieldDef) {
|
|
271
|
+
const typeMap = {
|
|
272
|
+
id: () => AdminField.id(name),
|
|
273
|
+
string: () => AdminField.text(name),
|
|
274
|
+
text: () => AdminField.textarea(name),
|
|
275
|
+
integer: () => AdminField.number(name),
|
|
276
|
+
bigInteger:() => AdminField.number(name),
|
|
277
|
+
float: () => AdminField.number(name),
|
|
278
|
+
decimal: () => AdminField.number(name),
|
|
279
|
+
boolean: () => AdminField.boolean(name),
|
|
280
|
+
timestamp: () => AdminField.datetime(name),
|
|
281
|
+
date: () => AdminField.date(name),
|
|
282
|
+
enum: () => AdminField.select(name, fieldDef.enumValues || []),
|
|
283
|
+
json: () => AdminField.json(name),
|
|
284
|
+
};
|
|
285
|
+
const fn = typeMap[fieldDef.type] || (() => AdminField.text(name));
|
|
286
|
+
return fn();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── AdminFilter ───────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
class AdminFilter {
|
|
293
|
+
constructor(name, type) {
|
|
294
|
+
this._name = name;
|
|
295
|
+
this._type = type;
|
|
296
|
+
this._label = name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
static text(name) { return new AdminFilter(name, 'text'); }
|
|
300
|
+
static select(name, options) { const f = new AdminFilter(name, 'select'); f._options = options; return f; }
|
|
301
|
+
static boolean(name) { return new AdminFilter(name, 'boolean'); }
|
|
302
|
+
static dateRange(name) { return new AdminFilter(name, 'dateRange'); }
|
|
303
|
+
static number(name) { return new AdminFilter(name, 'number'); }
|
|
304
|
+
|
|
305
|
+
label(l) { this._label = l; return this; }
|
|
306
|
+
|
|
307
|
+
toJSON() {
|
|
308
|
+
return {
|
|
309
|
+
name: this._name,
|
|
310
|
+
type: this._type,
|
|
311
|
+
label: this._label,
|
|
312
|
+
options: this._options,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = { AdminResource, AdminField, AdminFilter };
|
package/src/auth/Auth.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Hasher = require('./Hasher');
|
|
4
|
+
const JwtDriver = require('./JwtDriver');
|
|
5
|
+
const HttpError = require('../errors/HttpError');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auth
|
|
9
|
+
*
|
|
10
|
+
* The primary authentication facade.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const { Auth } = require('millas/src');
|
|
14
|
+
*
|
|
15
|
+
* // Register a new user
|
|
16
|
+
* const user = await Auth.register({
|
|
17
|
+
* name: 'Alice',
|
|
18
|
+
* email: 'alice@example.com',
|
|
19
|
+
* password: 'secret123',
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Login
|
|
23
|
+
* const { user, token } = await Auth.login('alice@example.com', 'secret123');
|
|
24
|
+
*
|
|
25
|
+
* // Verify a token
|
|
26
|
+
* const payload = Auth.verify(token);
|
|
27
|
+
*
|
|
28
|
+
* // Get logged-in user from a request
|
|
29
|
+
* const user = await Auth.user(req);
|
|
30
|
+
*
|
|
31
|
+
* // Check password
|
|
32
|
+
* const ok = await Auth.checkPassword('plain', user.password);
|
|
33
|
+
*/
|
|
34
|
+
class Auth {
|
|
35
|
+
constructor() {
|
|
36
|
+
this._jwt = null;
|
|
37
|
+
this._config = null;
|
|
38
|
+
this._UserModel = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Configuration ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Configure Auth with config/auth.js settings.
|
|
45
|
+
* Called automatically by AuthServiceProvider.
|
|
46
|
+
*/
|
|
47
|
+
configure(config, UserModel = null) {
|
|
48
|
+
this._config = config;
|
|
49
|
+
this._UserModel = UserModel;
|
|
50
|
+
|
|
51
|
+
const jwtConfig = config?.guards?.jwt || {};
|
|
52
|
+
this._jwt = new JwtDriver(jwtConfig);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set the User model used for lookups.
|
|
57
|
+
*/
|
|
58
|
+
setUserModel(UserModel) {
|
|
59
|
+
this._UserModel = UserModel;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Core auth operations ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register a new user.
|
|
66
|
+
*
|
|
67
|
+
* Automatically hashes the password before saving.
|
|
68
|
+
* Returns the created user instance.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} data — { name, email, password, ...rest }
|
|
71
|
+
*/
|
|
72
|
+
async register(data) {
|
|
73
|
+
this._requireUserModel();
|
|
74
|
+
|
|
75
|
+
if (!data.password) throw new HttpError(422, 'Password is required');
|
|
76
|
+
if (!data.email) throw new HttpError(422, 'Email is required');
|
|
77
|
+
|
|
78
|
+
// Check for duplicate email
|
|
79
|
+
const existing = await this._UserModel.findBy('email', data.email);
|
|
80
|
+
if (existing) throw new HttpError(422, 'Email already in use');
|
|
81
|
+
|
|
82
|
+
const hashed = await Hasher.make(data.password);
|
|
83
|
+
return this._UserModel.create({ ...data, password: hashed });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Attempt to log in with email + password.
|
|
88
|
+
*
|
|
89
|
+
* Returns { user, token, refreshToken } on success.
|
|
90
|
+
* Throws 401 on failure.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} email
|
|
93
|
+
* @param {string} password
|
|
94
|
+
*/
|
|
95
|
+
async login(email, password) {
|
|
96
|
+
this._requireUserModel();
|
|
97
|
+
|
|
98
|
+
const user = await this._UserModel.findBy('email', email);
|
|
99
|
+
if (!user) throw new HttpError(401, 'Invalid credentials');
|
|
100
|
+
|
|
101
|
+
const ok = await Hasher.check(password, user.password);
|
|
102
|
+
if (!ok) throw new HttpError(401, 'Invalid credentials');
|
|
103
|
+
|
|
104
|
+
const payload = this._buildTokenPayload(user);
|
|
105
|
+
const token = this._jwt.sign(payload);
|
|
106
|
+
const refreshToken = this._jwt.signRefreshToken(payload);
|
|
107
|
+
|
|
108
|
+
return { user, token, refreshToken };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verify and decode a token string.
|
|
113
|
+
* Throws 401 if expired or invalid.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} token
|
|
116
|
+
* @returns {object} decoded payload
|
|
117
|
+
*/
|
|
118
|
+
verify(token) {
|
|
119
|
+
try {
|
|
120
|
+
return this._jwt.verify(token);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err.name === 'TokenExpiredError') {
|
|
123
|
+
throw new HttpError(401, 'Token has expired');
|
|
124
|
+
}
|
|
125
|
+
throw new HttpError(401, 'Invalid token');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the authenticated user from a request.
|
|
131
|
+
* Reads the Bearer token from Authorization header.
|
|
132
|
+
*
|
|
133
|
+
* Returns null if no token present or invalid.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} req — Express request
|
|
136
|
+
* @returns {object|null} user model instance
|
|
137
|
+
*/
|
|
138
|
+
async user(req) {
|
|
139
|
+
this._requireUserModel();
|
|
140
|
+
|
|
141
|
+
const token = this._extractToken(req);
|
|
142
|
+
if (!token) return null;
|
|
143
|
+
|
|
144
|
+
let payload;
|
|
145
|
+
try {
|
|
146
|
+
payload = this._jwt.verify(token);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return this._UserModel.find(payload.id || payload.sub);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the authenticated user and throw 401 if not found.
|
|
156
|
+
*/
|
|
157
|
+
async userOrFail(req) {
|
|
158
|
+
const u = await this.user(req);
|
|
159
|
+
if (!u) throw new HttpError(401, 'Unauthenticated');
|
|
160
|
+
return u;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Hash a plain-text password.
|
|
165
|
+
*/
|
|
166
|
+
async hashPassword(plain) {
|
|
167
|
+
return Hasher.make(plain);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check a plain-text password against a hash.
|
|
172
|
+
*/
|
|
173
|
+
async checkPassword(plain, hash) {
|
|
174
|
+
return Hasher.check(plain, hash);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Issue a new token for a user directly.
|
|
179
|
+
* Useful for token refresh flows.
|
|
180
|
+
*/
|
|
181
|
+
issueToken(user, options = {}) {
|
|
182
|
+
const payload = this._buildTokenPayload(user);
|
|
183
|
+
return this._jwt.sign(payload, options);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decode a token without verifying (inspect expired tokens).
|
|
188
|
+
*/
|
|
189
|
+
decode(token) {
|
|
190
|
+
return this._jwt.decode(token);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generate a secure password reset token for a user.
|
|
195
|
+
*/
|
|
196
|
+
generateResetToken(user) {
|
|
197
|
+
return this._jwt.signResetToken({
|
|
198
|
+
sub: user.id,
|
|
199
|
+
email: user.email,
|
|
200
|
+
type: 'password_reset',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Verify a password reset token.
|
|
206
|
+
* Returns the payload or throws 400.
|
|
207
|
+
*/
|
|
208
|
+
verifyResetToken(token) {
|
|
209
|
+
try {
|
|
210
|
+
const payload = this._jwt.verify(token);
|
|
211
|
+
if (payload.type !== 'password_reset') {
|
|
212
|
+
throw new HttpError(400, 'Invalid reset token type');
|
|
213
|
+
}
|
|
214
|
+
return payload;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err instanceof HttpError) throw err;
|
|
217
|
+
throw new HttpError(400, 'Invalid or expired reset token');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
_extractToken(req) {
|
|
224
|
+
const header = req.headers?.['authorization'] || '';
|
|
225
|
+
if (header.startsWith('Bearer ')) {
|
|
226
|
+
return header.slice(7);
|
|
227
|
+
}
|
|
228
|
+
// Also check query param for websocket / download links
|
|
229
|
+
return req.query?.token || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_buildTokenPayload(user) {
|
|
233
|
+
return {
|
|
234
|
+
id: user.id,
|
|
235
|
+
sub: user.id,
|
|
236
|
+
email: user.email,
|
|
237
|
+
role: user.role || null,
|
|
238
|
+
iat: Math.floor(Date.now() / 1000),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_requireUserModel() {
|
|
243
|
+
if (!this._UserModel) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
'Auth has no User model. ' +
|
|
246
|
+
'Call Auth.setUserModel(User) or boot AuthServiceProvider.'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Singleton facade
|
|
253
|
+
module.exports = new Auth();
|
|
254
|
+
module.exports.Auth = Auth;
|