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
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
* return jsonify(await User.update(params.id, body));
|
|
39
39
|
* })
|
|
40
40
|
*
|
|
41
|
-
* // Inline validation on body
|
|
41
|
+
* // Inline validation on body — use typed validators from millas/core/validation
|
|
42
42
|
* Route.post('/posts', async ({ body }) => {
|
|
43
43
|
* const data = await body.validate({
|
|
44
|
-
* title:
|
|
45
|
-
* content:
|
|
44
|
+
* title: string().required().max(255),
|
|
45
|
+
* content: string().required(),
|
|
46
46
|
* });
|
|
47
47
|
* return jsonify(await Post.create(data));
|
|
48
48
|
* })
|
|
@@ -129,12 +129,16 @@ class RequestContext {
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Build the body object with an attached .validate() method.
|
|
132
|
-
*
|
|
132
|
+
*
|
|
133
|
+
* const { string, email } = require('millas/core/validation');
|
|
133
134
|
*
|
|
134
135
|
* const data = await body.validate({
|
|
135
|
-
* name:
|
|
136
|
-
* email:
|
|
136
|
+
* name: string().required().max(100),
|
|
137
|
+
* email: email().required(),
|
|
137
138
|
* });
|
|
139
|
+
*
|
|
140
|
+
* When a route has .shape({ in: {...} }), validation already ran before
|
|
141
|
+
* the handler — body is pre-validated and body.validate() is not needed.
|
|
138
142
|
*/
|
|
139
143
|
_buildBody(rawBody, millaReq) {
|
|
140
144
|
// Start with the raw body data
|
|
@@ -173,4 +177,4 @@ class RequestContext {
|
|
|
173
177
|
}
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
module.exports = RequestContext;
|
|
180
|
+
module.exports = RequestContext;
|
|
@@ -19,8 +19,29 @@ class SecurityBootstrap {
|
|
|
19
19
|
app.use(globalRateLimit.middleware());
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// ── CSRF ──────────────────────────────────────────────────────────────────
|
|
23
|
+
// The admin panel (/admin) manages its own CSRF via AdminAuth.
|
|
24
|
+
// Auto-exclude it so the framework CSRF doesn't double-protect it.
|
|
22
25
|
if (config.csrf !== false) {
|
|
23
|
-
|
|
26
|
+
const csrfConfig = config.csrf || {};
|
|
27
|
+
const adminPrefix = config.adminPrefix || '/admin';
|
|
28
|
+
const docsPrefix = config.docsPrefix || '/docs';
|
|
29
|
+
const excluded = [...(csrfConfig.exclude || [])];
|
|
30
|
+
|
|
31
|
+
// Auto-exclude the admin panel — it manages its own CSRF via AdminAuth
|
|
32
|
+
if (!excluded.some(p => p === adminPrefix || p.startsWith(adminPrefix + '/'))) {
|
|
33
|
+
excluded.push(adminPrefix + '/');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Auto-exclude the docs internal API — /_api/try is a dev-time proxy,
|
|
37
|
+
// not a state-changing endpoint on user data. It is already protected by
|
|
38
|
+
// the docs.enabled flag (off in production by default).
|
|
39
|
+
const docsApiPrefix = docsPrefix + '/_api/';
|
|
40
|
+
if (!excluded.some(p => p === docsApiPrefix || docsApiPrefix.startsWith(p))) {
|
|
41
|
+
excluded.push(docsApiPrefix);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
app.use(CsrfMiddleware.from({ ...csrfConfig, exclude: excluded }).middleware());
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
SecurityBootstrap._registerErrorHandler(app);
|
|
@@ -30,7 +51,7 @@ class SecurityBootstrap {
|
|
|
30
51
|
console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
|
|
31
52
|
console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
|
|
32
53
|
console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
|
|
33
|
-
console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' :
|
|
54
|
+
console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' : `enabled (excluding: ${adminPrefix}/, ${docsPrefix}/_api/)`);
|
|
34
55
|
}
|
|
35
56
|
}
|
|
36
57
|
|
|
@@ -51,6 +72,7 @@ class SecurityBootstrap {
|
|
|
51
72
|
next(err);
|
|
52
73
|
});
|
|
53
74
|
}
|
|
75
|
+
|
|
54
76
|
static loadConfig(configPath) {
|
|
55
77
|
const path = require('path');
|
|
56
78
|
const fs = require('fs');
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shape — route input/output contract
|
|
5
|
+
*
|
|
6
|
+
* A shape defines what a route accepts and what it returns.
|
|
7
|
+
* It serves two purposes simultaneously — zero duplication:
|
|
8
|
+
*
|
|
9
|
+
* 1. Runtime validation middleware
|
|
10
|
+
* When a route has .shape() or .fromShape(), the framework validates
|
|
11
|
+
* incoming data BEFORE the handler runs. Bad requests are rejected
|
|
12
|
+
* with 422 automatically — the handler only runs with clean data.
|
|
13
|
+
*
|
|
14
|
+
* 2. API docs generation
|
|
15
|
+
* The docs panel reads the shape to render the body schema, query
|
|
16
|
+
* params, expected responses, and "Try it" form — no separate
|
|
17
|
+
* ApiResource declaration needed.
|
|
18
|
+
*
|
|
19
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* const { shape } = require('millas/core/http');
|
|
22
|
+
* const { string, number, email, boolean, array } = require('millas/core/validation');
|
|
23
|
+
*
|
|
24
|
+
* // Inline on a route:
|
|
25
|
+
* Route.post('/properties', PropertyController, 'store')
|
|
26
|
+
* .shape({
|
|
27
|
+
* label: 'Create property',
|
|
28
|
+
* group: 'Properties & Units',
|
|
29
|
+
* in: {
|
|
30
|
+
* name: string().required().max(200).example('Sunset Apartments'),
|
|
31
|
+
* city: string().required().example('Nairobi'),
|
|
32
|
+
* type: string().required().oneOf(['apartment','house','commercial']),
|
|
33
|
+
* },
|
|
34
|
+
* out: {
|
|
35
|
+
* 201: { id: 1, name: 'Sunset Apartments' },
|
|
36
|
+
* 422: { message: 'Validation failed', errors: {} },
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // From a shared shape file:
|
|
41
|
+
* Route.post('/properties', PropertyController, 'store')
|
|
42
|
+
* .fromShape(CreatePropertyShape);
|
|
43
|
+
*
|
|
44
|
+
* ── Shape file convention ─────────────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* Scaffold with: millas make:shape PropertyShape
|
|
47
|
+
* Outputs to: app/shapes/PropertyShape.js
|
|
48
|
+
*
|
|
49
|
+
* const { shape } = require('millas/core/http');
|
|
50
|
+
* const { string, number, array } = require('millas/core/validation');
|
|
51
|
+
*
|
|
52
|
+
* const CreatePropertyShape = shape({
|
|
53
|
+
* label: 'Create property',
|
|
54
|
+
* group: 'Properties & Units',
|
|
55
|
+
* in: {
|
|
56
|
+
* name: string().required().max(200).example('Sunset Apartments'),
|
|
57
|
+
* city: string().required().example('Nairobi'),
|
|
58
|
+
* },
|
|
59
|
+
* out: { 201: { id: 1 } },
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* const UpdatePropertyShape = shape({
|
|
63
|
+
* label: 'Update property',
|
|
64
|
+
* group: 'Properties & Units',
|
|
65
|
+
* in: {
|
|
66
|
+
* name: string().optional().max(200),
|
|
67
|
+
* city: string().optional(),
|
|
68
|
+
* },
|
|
69
|
+
* out: { 200: { id: 1 } },
|
|
70
|
+
* });
|
|
71
|
+
*
|
|
72
|
+
* module.exports = { CreatePropertyShape, UpdatePropertyShape };
|
|
73
|
+
*
|
|
74
|
+
* ── Handler access ────────────────────────────────────────────────────────────
|
|
75
|
+
*
|
|
76
|
+
* The handler receives clean, coerced data via the normal context keys:
|
|
77
|
+
*
|
|
78
|
+
* async store({ body, user }) {
|
|
79
|
+
* // body is already validated and coerced — guaranteed clean
|
|
80
|
+
* return this.created(await Property.create(body));
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* If validation fails the handler never runs — 422 is returned immediately.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
'use strict';
|
|
87
|
+
|
|
88
|
+
const { BaseValidator } = require('../validation/BaseValidator');
|
|
89
|
+
|
|
90
|
+
const SHAPE_BRAND = Symbol('MillasShape');
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* shape(def) — create a sealed, validated shape definition.
|
|
94
|
+
*
|
|
95
|
+
* Validates the definition at module load time so mistakes surface
|
|
96
|
+
* immediately, not at request time.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} def
|
|
99
|
+
* @returns {ShapeDefinition}
|
|
100
|
+
*/
|
|
101
|
+
function shape(def) {
|
|
102
|
+
if (!def || typeof def !== 'object') {
|
|
103
|
+
throw new Error('[shape] shape() requires a definition object.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dev-time validation of the "in" schema
|
|
107
|
+
if (def.in) {
|
|
108
|
+
if (typeof def.in !== 'object' || Array.isArray(def.in)) {
|
|
109
|
+
throw new Error('[shape] "in" must be a plain object of field validators.');
|
|
110
|
+
}
|
|
111
|
+
for (const [field, v] of Object.entries(def.in)) {
|
|
112
|
+
if (!(v instanceof BaseValidator)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`[shape] Field "${field}" in "in" must be a validator instance.\n` +
|
|
115
|
+
` Use: string(), number(), email(), boolean(), array(), date(), file()\n` +
|
|
116
|
+
` from millas/core/validation.\n` +
|
|
117
|
+
` Got: ${typeof v}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Dev-time validation of the "query" schema
|
|
124
|
+
if (def.query) {
|
|
125
|
+
for (const [field, v] of Object.entries(def.query)) {
|
|
126
|
+
if (!(v instanceof BaseValidator)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`[shape] Field "${field}" in "query" must be a validator instance. Got: ${typeof v}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Dev-time validation of "out"
|
|
135
|
+
if (def.out) {
|
|
136
|
+
for (const [status] of Object.entries(def.out)) {
|
|
137
|
+
if (isNaN(Number(status))) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[shape] Keys in "out" must be HTTP status codes (numbers). Got: "${status}"`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const built = {
|
|
146
|
+
[SHAPE_BRAND]: true,
|
|
147
|
+
label: def.label || null,
|
|
148
|
+
group: def.group || null,
|
|
149
|
+
icon: def.icon || null,
|
|
150
|
+
description: def.description || null,
|
|
151
|
+
encoding: def.encoding || 'json', // 'json' | 'form' | 'multipart'
|
|
152
|
+
in: Object.freeze(def.in || {}),
|
|
153
|
+
query: Object.freeze(def.query || {}),
|
|
154
|
+
out: Object.freeze(def.out || {}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return Object.freeze(built);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns true if the value is a shape definition built by shape().
|
|
162
|
+
* @param {*} val
|
|
163
|
+
*/
|
|
164
|
+
function isShape(val) {
|
|
165
|
+
return val && typeof val === 'object' && val[SHAPE_BRAND] === true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { shape, isShape, SHAPE_BRAND };
|
|
@@ -167,11 +167,15 @@ class ExpressAdapter extends HttpAdapter {
|
|
|
167
167
|
// Status
|
|
168
168
|
expressRes.status(response.statusCode);
|
|
169
169
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
// Headers stashed by middleware during the request pipeline
|
|
171
|
+
// (e.g. CorsMiddleware → _corsHeaders, ThrottleMiddleware → _rateLimitHeaders)
|
|
172
|
+
const corsHeaders = expressRes.req?._corsHeaders;
|
|
173
|
+
const rateLimitHeaders = expressRes.req?._rateLimitHeaders;
|
|
174
|
+
for (const map of [corsHeaders, rateLimitHeaders]) {
|
|
175
|
+
if (map) {
|
|
176
|
+
for (const [name, value] of Object.entries(map)) {
|
|
177
|
+
expressRes.setHeader(name, value);
|
|
178
|
+
}
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
@@ -11,6 +11,7 @@ const MillasResponse = require('../http/MillasResponse');
|
|
|
11
11
|
class CorsMiddleware extends Middleware {
|
|
12
12
|
constructor(options = {}) {
|
|
13
13
|
super();
|
|
14
|
+
|
|
14
15
|
this.origins = options.origins || ['*'];
|
|
15
16
|
this.methods = options.methods || ['GET','POST','PUT','PATCH','DELETE','OPTIONS'];
|
|
16
17
|
this.headers = options.headers || ['Content-Type','Authorization','X-Requested-With'];
|
|
@@ -23,7 +24,9 @@ class CorsMiddleware extends Middleware {
|
|
|
23
24
|
|
|
24
25
|
// Build headers map
|
|
25
26
|
const h = {};
|
|
27
|
+
|
|
26
28
|
if (this.origins.includes('*')) {
|
|
29
|
+
|
|
27
30
|
h['Access-Control-Allow-Origin'] = '*';
|
|
28
31
|
} else if (origin && this.origins.includes(origin)) {
|
|
29
32
|
h['Access-Control-Allow-Origin'] = origin;
|
|
@@ -42,7 +42,7 @@ class ThrottleMiddleware extends Middleware {
|
|
|
42
42
|
return new ThrottleMiddleware({ max, window: minutes * 60 });
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async handle(req, next) {
|
|
45
|
+
async handle({ req }, next) {
|
|
46
46
|
const key = this.keyBy(req);
|
|
47
47
|
const now = Date.now();
|
|
48
48
|
let record = this._store.get(key);
|
|
@@ -57,12 +57,15 @@ class ThrottleMiddleware extends Middleware {
|
|
|
57
57
|
const remaining = Math.max(0, this.max - record.count);
|
|
58
58
|
const resetIn = Math.ceil((record.resetAt - now) / 1000);
|
|
59
59
|
|
|
60
|
-
// Rate limit headers —
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
req.raw.
|
|
65
|
-
|
|
60
|
+
// Rate limit headers — set on the raw Express res since we don't have
|
|
61
|
+
// the final MillasResponse yet. Present on all responses from throttled routes.
|
|
62
|
+
// Stash rate-limit headers on the raw request so ExpressAdapter.dispatch()
|
|
63
|
+
// can apply them to the final MillasResponse — same pattern as CorsMiddleware.
|
|
64
|
+
req.raw._rateLimitHeaders = {
|
|
65
|
+
'X-RateLimit-Limit': String(this.max),
|
|
66
|
+
'X-RateLimit-Remaining': String(remaining),
|
|
67
|
+
'X-RateLimit-Reset': String(Math.ceil(record.resetAt / 1000)),
|
|
68
|
+
};
|
|
66
69
|
|
|
67
70
|
if (record.count > this.max) {
|
|
68
71
|
return jsonify({
|
package/src/orm/model/Model.js
CHANGED
|
@@ -198,6 +198,18 @@ class Model {
|
|
|
198
198
|
/** Define relations: static relations = { author: new BelongsTo(...) } */
|
|
199
199
|
static relations = {};
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Fields always excluded from toJSON() — the universal safety net.
|
|
203
|
+
* Applied everywhere a model is serialized: API responses, logs, admin.
|
|
204
|
+
* Individual models extend this list for their own sensitive fields.
|
|
205
|
+
*
|
|
206
|
+
* // In your User model:
|
|
207
|
+
* static hidden = ['password', 'remember_token', 'two_factor_secret'];
|
|
208
|
+
*
|
|
209
|
+
* Default covers the two fields that should never leak anywhere.
|
|
210
|
+
*/
|
|
211
|
+
static hidden = ['password', 'remember_token'];
|
|
212
|
+
|
|
201
213
|
// ─── Lifecycle hooks (override in subclass) ───────────────────────────────
|
|
202
214
|
|
|
203
215
|
static async beforeCreate(data) { return data; }
|
|
@@ -724,9 +736,10 @@ class Model {
|
|
|
724
736
|
get isTrashed() { return !!this.deleted_at; }
|
|
725
737
|
|
|
726
738
|
toJSON() {
|
|
739
|
+
const hidden = new Set(this.constructor.hidden || []);
|
|
727
740
|
const obj = {};
|
|
728
741
|
for (const key of Object.keys(this)) {
|
|
729
|
-
if (!key.startsWith('_') && typeof this[key] !== 'function') {
|
|
742
|
+
if (!key.startsWith('_') && typeof this[key] !== 'function' && !hidden.has(key)) {
|
|
730
743
|
obj[key] = this[key];
|
|
731
744
|
}
|
|
732
745
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ServiceProvider = require('./ServiceProvider');
|
|
4
|
+
const { EncrypterManager } = require('../encryption/Encrypter');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* EncryptionServiceProvider
|
|
8
|
+
*
|
|
9
|
+
* Registers the Encrypter as a singleton in the DI container.
|
|
10
|
+
* Reads APP_KEY and (optionally) MILLAS_CIPHER from config or environment.
|
|
11
|
+
*
|
|
12
|
+
* ── Registration ──────────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Add to bootstrap/app.js:
|
|
15
|
+
*
|
|
16
|
+
* const { EncryptionServiceProvider } = require('millas/providers/EncryptionServiceProvider');
|
|
17
|
+
*
|
|
18
|
+
* app.providers([
|
|
19
|
+
* EncryptionServiceProvider,
|
|
20
|
+
* // ... other providers
|
|
21
|
+
* ]);
|
|
22
|
+
*
|
|
23
|
+
* ── Configuration ─────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* The provider resolves keys in this order:
|
|
26
|
+
*
|
|
27
|
+
* 1. config/app.js → { key: '...', cipher: 'AES-256-CBC' }
|
|
28
|
+
* 2. Environment variables → APP_KEY, MILLAS_CIPHER
|
|
29
|
+
* 3. Defaults → cipher: 'AES-256-CBC'
|
|
30
|
+
*
|
|
31
|
+
* Example config/app.js:
|
|
32
|
+
*
|
|
33
|
+
* module.exports = {
|
|
34
|
+
* key: process.env.APP_KEY,
|
|
35
|
+
* cipher: 'AES-256-CBC', // optional — default
|
|
36
|
+
* };
|
|
37
|
+
*
|
|
38
|
+
* ── Key generation ─────────────────────────────────────────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* Generate a key for your .env file:
|
|
41
|
+
*
|
|
42
|
+
* const { Encrypter } = require('millas/encryption/Encrypter');
|
|
43
|
+
* console.log(Encrypter.generateKey('AES-256-CBC'));
|
|
44
|
+
* // → 'base64:...' ← paste this as APP_KEY=
|
|
45
|
+
*/
|
|
46
|
+
class EncryptionServiceProvider extends ServiceProvider {
|
|
47
|
+
register(container) {
|
|
48
|
+
container.singleton('encrypter', () => {
|
|
49
|
+
const basePath = (() => { try { return container.make('basePath'); } catch { return process.cwd(); } })();
|
|
50
|
+
|
|
51
|
+
let appConfig = {};
|
|
52
|
+
try { appConfig = require(basePath + '/config/app'); } catch { /* no config/app.js */ }
|
|
53
|
+
|
|
54
|
+
return new EncrypterManager({
|
|
55
|
+
key: appConfig.key || process.env.APP_KEY || '',
|
|
56
|
+
cipher: appConfig.cipher || process.env.MILLAS_CIPHER || 'AES-256-CBC',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Aliases — 'encrypter', 'Encrypter', and 'crypt' all resolve to the same binding
|
|
61
|
+
container.alias('Encrypter', 'encrypter');
|
|
62
|
+
container.alias('crypt', 'encrypter');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = EncryptionServiceProvider;
|
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* MiddlewareRegistry
|
|
5
5
|
*
|
|
6
|
-
* Maps string aliases → middleware
|
|
6
|
+
* Maps string aliases → Millas middleware classes or instances.
|
|
7
|
+
* Resolution produces adapter-native handler functions via the adapter,
|
|
8
|
+
* so this class has zero knowledge of Express (or any HTTP engine).
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* MiddlewareRegistry.register('throttle', ThrottleMiddleware);
|
|
11
|
-
*
|
|
12
|
-
* Registered automatically by AppServiceProvider (Phase 3+).
|
|
10
|
+
* The adapter is injected at resolution time (not construction time)
|
|
11
|
+
* so the registry can be built before the adapter exists.
|
|
13
12
|
*/
|
|
14
13
|
class MiddlewareRegistry {
|
|
15
14
|
constructor() {
|
|
@@ -18,24 +17,31 @@ class MiddlewareRegistry {
|
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
19
|
* Register a middleware alias.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
*
|
|
21
|
+
* registry.register('auth', AuthMiddleware)
|
|
22
|
+
* registry.register('throttle', new ThrottleMiddleware({ max: 60 }))
|
|
23
23
|
*/
|
|
24
24
|
register(alias, handler) {
|
|
25
25
|
this._map[alias] = handler;
|
|
26
|
+
return this;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* Resolve a
|
|
30
|
-
* Supports
|
|
30
|
+
* Resolve a middleware alias or class/instance into an adapter-native handler.
|
|
31
|
+
* Supports parameterized aliases: 'throttle:60,1' → 60 req per 1 minute.
|
|
31
32
|
*
|
|
32
|
-
* @param {string|Function} aliasOrFn
|
|
33
|
-
* @
|
|
33
|
+
* @param {string|Function|object} aliasOrFn
|
|
34
|
+
* @param {import('../http/adapters/HttpAdapter')} adapter
|
|
35
|
+
* @param {object|null} container
|
|
36
|
+
* @returns {Function} adapter-native handler
|
|
34
37
|
*/
|
|
35
|
-
resolve(aliasOrFn) {
|
|
36
|
-
|
|
38
|
+
resolve(aliasOrFn, adapter, container = null) {
|
|
39
|
+
// If it's already a function, pass through
|
|
40
|
+
if (typeof aliasOrFn === 'function') {
|
|
41
|
+
return aliasOrFn;
|
|
42
|
+
}
|
|
37
43
|
|
|
38
|
-
// Parse
|
|
44
|
+
// Parse parameterized alias: 'throttle:60,1' → alias='throttle', params=['60','1']
|
|
39
45
|
let alias = aliasOrFn;
|
|
40
46
|
let params = [];
|
|
41
47
|
if (typeof aliasOrFn === 'string' && aliasOrFn.includes(':')) {
|
|
@@ -49,50 +55,26 @@ class MiddlewareRegistry {
|
|
|
49
55
|
throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (typeof Handler === 'function' && Handler.prototype &&
|
|
55
|
-
typeof Handler.prototype.handle === 'function') {
|
|
56
|
-
const instance = typeof Handler.fromParams === 'function'
|
|
57
|
-
? Handler.fromParams(params)
|
|
58
|
-
: new Handler(...params);
|
|
59
|
-
return (req, res, next) => {
|
|
60
|
-
const result = instance.handle(req, res, next);
|
|
61
|
-
if (result && typeof result.catch === 'function') result.catch(next);
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Pre-instantiated object with handle() method (e.g. new ThrottleMiddleware())
|
|
67
|
-
if (typeof Handler === 'object' && Handler !== null && typeof Handler.handle === 'function') {
|
|
68
|
-
return (req, res, next) => {
|
|
69
|
-
const result = Handler.handle(req, res, next);
|
|
70
|
-
if (result && typeof result.catch === 'function') result.catch(next);
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Class with handle() on prototype
|
|
75
|
-
if (typeof Handler === 'function' && Handler.prototype && typeof Handler.prototype.handle === 'function') {
|
|
76
|
-
const instance = new Handler();
|
|
77
|
-
return (req, res, next) => {
|
|
78
|
-
const result = instance.handle(req, res, next);
|
|
79
|
-
if (result && typeof result.catch === 'function') result.catch(next);
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Raw Express function
|
|
84
|
-
if (typeof Handler === 'function') return Handler;
|
|
58
|
+
return this._wrap(Handler, adapter, container, params);
|
|
59
|
+
}
|
|
85
60
|
|
|
86
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Resolve all aliases in a list.
|
|
63
|
+
*/
|
|
64
|
+
resolveAll(list = [], adapter, container = null) {
|
|
65
|
+
return list.map(m => this.resolve(m, adapter, container));
|
|
87
66
|
}
|
|
88
67
|
|
|
89
68
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* @returns {Function[]}
|
|
69
|
+
* Return a no-op passthrough handler for the given adapter.
|
|
70
|
+
* Used when a middleware alias is missing but should not crash the app.
|
|
93
71
|
*/
|
|
94
|
-
|
|
95
|
-
return
|
|
72
|
+
resolvePassthrough(adapter) {
|
|
73
|
+
// Adapter-agnostic: return a function matching the native signature
|
|
74
|
+
// by asking the adapter to wrap a no-op middleware instance.
|
|
75
|
+
return adapter.wrapMiddleware({
|
|
76
|
+
handle: (_ctx, next) => next(),
|
|
77
|
+
}, null);
|
|
96
78
|
}
|
|
97
79
|
|
|
98
80
|
has(alias) {
|
|
@@ -102,6 +84,49 @@ class MiddlewareRegistry {
|
|
|
102
84
|
all() {
|
|
103
85
|
return { ...this._map };
|
|
104
86
|
}
|
|
87
|
+
|
|
88
|
+
// ── Internal ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
_wrap(Handler, adapter, container, params = []) {
|
|
91
|
+
// If params provided, instantiate with fromParams() or constructor
|
|
92
|
+
if (params.length > 0) {
|
|
93
|
+
if (
|
|
94
|
+
typeof Handler === 'function' &&
|
|
95
|
+
Handler.prototype &&
|
|
96
|
+
typeof Handler.prototype.handle === 'function'
|
|
97
|
+
) {
|
|
98
|
+
const instance = typeof Handler.fromParams === 'function'
|
|
99
|
+
? Handler.fromParams(params)
|
|
100
|
+
: new Handler(...params);
|
|
101
|
+
return adapter.wrapMiddleware(instance, container);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pre-instantiated Millas middleware object with handle()
|
|
106
|
+
if (
|
|
107
|
+
typeof Handler === 'object' &&
|
|
108
|
+
Handler !== null &&
|
|
109
|
+
typeof Handler.handle === 'function'
|
|
110
|
+
) {
|
|
111
|
+
return adapter.wrapMiddleware(Handler, container);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Millas middleware class (handle on prototype)
|
|
115
|
+
if (
|
|
116
|
+
typeof Handler === 'function' &&
|
|
117
|
+
Handler.prototype &&
|
|
118
|
+
typeof Handler.prototype.handle === 'function'
|
|
119
|
+
) {
|
|
120
|
+
return adapter.wrapMiddleware(new Handler(), container);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Raw adapter-native function — pass through as-is (escape hatch)
|
|
124
|
+
if (typeof Handler === 'function') {
|
|
125
|
+
return Handler;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new Error('Middleware must be a function or a class with handle().');
|
|
129
|
+
}
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
module.exports = MiddlewareRegistry;
|
package/src/router/Route.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const RouteGroup
|
|
3
|
+
const RouteGroup = require('./RouteGroup');
|
|
4
4
|
const RouteRegistry = require('./RouteRegistry');
|
|
5
|
+
const RouteEntry = require('./RouteEntry');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Route
|
|
@@ -170,15 +171,19 @@ class Route {
|
|
|
170
171
|
|
|
171
172
|
const entry = {
|
|
172
173
|
verb,
|
|
173
|
-
path:
|
|
174
|
+
path: fullPath,
|
|
174
175
|
handler,
|
|
175
176
|
method, // string method name OR raw function
|
|
176
177
|
middleware,
|
|
177
178
|
name,
|
|
179
|
+
shape: null, // populated by RouteEntry.shape() / .fromShape()
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
this._registry.register(entry);
|
|
181
|
-
|
|
183
|
+
// Return a RouteEntry so .shape() / .fromShape() can be chained.
|
|
184
|
+
// The RouteEntry holds a reference back to this Route so group-level
|
|
185
|
+
// calls (which don't use the returned value) still work normally.
|
|
186
|
+
return new RouteEntry(entry, this);
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
_mergeGroupStack() {
|
|
@@ -252,4 +257,4 @@ class RouteGroupBuilder {
|
|
|
252
257
|
}
|
|
253
258
|
}
|
|
254
259
|
|
|
255
|
-
module.exports = Route;
|
|
260
|
+
module.exports = Route;
|