millas 0.2.13 → 0.2.14
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/package.json +6 -3
- package/src/admin/Admin.js +107 -1027
- package/src/admin/AdminAuth.js +1 -1
- package/src/admin/ViewContext.js +1 -1
- package/src/admin/handlers/ActionHandler.js +103 -0
- package/src/admin/handlers/ApiHandler.js +113 -0
- package/src/admin/handlers/AuthHandler.js +76 -0
- package/src/admin/handlers/ExportHandler.js +70 -0
- package/src/admin/handlers/InlineHandler.js +71 -0
- package/src/admin/handlers/PageHandler.js +351 -0
- package/src/admin/resources/AdminResource.js +22 -1
- package/src/admin/static/SelectFilter2.js +34 -0
- package/src/admin/static/actions.js +201 -0
- package/src/admin/static/admin.css +7 -0
- package/src/admin/static/change_form.js +585 -0
- package/src/admin/static/core.js +128 -0
- package/src/admin/static/login.js +76 -0
- package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
- package/src/admin/static/vendor/jquery.min.js +2 -0
- package/src/admin/views/layouts/base.njk +30 -113
- package/src/admin/views/pages/detail.njk +10 -9
- package/src/admin/views/pages/form.njk +4 -4
- package/src/admin/views/pages/list.njk +11 -193
- package/src/admin/views/pages/login.njk +19 -64
- package/src/admin/views/partials/form-field.njk +1 -1
- package/src/admin/views/partials/form-scripts.njk +4 -478
- package/src/admin/views/partials/form-widget.njk +10 -10
- package/src/ai/AITokenBudget.js +1 -1
- package/src/auth/Auth.js +112 -3
- package/src/auth/AuthMiddleware.js +18 -15
- package/src/auth/Hasher.js +15 -43
- package/src/cli.js +3 -0
- package/src/commands/call.js +190 -0
- package/src/commands/createsuperuser.js +3 -4
- package/src/commands/key.js +97 -0
- package/src/commands/make.js +16 -2
- package/src/commands/new.js +16 -1
- package/src/commands/serve.js +5 -5
- package/src/console/Command.js +337 -0
- package/src/console/CommandLoader.js +165 -0
- package/src/console/index.js +6 -0
- package/src/container/AppInitializer.js +48 -1
- package/src/container/Application.js +3 -1
- package/src/container/HttpServer.js +0 -1
- package/src/container/MillasConfig.js +48 -0
- package/src/controller/Controller.js +13 -11
- package/src/core/docs.js +6 -0
- package/src/core/foundation.js +8 -0
- package/src/core/http.js +20 -10
- package/src/core/validation.js +58 -27
- package/src/docs/Docs.js +268 -0
- package/src/docs/DocsServiceProvider.js +80 -0
- package/src/docs/SchemaInferrer.js +131 -0
- package/src/docs/handlers/ApiHandler.js +305 -0
- package/src/docs/handlers/PageHandler.js +47 -0
- package/src/docs/index.js +13 -0
- package/src/docs/resources/ApiResource.js +402 -0
- package/src/docs/static/docs.css +723 -0
- package/src/docs/static/docs.js +1181 -0
- package/src/encryption/Encrypter.js +381 -0
- package/src/facades/Auth.js +5 -2
- package/src/facades/Crypt.js +166 -0
- package/src/facades/Docs.js +43 -0
- package/src/facades/Mail.js +1 -1
- package/src/http/MillasRequest.js +7 -31
- package/src/http/RequestContext.js +11 -7
- package/src/http/SecurityBootstrap.js +24 -2
- package/src/http/Shape.js +168 -0
- package/src/http/adapters/ExpressAdapter.js +9 -5
- package/src/middleware/CorsMiddleware.js +3 -0
- package/src/middleware/ThrottleMiddleware.js +10 -7
- package/src/orm/model/Model.js +14 -1
- package/src/providers/EncryptionServiceProvider.js +66 -0
- package/src/router/MiddlewareRegistry.js +79 -54
- package/src/router/Route.js +9 -4
- package/src/router/RouteEntry.js +91 -0
- package/src/router/Router.js +71 -1
- package/src/scaffold/maker.js +138 -1
- package/src/scaffold/templates.js +12 -0
- package/src/serializer/Serializer.js +239 -0
- package/src/support/Str.js +1080 -0
- package/src/validation/BaseValidator.js +45 -5
- package/src/validation/Validator.js +67 -61
- package/src/validation/types.js +490 -0
- package/src/middleware/AuthMiddleware.js +0 -46
- package/src/middleware/MiddlewareRegistry.js +0 -106
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// ── Supported ciphers ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const CIPHER_CONFIG = {
|
|
8
|
+
'AES-128-CBC': { keyBytes: 16, ivBytes: 16 },
|
|
9
|
+
'AES-256-CBC': { keyBytes: 32, ivBytes: 16 },
|
|
10
|
+
'AES-128-GCM': { keyBytes: 16, ivBytes: 12, gcm: true },
|
|
11
|
+
'AES-256-GCM': { keyBytes: 32, ivBytes: 12, gcm: true },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ── EncryptionError ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class EncryptionError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'EncryptionError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class DecryptionError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'DecryptionError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Encrypter ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Encrypter
|
|
34
|
+
*
|
|
35
|
+
* Laravel-style AES encryption service.
|
|
36
|
+
* Registered in the container as 'encrypter'.
|
|
37
|
+
* Access via the Encrypt facade — never instantiate directly.
|
|
38
|
+
*
|
|
39
|
+
* ── How it works ─────────────────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* Every encrypted payload is a base64-encoded JSON object:
|
|
42
|
+
*
|
|
43
|
+
* {
|
|
44
|
+
* iv: "<base64 iv>",
|
|
45
|
+
* val: "<base64 ciphertext>",
|
|
46
|
+
* mac: "<hex HMAC-SHA256>", // CBC only
|
|
47
|
+
* tag: "<base64 auth tag>", // GCM only
|
|
48
|
+
* ser: true // if value was JSON-serialised
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* The MAC (CBC) or auth tag (GCM) prevents tampered payloads from decrypting.
|
|
52
|
+
*
|
|
53
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* const { Encrypt } = require('millas/facades/Encrypt');
|
|
56
|
+
*
|
|
57
|
+
* // Encrypt any JS value (objects, arrays, strings, numbers)
|
|
58
|
+
* const token = Encrypt.encrypt({ userId: 1, role: 'admin' });
|
|
59
|
+
*
|
|
60
|
+
* // Decrypt back
|
|
61
|
+
* const payload = Encrypt.decrypt(token);
|
|
62
|
+
* // → { userId: 1, role: 'admin' }
|
|
63
|
+
*
|
|
64
|
+
* // Encrypt a raw string — skips JSON serialisation
|
|
65
|
+
* const raw = Encrypt.encryptString('hello');
|
|
66
|
+
* const str = Encrypt.decryptString(raw); // → 'hello'
|
|
67
|
+
*
|
|
68
|
+
* // Key introspection
|
|
69
|
+
* Encrypt.supported('my-key', 'AES-256-CBC'); // → true / false
|
|
70
|
+
* Encrypt.getKey(); // → Buffer
|
|
71
|
+
* Encrypt.getCipher(); // → 'AES-256-CBC'
|
|
72
|
+
*/
|
|
73
|
+
class Encrypter {
|
|
74
|
+
/**
|
|
75
|
+
* @param {string|Buffer} key — raw key (use generateKey() to create one)
|
|
76
|
+
* @param {string} cipher — 'AES-128-CBC' | 'AES-256-CBC' | 'AES-128-GCM' | 'AES-256-GCM'
|
|
77
|
+
*/
|
|
78
|
+
constructor(key, cipher = 'AES-256-CBC') {
|
|
79
|
+
const cipherUpper = cipher.toUpperCase();
|
|
80
|
+
const config = CIPHER_CONFIG[cipherUpper];
|
|
81
|
+
|
|
82
|
+
if (!config) {
|
|
83
|
+
throw new EncryptionError(
|
|
84
|
+
`[Encrypt] Unsupported cipher "${cipher}". ` +
|
|
85
|
+
`Supported: ${Object.keys(CIPHER_CONFIG).join(', ')}.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const keyBuf = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
|
90
|
+
|
|
91
|
+
if (keyBuf.length !== config.keyBytes) {
|
|
92
|
+
throw new EncryptionError(
|
|
93
|
+
`[Encrypt] Invalid key length for ${cipherUpper}. ` +
|
|
94
|
+
`Expected ${config.keyBytes} bytes, got ${keyBuf.length}. ` +
|
|
95
|
+
`Use Encrypter.generateKey('${cipherUpper}') to create a valid key.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._key = keyBuf;
|
|
100
|
+
this._cipher = cipherUpper;
|
|
101
|
+
this._config = config;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Core ───────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Encrypt a value (any JSON-serialisable type).
|
|
108
|
+
* Returns a base64-encoded payload string.
|
|
109
|
+
*
|
|
110
|
+
* Encrypt.encrypt({ userId: 1 })
|
|
111
|
+
* Encrypt.encrypt([1, 2, 3])
|
|
112
|
+
* Encrypt.encrypt('hello')
|
|
113
|
+
* Encrypt.encrypt(42)
|
|
114
|
+
*/
|
|
115
|
+
encrypt(value) {
|
|
116
|
+
return this._encrypt(JSON.stringify(value), true);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Encrypt a raw string without JSON serialisation.
|
|
121
|
+
* Use when you need deterministic round-tripping of plain strings.
|
|
122
|
+
*
|
|
123
|
+
* Encrypt.encryptString('secret-token')
|
|
124
|
+
*/
|
|
125
|
+
encryptString(value) {
|
|
126
|
+
return this._encrypt(String(value), false);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decrypt a payload produced by encrypt().
|
|
131
|
+
* Deserialises the JSON value back to the original type.
|
|
132
|
+
*
|
|
133
|
+
* const obj = Encrypt.decrypt(token); // → original object / array / primitive
|
|
134
|
+
*/
|
|
135
|
+
decrypt(payload) {
|
|
136
|
+
const raw = this._decrypt(payload);
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new DecryptionError('[Encrypt] Failed to deserialise decrypted value.');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decrypt a payload produced by encryptString().
|
|
146
|
+
* Returns a plain string.
|
|
147
|
+
*
|
|
148
|
+
* const str = Encrypt.decryptString(token);
|
|
149
|
+
*/
|
|
150
|
+
decryptString(payload) {
|
|
151
|
+
return this._decrypt(payload);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Key / cipher info ──────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Return the raw key as a Buffer.
|
|
158
|
+
*/
|
|
159
|
+
getKey() {
|
|
160
|
+
return Buffer.from(this._key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Return the cipher name (e.g. 'AES-256-CBC').
|
|
165
|
+
*/
|
|
166
|
+
getCipher() {
|
|
167
|
+
return this._cipher;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check whether a given key + cipher combination is supported and valid.
|
|
172
|
+
*
|
|
173
|
+
* Encrypter.supported(myKey, 'AES-256-CBC') → true / false
|
|
174
|
+
*
|
|
175
|
+
* @param {string|Buffer} key
|
|
176
|
+
* @param {string} cipher
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
static supported(key, cipher) {
|
|
180
|
+
try {
|
|
181
|
+
const config = CIPHER_CONFIG[(cipher || '').toUpperCase()];
|
|
182
|
+
if (!config) return false;
|
|
183
|
+
const keyBuf = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
|
184
|
+
return keyBuf.length === config.keyBytes;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Key generation ─────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate a cryptographically random key for the given cipher.
|
|
194
|
+
* Returns a base64-encoded string — store this in your APP_KEY env var.
|
|
195
|
+
*
|
|
196
|
+
* const key = Encrypter.generateKey('AES-256-CBC');
|
|
197
|
+
* // → 'base64:...'
|
|
198
|
+
*
|
|
199
|
+
* @param {string} cipher
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
static generateKey(cipher = 'AES-256-CBC') {
|
|
203
|
+
const config = CIPHER_CONFIG[cipher.toUpperCase()];
|
|
204
|
+
if (!config) {
|
|
205
|
+
throw new EncryptionError(`[Encrypt] Unknown cipher "${cipher}".`);
|
|
206
|
+
}
|
|
207
|
+
return 'base64:' + crypto.randomBytes(config.keyBytes).toString('base64');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
_encrypt(plaintext, serialised) {
|
|
213
|
+
const iv = crypto.randomBytes(this._config.ivBytes);
|
|
214
|
+
|
|
215
|
+
if (this._config.gcm) {
|
|
216
|
+
return this._encryptGcm(plaintext, iv, serialised);
|
|
217
|
+
}
|
|
218
|
+
return this._encryptCbc(plaintext, iv, serialised);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_encryptCbc(plaintext, iv, serialised) {
|
|
222
|
+
const cipher = crypto.createCipheriv(this._cipher, this._key, iv);
|
|
223
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
224
|
+
const ivB64 = iv.toString('base64');
|
|
225
|
+
const valB64 = encrypted.toString('base64');
|
|
226
|
+
const mac = this._computeMac(ivB64, valB64);
|
|
227
|
+
|
|
228
|
+
const payload = JSON.stringify({ iv: ivB64, val: valB64, mac, ser: serialised });
|
|
229
|
+
return Buffer.from(payload).toString('base64');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_encryptGcm(plaintext, iv, serialised) {
|
|
233
|
+
const cipher = crypto.createCipheriv(this._cipher, this._key, iv);
|
|
234
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
235
|
+
const tag = cipher.getAuthTag();
|
|
236
|
+
const ivB64 = iv.toString('base64');
|
|
237
|
+
const valB64 = encrypted.toString('base64');
|
|
238
|
+
const tagB64 = tag.toString('base64');
|
|
239
|
+
|
|
240
|
+
const payload = JSON.stringify({ iv: ivB64, val: valB64, tag: tagB64, ser: serialised });
|
|
241
|
+
return Buffer.from(payload).toString('base64');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_decrypt(token) {
|
|
245
|
+
// Decode and parse the envelope
|
|
246
|
+
let envelope;
|
|
247
|
+
try {
|
|
248
|
+
const json = Buffer.from(token, 'base64').toString('utf8');
|
|
249
|
+
envelope = JSON.parse(json);
|
|
250
|
+
} catch {
|
|
251
|
+
throw new DecryptionError('[Encrypt] The payload is invalid — could not decode.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!envelope || typeof envelope !== 'object') {
|
|
255
|
+
throw new DecryptionError('[Encrypt] The payload is invalid — unexpected structure.');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (this._config.gcm) {
|
|
259
|
+
return this._decryptGcm(envelope);
|
|
260
|
+
}
|
|
261
|
+
return this._decryptCbc(envelope);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_decryptCbc(envelope) {
|
|
265
|
+
const { iv: ivB64, val: valB64, mac } = envelope;
|
|
266
|
+
|
|
267
|
+
if (!ivB64 || !valB64 || !mac) {
|
|
268
|
+
throw new DecryptionError('[Encrypt] The payload is missing required CBC fields (iv, val, mac).');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Verify MAC before decrypting — prevents padding oracle attacks
|
|
272
|
+
const expectedMac = this._computeMac(ivB64, valB64);
|
|
273
|
+
if (!crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(expectedMac, 'hex'))) {
|
|
274
|
+
throw new DecryptionError('[Encrypt] The MAC is invalid. The payload may have been tampered with.');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
279
|
+
const encrypted = Buffer.from(valB64, 'base64');
|
|
280
|
+
const decipher = crypto.createDecipheriv(this._cipher, this._key, iv);
|
|
281
|
+
return decipher.update(encrypted) + decipher.final('utf8');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new DecryptionError(`[Encrypt] Decryption failed: ${err.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_decryptGcm(envelope) {
|
|
288
|
+
const { iv: ivB64, val: valB64, tag: tagB64 } = envelope;
|
|
289
|
+
|
|
290
|
+
if (!ivB64 || !valB64 || !tagB64) {
|
|
291
|
+
throw new DecryptionError('[Encrypt] The payload is missing required GCM fields (iv, val, tag).');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
296
|
+
const encrypted = Buffer.from(valB64, 'base64');
|
|
297
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
298
|
+
const decipher = crypto.createDecipheriv(this._cipher, this._key, iv);
|
|
299
|
+
decipher.setAuthTag(tag);
|
|
300
|
+
return decipher.update(encrypted) + decipher.final('utf8');
|
|
301
|
+
} catch (err) {
|
|
302
|
+
throw new DecryptionError(`[Encrypt] Decryption failed — GCM auth tag mismatch or corrupted payload.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_computeMac(iv, val) {
|
|
307
|
+
return crypto
|
|
308
|
+
.createHmac('sha256', this._key)
|
|
309
|
+
.update(iv + val)
|
|
310
|
+
.digest('hex');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── EncrypterManager ──────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* EncrypterManager
|
|
318
|
+
*
|
|
319
|
+
* Container-registered service (bound as 'encrypter').
|
|
320
|
+
* Reads APP_KEY and MILLAS_CIPHER from the environment and
|
|
321
|
+
* delegates everything to the underlying Encrypter.
|
|
322
|
+
*
|
|
323
|
+
* ── Service provider registration ─────────────────────────────────────────────
|
|
324
|
+
*
|
|
325
|
+
* container.singleton('encrypter', () => new EncrypterManager());
|
|
326
|
+
*/
|
|
327
|
+
class EncrypterManager {
|
|
328
|
+
constructor(config = {}) {
|
|
329
|
+
this._config = config;
|
|
330
|
+
this._instance = null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Resolve (and cache) the underlying Encrypter.
|
|
335
|
+
* Lazily created so APP_KEY can be set after the manager is instantiated.
|
|
336
|
+
*/
|
|
337
|
+
_driver() {
|
|
338
|
+
if (this._instance) return this._instance;
|
|
339
|
+
|
|
340
|
+
let key = this._config.key || process.env.APP_KEY || '';
|
|
341
|
+
const cipher = this._config.cipher || process.env.MILLAS_CIPHER || 'AES-256-CBC';
|
|
342
|
+
|
|
343
|
+
// Strip the 'base64:' prefix Laravel / Millas uses for generated keys
|
|
344
|
+
if (key.startsWith('base64:')) {
|
|
345
|
+
key = key.slice(7);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!key) {
|
|
349
|
+
throw new EncryptionError(
|
|
350
|
+
'[Encrypt] No application key set. ' +
|
|
351
|
+
'Set APP_KEY in your .env file. ' +
|
|
352
|
+
'Generate one with: Encrypter.generateKey(\'AES-256-CBC\')'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this._instance = new Encrypter(key, cipher);
|
|
357
|
+
return this._instance;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Delegated API ──────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
encrypt(value) { return this._driver().encrypt(value); }
|
|
363
|
+
encryptString(value) { return this._driver().encryptString(value); }
|
|
364
|
+
decrypt(payload) { return this._driver().decrypt(payload); }
|
|
365
|
+
decryptString(payload) { return this._driver().decryptString(payload); }
|
|
366
|
+
getKey() { return this._driver().getKey(); }
|
|
367
|
+
getCipher() { return this._driver().getCipher(); }
|
|
368
|
+
|
|
369
|
+
// ── Static passthroughs ───────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
static supported(key, cipher) { return Encrypter.supported(key, cipher); }
|
|
372
|
+
static generateKey(cipher) { return Encrypter.generateKey(cipher); }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
module.exports = new EncrypterManager();
|
|
378
|
+
module.exports.Encrypter = Encrypter;
|
|
379
|
+
module.exports.EncrypterManager = EncrypterManager;
|
|
380
|
+
module.exports.EncryptionError = EncryptionError;
|
|
381
|
+
module.exports.DecryptionError = DecryptionError;
|
package/src/facades/Auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { createFacade } = require('./Facade');
|
|
4
|
-
const { AuthUser, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware, AuthController, AuthServiceProvider } = require('../core');
|
|
4
|
+
// const { AuthUser, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware, AuthController, AuthServiceProvider } = require('../core');
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Auth facade.
|
|
@@ -9,9 +9,12 @@ const { AuthUser, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware, AuthControl
|
|
|
9
9
|
* @class
|
|
10
10
|
* @property {function(object): Promise<object>} register
|
|
11
11
|
* @property {function(string, string): Promise<{user, token, refreshToken}>} login
|
|
12
|
+
* @property {function(string, string): Promise<object|null>} attempt
|
|
12
13
|
* @property {function(string): object} verify
|
|
13
14
|
* @property {function(object): Promise<object|null>} user
|
|
14
15
|
* @property {function(object): Promise<object>} userOrFail
|
|
16
|
+
* @property {function(object, string): Promise<void>} revokeToken
|
|
17
|
+
* @property {function(string): boolean} isRevoked
|
|
15
18
|
* @property {function(string, string): Promise<boolean>} checkPassword
|
|
16
19
|
* @property {function(string): Promise<string>} hashPassword
|
|
17
20
|
* @property {function(object, object=): string} issueToken
|
|
@@ -26,4 +29,4 @@ const { AuthUser, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware, AuthControl
|
|
|
26
29
|
*/
|
|
27
30
|
class Auth extends createFacade('auth') {}
|
|
28
31
|
|
|
29
|
-
module.exports =
|
|
32
|
+
module.exports = Auth
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createFacade } = require('./Facade');
|
|
4
|
+
const { Encrypter, EncrypterManager,
|
|
5
|
+
EncryptionError, DecryptionError } = require('../encryption/Encrypter');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Crypt facade — Laravel-style AES encryption.
|
|
9
|
+
*
|
|
10
|
+
* Resolved from the DI container as 'encrypter'.
|
|
11
|
+
* Identical in behaviour to the Encrypt facade — exists so that developers
|
|
12
|
+
* familiar with Laravel can use the name they already know.
|
|
13
|
+
*
|
|
14
|
+
* @class
|
|
15
|
+
*
|
|
16
|
+
* ── Core ──────────────────────────────────────────────────────────────────────
|
|
17
|
+
* @property {function(*): string} encrypt
|
|
18
|
+
* Encrypt any JSON-serialisable value (object, array, string, number, boolean).
|
|
19
|
+
* Returns a base64-encoded ciphertext payload.
|
|
20
|
+
*
|
|
21
|
+
* const token = Crypt.encrypt({ userId: 1, role: 'admin' });
|
|
22
|
+
* const token = Crypt.encrypt('hello');
|
|
23
|
+
* const token = Crypt.encrypt(42);
|
|
24
|
+
*
|
|
25
|
+
* @property {function(string): string} encryptString
|
|
26
|
+
* Encrypt a raw string without JSON serialisation.
|
|
27
|
+
* Use for tokens, IDs, or any value you know is a string.
|
|
28
|
+
*
|
|
29
|
+
* const token = Crypt.encryptString('reset-token-abc');
|
|
30
|
+
*
|
|
31
|
+
* @property {function(string): *} decrypt
|
|
32
|
+
* Decrypt a payload produced by encrypt().
|
|
33
|
+
* Automatically deserialises JSON back to the original type.
|
|
34
|
+
* Throws DecryptionError if the payload is invalid or tampered.
|
|
35
|
+
*
|
|
36
|
+
* const payload = Crypt.decrypt(token); // → original object / value
|
|
37
|
+
*
|
|
38
|
+
* @property {function(string): string} decryptString
|
|
39
|
+
* Decrypt a payload produced by encryptString().
|
|
40
|
+
* Returns a plain string.
|
|
41
|
+
* Throws DecryptionError if the payload is invalid or tampered.
|
|
42
|
+
*
|
|
43
|
+
* const str = Crypt.decryptString(token);
|
|
44
|
+
*
|
|
45
|
+
* ── Key / cipher introspection ────────────────────────────────────────────────
|
|
46
|
+
* @property {function(): Buffer} getKey
|
|
47
|
+
* Return the raw key Buffer currently in use.
|
|
48
|
+
*
|
|
49
|
+
* const key = Crypt.getKey(); // → Buffer
|
|
50
|
+
*
|
|
51
|
+
* @property {function(): string} getCipher
|
|
52
|
+
* Return the cipher name in use (e.g. 'AES-256-CBC').
|
|
53
|
+
*
|
|
54
|
+
* const cipher = Crypt.getCipher(); // → 'AES-256-CBC'
|
|
55
|
+
*
|
|
56
|
+
* ── Static helpers (do not go through the container) ─────────────────────────
|
|
57
|
+
* @property {function(string|Buffer, string): boolean} supported
|
|
58
|
+
* Check whether a key + cipher pair is valid before constructing an Encrypter.
|
|
59
|
+
*
|
|
60
|
+
* Crypt.supported(myKey, 'AES-256-CBC'); // → true / false
|
|
61
|
+
*
|
|
62
|
+
* @property {function(string=): string} generateKey
|
|
63
|
+
* Generate a cryptographically random base64 key for a given cipher.
|
|
64
|
+
* The returned string includes the 'base64:' prefix — paste it into .env as APP_KEY.
|
|
65
|
+
*
|
|
66
|
+
* const key = Crypt.generateKey(); // → 'base64:...' (AES-256-CBC)
|
|
67
|
+
* const key = Crypt.generateKey('AES-128-CBC'); // → 'base64:...'
|
|
68
|
+
*
|
|
69
|
+
* ── How encryption works ──────────────────────────────────────────────────────
|
|
70
|
+
*
|
|
71
|
+
* Every payload is a base64-encoded JSON envelope:
|
|
72
|
+
*
|
|
73
|
+
* {
|
|
74
|
+
* iv: "<base64 IV>",
|
|
75
|
+
* val: "<base64 ciphertext>",
|
|
76
|
+
* mac: "<hex HMAC-SHA256>", // CBC only — prevents tampering
|
|
77
|
+
* tag: "<base64 auth tag>", // GCM only — built-in authentication
|
|
78
|
+
* ser: true // present when value was JSON-serialised
|
|
79
|
+
* }
|
|
80
|
+
*
|
|
81
|
+
* CBC mode uses a separate HMAC-SHA256 MAC over (iv + ciphertext) and verifies
|
|
82
|
+
* it with crypto.timingSafeEqual() before decrypting — guards against padding
|
|
83
|
+
* oracle attacks.
|
|
84
|
+
*
|
|
85
|
+
* GCM mode uses the built-in auth tag; decryption throws if the tag is invalid.
|
|
86
|
+
*
|
|
87
|
+
* ── Configuration ─────────────────────────────────────────────────────────────
|
|
88
|
+
*
|
|
89
|
+
* APP_KEY and (optionally) MILLAS_CIPHER are read from the environment:
|
|
90
|
+
*
|
|
91
|
+
* APP_KEY=base64:A3k9... # required
|
|
92
|
+
* MILLAS_CIPHER=AES-256-CBC # optional, default AES-256-CBC
|
|
93
|
+
*
|
|
94
|
+
* Supported ciphers: AES-128-CBC, AES-256-CBC, AES-128-GCM, AES-256-GCM
|
|
95
|
+
*
|
|
96
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
*
|
|
98
|
+
* const { Crypt } = require('millas/facades/Crypt');
|
|
99
|
+
*
|
|
100
|
+
* // Encrypt / decrypt any value
|
|
101
|
+
* const token = Crypt.encrypt({ userId: 1, plan: 'pro' });
|
|
102
|
+
* const payload = Crypt.decrypt(token); // → { userId: 1, plan: 'pro' }
|
|
103
|
+
*
|
|
104
|
+
* // Encrypt / decrypt raw strings
|
|
105
|
+
* const raw = Crypt.encryptString('api-secret-xyz');
|
|
106
|
+
* const str = Crypt.decryptString(raw); // → 'api-secret-xyz'
|
|
107
|
+
*
|
|
108
|
+
* // Generate a key (e.g. in a setup script)
|
|
109
|
+
* console.log(Crypt.generateKey()); // → 'base64:...'
|
|
110
|
+
*
|
|
111
|
+
* // Check a key is valid before use
|
|
112
|
+
* if (!Crypt.supported(myKey, 'AES-256-CBC')) {
|
|
113
|
+
* throw new Error('Invalid key');
|
|
114
|
+
* }
|
|
115
|
+
*
|
|
116
|
+
* ── Testing ───────────────────────────────────────────────────────────────────
|
|
117
|
+
* @property {function(object): void} swap
|
|
118
|
+
* Swap the underlying instance for a fake in tests:
|
|
119
|
+
* Crypt.swap({ encrypt: (v) => 'fake', decrypt: (v) => original });
|
|
120
|
+
*
|
|
121
|
+
* @property {function(): void} restore
|
|
122
|
+
* Restore the real implementation after a swap.
|
|
123
|
+
*
|
|
124
|
+
* @see src/encryption/Encrypter.js
|
|
125
|
+
* @see src/facades/Encrypt.js
|
|
126
|
+
*/
|
|
127
|
+
class Crypt extends createFacade('encrypter') {
|
|
128
|
+
static AES_128_CBC = 'AES-128-CBC';
|
|
129
|
+
static AES_256_CBC = 'AES-256-CBC';
|
|
130
|
+
static AES_128_GCM = 'AES-128-GCM';
|
|
131
|
+
static AES_256_GCM = 'AES-256-GCM';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check whether a key + cipher pair is valid.
|
|
135
|
+
* Static helper — does not go through the container.
|
|
136
|
+
*
|
|
137
|
+
* @param {string|Buffer} key
|
|
138
|
+
* @param {string} cipher
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
static supported(key, cipher) {
|
|
142
|
+
return Encrypter.supported(key, cipher);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate a cryptographically random base64 key for the given cipher.
|
|
147
|
+
* Static helper — does not go through the container.
|
|
148
|
+
*
|
|
149
|
+
* The returned string includes the 'base64:' prefix so it can be pasted
|
|
150
|
+
* directly into your .env file as APP_KEY.
|
|
151
|
+
*
|
|
152
|
+
* const key = Crypt.generateKey(); // AES-256-CBC (default)
|
|
153
|
+
* const key = Crypt.generateKey('AES-128-CBC');
|
|
154
|
+
* const key = Crypt.generateKey('AES-256-GCM');
|
|
155
|
+
*
|
|
156
|
+
* @param {string} [cipher='AES-256-CBC']
|
|
157
|
+
* @returns {string} e.g. 'base64:A3k9...'
|
|
158
|
+
*/
|
|
159
|
+
static generateKey(cipher = 'AES-256-CBC') {
|
|
160
|
+
return Encrypter.generateKey(cipher);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = Crypt;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* millas/facades/Docs
|
|
5
|
+
*
|
|
6
|
+
* const { Docs, ApiResource, ApiEndpoint, ApiField } = require('millas/facades/Docs');
|
|
7
|
+
*
|
|
8
|
+
* class UserApiResource extends ApiResource {
|
|
9
|
+
* static controller = UserController;
|
|
10
|
+
* static label = 'Users';
|
|
11
|
+
* static group = 'Auth & Users';
|
|
12
|
+
* static prefix = '/api/v1';
|
|
13
|
+
*
|
|
14
|
+
* static endpoints() {
|
|
15
|
+
* return [
|
|
16
|
+
* ApiEndpoint.post('/auth/register')
|
|
17
|
+
* .label('Register')
|
|
18
|
+
* .body({
|
|
19
|
+
* name: ApiField.text().required().example('Jane Doe'),
|
|
20
|
+
* email: ApiField.email().required().example('jane@example.com'),
|
|
21
|
+
* })
|
|
22
|
+
* .response(201, { id: 1, token: 'eyJ...' }),
|
|
23
|
+
*
|
|
24
|
+
* ApiEndpoint.get('/users/me').label('Get current user').auth(),
|
|
25
|
+
* ];
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* // In AppServiceProvider.boot():
|
|
30
|
+
* Docs.register(UserApiResource);
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const Docs = require('../docs/Docs');
|
|
34
|
+
const { ApiResource, ApiEndpoint, ApiField } = require('../docs/resources/ApiResource');
|
|
35
|
+
const DocsServiceProvider = require('../docs/DocsServiceProvider');
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
Docs,
|
|
39
|
+
ApiResource,
|
|
40
|
+
ApiEndpoint,
|
|
41
|
+
ApiField,
|
|
42
|
+
DocsServiceProvider,
|
|
43
|
+
};
|
package/src/facades/Mail.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { createFacade } = require('./Facade');
|
|
4
|
-
const { MailMessage, TemplateEngine, SmtpDriver, SendGridDriver, MailgunDriver, LogDriver, MailServiceProvider } = require('../core');
|
|
4
|
+
// const { MailMessage, TemplateEngine, SmtpDriver, SendGridDriver, MailgunDriver, LogDriver, MailServiceProvider } = require('../core');
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Mail facade.
|
|
@@ -222,29 +222,17 @@ class MillasRequest {
|
|
|
222
222
|
return this._req.secure || false;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
// ─── Validation
|
|
225
|
+
// ─── Validation (escape hatch) ───────────────────────────────────────────────
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
|
-
* Validate
|
|
229
|
-
*
|
|
230
|
-
* Returns the validated + type-coerced data subset on success.
|
|
228
|
+
* Validate input against a typed schema.
|
|
229
|
+
* Prefer using body.validate() in handlers, or .shape() on routes.
|
|
231
230
|
*
|
|
232
|
-
* const
|
|
233
|
-
* name: 'required|string|min:2|max:100',
|
|
234
|
-
* email: 'required|email',
|
|
235
|
-
* password: 'required|string|min:8',
|
|
236
|
-
* age: 'optional|number|min:13',
|
|
237
|
-
* });
|
|
238
|
-
*
|
|
239
|
-
* For route-level validation (runs before the handler, result in req.validated):
|
|
231
|
+
* const { string, email } = require('millas/core/validation');
|
|
240
232
|
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* password: 'required|string|min:8',
|
|
245
|
-
* },
|
|
246
|
-
* }, async (req) => {
|
|
247
|
-
* const { email, password } = req.validated;
|
|
233
|
+
* const data = await req.validate({
|
|
234
|
+
* name: string().required().max(100),
|
|
235
|
+
* email: email().required(),
|
|
248
236
|
* });
|
|
249
237
|
*/
|
|
250
238
|
async validate(rules) {
|
|
@@ -252,18 +240,6 @@ class MillasRequest {
|
|
|
252
240
|
return Validator.validate(this.all(), rules);
|
|
253
241
|
}
|
|
254
242
|
|
|
255
|
-
/**
|
|
256
|
-
* The validated + coerced input — populated by route-level validation middleware.
|
|
257
|
-
* Null if no route-level validation was declared for this route.
|
|
258
|
-
*
|
|
259
|
-
* Route.post('/login', { validate: { email: 'required|email' } }, async (req) => {
|
|
260
|
-
* req.validated.email // guaranteed valid email string
|
|
261
|
-
* });
|
|
262
|
-
*/
|
|
263
|
-
get validated() {
|
|
264
|
-
return this._req.validated ?? null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
243
|
// ─── CSRF ────────────────────────────────────────────────────────────────────
|
|
268
244
|
|
|
269
245
|
/**
|