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.
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 +14 -1
  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,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SchemaInferrer
5
+ *
6
+ * Reads a shape's "in" / "query" schema (BaseValidator instances) and
7
+ * maps them to ApiField objects for the docs panel.
8
+ *
9
+ * This is the bridge between the validation layer and the docs layer.
10
+ * Developers write their shape once — SchemaInferrer makes sure the
11
+ * docs panel reflects it accurately.
12
+ *
13
+ * ── Mapping ───────────────────────────────────────────────────────────────────
14
+ *
15
+ * StringValidator → ApiField.text()
16
+ * StringValidator + .email() → ApiField.email()
17
+ * StringValidator + .url() → ApiField.url()
18
+ * StringValidator + .uuid() → ApiField.uuid()
19
+ * StringValidator + .phone() → ApiField.phone()
20
+ * StringValidator + .oneOf() → ApiField.select([...])
21
+ * EmailValidator → ApiField.email()
22
+ * NumberValidator → ApiField.number()
23
+ * NumberValidator + .integer() → ApiField.integer()
24
+ * BooleanValidator → ApiField.boolean()
25
+ * ArrayValidator → ApiField.array()
26
+ * DateValidator → ApiField.date()
27
+ * FileValidator → ApiField.file()
28
+ * ObjectValidator → ApiField.json()
29
+ *
30
+ * All validators:
31
+ * ._required → ApiField.required() / .nullable()
32
+ * ._example → ApiField.example(value)
33
+ * ._describe → ApiField.description(text)
34
+ * ._minLen / ._minVal → ApiField.min(n)
35
+ * ._maxLen / ._maxVal → ApiField.max(n)
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const {
41
+ StringValidator,
42
+ EmailValidator,
43
+ NumberValidator,
44
+ BooleanValidator,
45
+ ArrayValidator,
46
+ DateValidator,
47
+ ObjectValidator,
48
+ FileValidator,
49
+ BaseValidator,
50
+ } = require('../validation/types');
51
+
52
+ /**
53
+ * Infer an ApiField-compatible JSON descriptor from a BaseValidator instance.
54
+ * Returns a plain object matching ApiField.toJSON() shape.
55
+ *
56
+ * @param {string} fieldName
57
+ * @param {BaseValidator} validator
58
+ * @returns {object} ApiField-compatible JSON
59
+ */
60
+ function inferField(fieldName, validator) {
61
+ if (!(validator instanceof BaseValidator)) return null;
62
+
63
+ // Determine the ApiField type
64
+ let type = 'string';
65
+
66
+ if (validator instanceof EmailValidator) {
67
+ type = 'email';
68
+ } else if (validator instanceof StringValidator) {
69
+ if (validator._emailCheck) type = 'email';
70
+ else if (validator._urlCheck) type = 'url';
71
+ else if (validator._uuidCheck) type = 'uuid';
72
+ else if (validator._phoneCheck) type = 'phone';
73
+ else if (validator._oneOfVals) type = 'select';
74
+ else type = 'string';
75
+ } else if (validator instanceof NumberValidator) {
76
+ type = validator._isInt ? 'integer' : 'number';
77
+ } else if (validator instanceof BooleanValidator) {
78
+ type = 'boolean';
79
+ } else if (validator instanceof ArrayValidator) {
80
+ type = 'array';
81
+ } else if (validator instanceof DateValidator) {
82
+ type = 'date';
83
+ } else if (validator instanceof ObjectValidator) {
84
+ type = 'json';
85
+ } else if (validator instanceof FileValidator) {
86
+ type = 'file';
87
+ }
88
+
89
+ // Build enum for select fields
90
+ let enumVals = null;
91
+ if (type === 'select' && validator._oneOfVals) {
92
+ enumVals = validator._oneOfVals.map(v => ({ value: String(v), label: String(v) }));
93
+ }
94
+
95
+ // Determine min/max from the validator instance properties
96
+ const minVal = validator._minLen ?? validator._minVal ?? null;
97
+ const maxVal = validator._maxLen ?? validator._maxVal ?? null;
98
+
99
+ return {
100
+ name: fieldName,
101
+ type,
102
+ required: validator._required || false,
103
+ nullable: validator._nullable || false,
104
+ example: validator._example !== undefined ? validator._example : undefined,
105
+ description: validator._describe || validator._label || null,
106
+ default: validator._defaultValue !== undefined ? validator._defaultValue : undefined,
107
+ enum: enumVals,
108
+ min: minVal,
109
+ max: maxVal,
110
+ format: null,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Infer an entire schema map (from shape.in or shape.query) into an
116
+ * object of ApiField-compatible JSON descriptors.
117
+ *
118
+ * @param {object} schema — { fieldName: BaseValidator, ... }
119
+ * @returns {object} — { fieldName: ApiFieldJSON, ... }
120
+ */
121
+ function inferFields(schema) {
122
+ if (!schema || typeof schema !== 'object') return {};
123
+ const out = {};
124
+ for (const [name, validator] of Object.entries(schema)) {
125
+ const field = inferField(name, validator);
126
+ if (field) out[name] = field;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ module.exports = { inferField, inferFields };
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { URL } = require('url');
6
+
7
+ /**
8
+ * ApiHandler
9
+ *
10
+ * Internal JSON API used by the client-side docs app.
11
+ *
12
+ * GET /_api/manifest → full docs manifest (groups + endpoints)
13
+ * POST /_api/try → proxy a real API request
14
+ * GET /_api/export/postman → Postman collection JSON
15
+ * GET /_api/export/openapi → OpenAPI 3.0 JSON
16
+ */
17
+ class ApiHandler {
18
+ constructor(docs) {
19
+ this._docs = docs;
20
+ }
21
+
22
+ manifest(req, res) {
23
+ try {
24
+ const manifest = this._docs.buildManifest();
25
+ res.json({ ok: true, data: manifest });
26
+ } catch (err) {
27
+ res.status(500).json({ ok: false, error: err.message });
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Proxy a real request from the browser.
33
+ *
34
+ * Request body:
35
+ * {
36
+ * method: 'GET' | 'POST' | ...,
37
+ * url: 'http://localhost:3000/api/v1/users',
38
+ * headers: { 'Authorization': 'Bearer ...' },
39
+ * body: { ... }, // JSON body for POST/PATCH/PUT
40
+ * encoding: 'json' | 'form' | 'multipart'
41
+ * }
42
+ *
43
+ * For multipart: body is forwarded as application/x-www-form-urlencoded
44
+ * (file contents are not proxied — the browser sends field values only).
45
+ * Full file upload proxying requires the client to send a FormData blob,
46
+ * which is handled separately via the browser fetch direct path.
47
+ */
48
+ async tryRequest(req, res) {
49
+ const { method, url, headers = {}, body, encoding } = req.body || {};
50
+
51
+ if (!url) {
52
+ return res.status(400).json({ ok: false, error: 'url is required' });
53
+ }
54
+
55
+ const start = Date.now();
56
+
57
+ try {
58
+ const parsed = new URL(url);
59
+ const lib = parsed.protocol === 'https:' ? https : http;
60
+ const verb = (method || 'GET').toUpperCase();
61
+ const hasBody = body && verb !== 'GET' && verb !== 'DELETE';
62
+
63
+ let bodyStr = null;
64
+ let contentType = 'application/json';
65
+
66
+ if (hasBody) {
67
+ if (encoding === 'form' || encoding === 'multipart') {
68
+ // Send as URL-encoded form (file fields are omitted — browser must handle uploads directly)
69
+ const formParts = Object.entries(body)
70
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
71
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
72
+ bodyStr = formParts.join('&');
73
+ contentType = 'application/x-www-form-urlencoded';
74
+ } else {
75
+ bodyStr = JSON.stringify(body);
76
+ contentType = 'application/json';
77
+ }
78
+ }
79
+
80
+ const options = {
81
+ hostname: parsed.hostname,
82
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
83
+ path: parsed.pathname + parsed.search,
84
+ method: verb,
85
+ headers: {
86
+ 'Content-Type': contentType,
87
+ 'Accept': 'application/json',
88
+ ...headers,
89
+ ...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
90
+ },
91
+ };
92
+
93
+ const result = await new Promise((resolve, reject) => {
94
+ const reqOut = lib.request(options, (incoming) => {
95
+ let data = '';
96
+ incoming.on('data', chunk => { data += chunk; });
97
+ incoming.on('end', () => {
98
+ resolve({
99
+ status: incoming.statusCode,
100
+ headers: incoming.headers,
101
+ body: data,
102
+ });
103
+ });
104
+ });
105
+ reqOut.on('error', reject);
106
+ if (bodyStr) reqOut.write(bodyStr);
107
+ reqOut.end();
108
+ });
109
+
110
+ let parsedBody = result.body;
111
+ try { parsedBody = JSON.parse(result.body); } catch {}
112
+
113
+ res.json({
114
+ ok: true,
115
+ status: result.status,
116
+ headers: result.headers,
117
+ body: parsedBody,
118
+ time: Date.now() - start,
119
+ });
120
+ } catch (err) {
121
+ res.status(500).json({ ok: false, error: err.message, time: Date.now() - start });
122
+ }
123
+ }
124
+
125
+ exportPostman(req, res) {
126
+ try {
127
+ const manifest = this._docs.buildManifest();
128
+ const baseUrl = (req.query.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
129
+ const collection = _toPostman(manifest, baseUrl);
130
+ res.setHeader('Content-Disposition', 'attachment; filename="api-collection.json"');
131
+ res.json(collection);
132
+ } catch (err) {
133
+ res.status(500).json({ ok: false, error: err.message });
134
+ }
135
+ }
136
+
137
+ exportOpenApi(req, res) {
138
+ try {
139
+ const manifest = this._docs.buildManifest();
140
+ const baseUrl = req.query.baseUrl || 'http://localhost:3000';
141
+ const spec = _toOpenApi(manifest, baseUrl);
142
+ res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"');
143
+ res.json(spec);
144
+ } catch (err) {
145
+ res.status(500).json({ ok: false, error: err.message });
146
+ }
147
+ }
148
+ }
149
+
150
+ // ── Export helpers ─────────────────────────────────────────────────────────────
151
+
152
+ function _toPostman(manifest, baseUrl) {
153
+ const items = [];
154
+
155
+ for (const group of manifest.groups) {
156
+ const groupItems = group.endpoints.map(ep => {
157
+ // Path params
158
+ const url = (baseUrl + ep.path).replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{{$1}}');
159
+
160
+ const item = {
161
+ name: ep.label,
162
+ request: {
163
+ method: ep.verb.toUpperCase(),
164
+ header: [
165
+ { key: 'Content-Type', value: 'application/json' },
166
+ ...(ep.auth ? [{ key: 'Authorization', value: 'Bearer {{token}}' }] : []),
167
+ ],
168
+ url: { raw: url, host: [url] },
169
+ },
170
+ };
171
+
172
+ if (ep.body && Object.keys(ep.body).length && ep.verb !== 'get') {
173
+ const example = {};
174
+ for (const [k, f] of Object.entries(ep.body)) {
175
+ example[k] = f.example !== undefined ? f.example : _typeDefault(f.type);
176
+ }
177
+ item.request.body = {
178
+ mode: 'raw',
179
+ raw: JSON.stringify(example, null, 2),
180
+ options: { raw: { language: 'json' } },
181
+ };
182
+ }
183
+
184
+ return item;
185
+ });
186
+
187
+ items.push({
188
+ name: group.label,
189
+ item: groupItems,
190
+ });
191
+ }
192
+
193
+ return {
194
+ info: {
195
+ name: manifest.title,
196
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
197
+ },
198
+ item: items,
199
+ variable: [
200
+ { key: 'baseUrl', value: baseUrl },
201
+ { key: 'token', value: '' },
202
+ ],
203
+ };
204
+ }
205
+
206
+ function _toOpenApi(manifest, baseUrl) {
207
+ const paths = {};
208
+
209
+ for (const group of manifest.groups) {
210
+ for (const ep of group.endpoints) {
211
+ const oaPath = ep.path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
212
+ if (!paths[oaPath]) paths[oaPath] = {};
213
+
214
+ const operation = {
215
+ summary: ep.label,
216
+ description: ep.description || '',
217
+ tags: [group.label],
218
+ parameters: [],
219
+ responses: {},
220
+ };
221
+
222
+ // Path params
223
+ for (const pName of (ep.pathParams || [])) {
224
+ const pDef = ep.params?.[pName] || {};
225
+ operation.parameters.push({
226
+ name: pName, in: 'path', required: true,
227
+ schema: { type: pDef.type || 'string' },
228
+ example: pDef.example,
229
+ description: pDef.description || '',
230
+ });
231
+ }
232
+
233
+ // Query params
234
+ for (const [qName, qDef] of Object.entries(ep.query || {})) {
235
+ operation.parameters.push({
236
+ name: qName, in: 'query', required: qDef.required || false,
237
+ schema: { type: qDef.type || 'string' },
238
+ example: qDef.example,
239
+ description: qDef.description || '',
240
+ });
241
+ }
242
+
243
+ // Auth
244
+ if (ep.auth) {
245
+ operation.security = [{ bearerAuth: [] }];
246
+ }
247
+
248
+ // Request body
249
+ if (ep.body && Object.keys(ep.body).length && ep.verb !== 'get') {
250
+ const props = {}, required = [];
251
+ for (const [fName, fDef] of Object.entries(ep.body)) {
252
+ props[fName] = { type: fDef.type || 'string', example: fDef.example, description: fDef.description || '' };
253
+ if (fDef.required) required.push(fName);
254
+ }
255
+ operation.requestBody = {
256
+ content: {
257
+ 'application/json': {
258
+ schema: { type: 'object', properties: props, ...(required.length ? { required } : {}) },
259
+ },
260
+ },
261
+ };
262
+ }
263
+
264
+ // Responses
265
+ if (ep.responses?.length) {
266
+ for (const r of ep.responses) {
267
+ operation.responses[String(r.status)] = {
268
+ description: r.description || _statusText(r.status),
269
+ content: r.example ? {
270
+ 'application/json': { example: r.example },
271
+ } : undefined,
272
+ };
273
+ }
274
+ } else {
275
+ operation.responses['200'] = { description: 'OK' };
276
+ }
277
+
278
+ paths[oaPath][ep.verb] = operation;
279
+ }
280
+ }
281
+
282
+ return {
283
+ openapi: '3.0.3',
284
+ info: { title: manifest.title, version: '1.0.0' },
285
+ servers: [{ url: baseUrl }],
286
+ components: {
287
+ securitySchemes: {
288
+ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
289
+ },
290
+ },
291
+ paths,
292
+ };
293
+ }
294
+
295
+ function _typeDefault(type) {
296
+ const m = { string: '', email: 'user@example.com', password: 'secret', number: 0, integer: 0, boolean: false, array: [], json: {} };
297
+ return m[type] ?? '';
298
+ }
299
+
300
+ function _statusText(s) {
301
+ const t = { 200:'OK',201:'Created',204:'No Content',400:'Bad Request',401:'Unauthorized',403:'Forbidden',404:'Not Found',422:'Unprocessable Entity',500:'Server Error' };
302
+ return t[s] || 'Response';
303
+ }
304
+
305
+ module.exports = ApiHandler;
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PageHandler
5
+ *
6
+ * Serves the single-page docs app shell.
7
+ * All content is loaded client-side via /_api/manifest.
8
+ */
9
+ class PageHandler {
10
+ constructor(docs) {
11
+ this._docs = docs;
12
+ }
13
+
14
+ index(req, res) {
15
+ const cfg = this._docs._config;
16
+ const adminPrefix = cfg.adminPrefix || '/admin';
17
+ res.send(_shell(cfg.title, cfg.prefix, adminPrefix));
18
+ }
19
+ }
20
+
21
+ function _shell(title, prefix, adminPrefix) {
22
+ // Bootstrap Icons is served from the admin's local vendor directory —
23
+ // no CDN, no CSP violation. Both admin and docs are framework internals
24
+ // so sharing the vendor path is intentional and safe.
25
+ const biCss = `${adminPrefix}/static/vendor/bi/bootstrap-icons.min.css`;
26
+
27
+ // __DOCS_PREFIX__ is passed via a data attribute on #app instead of an
28
+ // inline <script> block — avoids the script-src 'unsafe-inline' CSP error.
29
+ return `<!DOCTYPE html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="UTF-8" />
33
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
34
+ <title>${title}</title>
35
+ <link rel="stylesheet" href="${prefix}/static/docs.css?v=5" />
36
+ <link rel="stylesheet" href="${biCss}" />
37
+ </head>
38
+ <body>
39
+ <div id="app" data-prefix="${prefix}" data-admin-prefix="${adminPrefix}"></div>
40
+ <!-- ui.js from admin — Portal, Modal, Drawer, Toast, Confirm, Tooltip, Dropdown -->
41
+ <script src="${adminPrefix}/static/ui.js"></script>
42
+ <script src="${prefix}/static/docs.js?v=10"></script>
43
+ </body>
44
+ </html>`;
45
+ }
46
+
47
+ module.exports = PageHandler;
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const Docs = require('./Docs');
4
+ const DocsServiceProvider = require('./DocsServiceProvider');
5
+ const { ApiResource, ApiEndpoint, ApiField } = require('./resources/ApiResource');
6
+
7
+ module.exports = {
8
+ Docs,
9
+ DocsServiceProvider,
10
+ ApiResource,
11
+ ApiEndpoint,
12
+ ApiField,
13
+ };