millas 0.2.13 → 0.2.15

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 (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +20 -2
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. 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;
@@ -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 = { Auth, AuthUser, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware, AuthController, AuthServiceProvider };
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
+ };
@@ -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 request input against rules.
229
- * Throws a 422 ValidationError on failure.
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 data = await req.validate({
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
- * Route.post('/register', {
242
- * validate: {
243
- * email: 'required|email',
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
  /**