millas 0.2.12-beta-1 → 0.2.13-beta

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Millas framework default translatable strings.
5
+ *
6
+ * These are the strings used internally by Millas — validation messages,
7
+ * auth errors, admin panel labels, migration prompts, etc.
8
+ *
9
+ * When a developer runs `millas lang:publish sw --defaults`, these keys
10
+ * are merged into their locale file alongside their own app strings.
11
+ *
12
+ * Organised by module so translators know the context.
13
+ * All values are the English source text — same key = same value for 'en'.
14
+ *
15
+ * Format:
16
+ * 'key': 'English source text' — simple string
17
+ * 'key': ['singular', 'plural'] — plural form
18
+ * 'context|key': 'English source text' — contextual
19
+ */
20
+ module.exports = {
21
+
22
+ // ── Validation ─────────────────────────────────────────────────────────
23
+ 'This field is required.': 'This field is required.',
24
+ 'Maximum {max} characters allowed.': 'Maximum {max} characters allowed.',
25
+ 'Must be a whole number.': 'Must be a whole number.',
26
+ 'Must be a number.': 'Must be a number.',
27
+ 'Must be a valid email address.': 'Must be a valid email address.',
28
+ 'Must be a valid URL.': 'Must be a valid URL.',
29
+ 'Must be a valid date (YYYY-MM-DD).': 'Must be a valid date (YYYY-MM-DD).',
30
+ 'Must be valid JSON.': 'Must be valid JSON.',
31
+ 'Must be a valid UUID.': 'Must be a valid UUID.',
32
+ 'Please select a value.': 'Please select a value.',
33
+ 'Please select a related record.': 'Please select a related record.',
34
+ 'Invalid value. Must be one of: {values}': 'Invalid value. Must be one of: {values}',
35
+ 'Password must be at least 8 characters.': 'Password must be at least 8 characters.',
36
+
37
+ // ── Auth ───────────────────────────────────────────────────────────────
38
+ 'Email is required': 'Email is required',
39
+ 'Password is required': 'Password is required',
40
+ 'Invalid email or password': 'Invalid email or password',
41
+ 'Your account has been deactivated': 'Your account has been deactivated',
42
+ 'Email already in use': 'Email already in use',
43
+ 'Passwords do not match': 'Passwords do not match',
44
+ 'Token has expired': 'Token has expired',
45
+ 'Invalid token': 'Invalid token',
46
+ 'You are not authorized to perform this action': 'You are not authorized to perform this action',
47
+
48
+ // ── HTTP errors ────────────────────────────────────────────────────────
49
+ 'Not Found': 'Not Found',
50
+ 'Unauthorized': 'Unauthorized',
51
+ 'Forbidden': 'Forbidden',
52
+ 'Unprocessable Entity': 'Unprocessable Entity',
53
+ 'Internal Server Error': 'Internal Server Error',
54
+ 'Too Many Requests': 'Too Many Requests',
55
+ '{model} #{id} not found': '{model} #{id} not found',
56
+
57
+ // ── Pagination ─────────────────────────────────────────────────────────
58
+ 'Showing {from} to {to} of {total} results': 'Showing {from} to {to} of {total} results',
59
+ 'Previous': 'Previous',
60
+ 'Next': 'Next',
61
+ 'Page {page} of {total}': 'Page {page} of {total}',
62
+
63
+ // ── CRUD ───────────────────────────────────────────────────────────────
64
+ '{model} created successfully': '{model} created successfully',
65
+ '{model} updated successfully': '{model} updated successfully',
66
+ '{model} deleted': '{model} deleted',
67
+ 'No records found': 'No records found',
68
+ 'Record not found': 'Record not found',
69
+
70
+ // ── Plural CRUD ────────────────────────────────────────────────────────
71
+ '{count} record deleted': ['{count} record deleted', '{count} records deleted'],
72
+ '{count} record updated': ['{count} record updated', '{count} records updated'],
73
+
74
+ // ── Admin panel ────────────────────────────────────────────────────────
75
+ 'Dashboard': 'Dashboard',
76
+ 'Search': 'Search',
77
+ 'Filters': 'Filters',
78
+ 'Actions': 'Actions',
79
+ 'Export CSV': 'Export CSV',
80
+ 'Export JSON': 'Export JSON',
81
+ 'Save': 'Save',
82
+ 'Save and continue editing': 'Save and continue editing',
83
+ 'Save and add another': 'Save and add another',
84
+ 'Delete': 'Delete',
85
+ 'Cancel': 'Cancel',
86
+ 'Edit': 'Edit',
87
+ 'Add {model}': 'Add {model}',
88
+ 'Change {model}': 'Change {model}',
89
+ 'Delete {model}': 'Delete {model}',
90
+ 'Are you sure you want to delete {label}?': 'Are you sure you want to delete {label}?',
91
+ 'This action cannot be undone.': 'This action cannot be undone.',
92
+ 'No {model} yet': 'No {model} yet',
93
+ 'Select all': 'Select all',
94
+ 'Deselect all': 'Deselect all',
95
+
96
+ // ── Admin status badges (contextual) ───────────────────────────────────
97
+ 'status|active': 'Active',
98
+ 'status|inactive': 'Inactive',
99
+ 'status|pending': 'Pending',
100
+ 'status|approved': 'Approved',
101
+ 'status|rejected': 'Rejected',
102
+ 'status|draft': 'Draft',
103
+ 'status|published': 'Published',
104
+
105
+ // ── Admin login ────────────────────────────────────────────────────────
106
+ 'Sign in to your account': 'Sign in to your account',
107
+ 'Email address': 'Email address',
108
+ 'Password': 'Password',
109
+ 'Remember me': 'Remember me',
110
+ 'Sign in': 'Sign in',
111
+ 'Sign out': 'Sign out',
112
+ 'You have been logged out.': 'You have been logged out.',
113
+
114
+ // ── Migration prompts ──────────────────────────────────────────────────
115
+ 'No changes detected.': 'No changes detected.',
116
+ 'Migrations generated:': 'Migrations generated:',
117
+ 'Run: millas migrate to apply.': 'Run: millas migrate to apply.',
118
+
119
+ // ── Queue ──────────────────────────────────────────────────────────────
120
+ 'Job failed: {message}': 'Job failed: {message}',
121
+ 'Job completed: {job}': 'Job completed: {job}',
122
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const Translator = require('./Translator');
4
+
5
+ /**
6
+ * Trans — global translation singleton + shorthand helpers.
7
+ *
8
+ * This is the object you import in controllers, models, views, and commands.
9
+ * It wraps the Translator instance with the Django-style shorthand functions.
10
+ *
11
+ * ── Import styles ──────────────────────────────────────────────────────────
12
+ *
13
+ * // Named destructuring (recommended)
14
+ * const { __, _n, _p, _f } = require('millas/src/i18n');
15
+ *
16
+ * // Full facade for locale control
17
+ * const { Trans } = require('millas/src/i18n');
18
+ * Trans.setLocale('sw');
19
+ *
20
+ * ── API ────────────────────────────────────────────────────────────────────
21
+ *
22
+ * __('Hello')
23
+ * → 'Habari' (when locale=sw)
24
+ * → 'Hello' (when locale=en or no translation found)
25
+ *
26
+ * _n('You have %d message', 'You have %d messages', 3)
27
+ * → 'Una ujumbe 3'
28
+ *
29
+ * _p('menu', 'File')
30
+ * → 'Faili' (contextual — same word, different contexts)
31
+ *
32
+ * _f('Welcome, {name}!', { name: 'Alice' })
33
+ * → 'Karibu, Alice!'
34
+ *
35
+ * _f('Hello %s, you have %d items', 'Alice', 5)
36
+ * → 'Habari Alice, una vitu 5'
37
+ *
38
+ * _fn('You have %d item', 'You have %d items', 3)
39
+ * → 'Una vitu 3'
40
+ *
41
+ * ── In templates (Nunjucks) ────────────────────────────────────────────────
42
+ *
43
+ * Add the filters via I18nServiceProvider (done automatically):
44
+ *
45
+ * {{ 'Hello' | __ }}
46
+ * {{ 'You have %d messages' | _n(count) }}
47
+ * {{ 'Welcome, {name}!' | _f({ name: user.name }) }}
48
+ *
49
+ * ── In CLI commands ────────────────────────────────────────────────────────
50
+ *
51
+ * const { __ } = require('millas/src/i18n');
52
+ * console.log(__('Migrations applied successfully.'));
53
+ */
54
+
55
+ // ── Singleton Translator instance ────────────────────────────────────────────
56
+ const Trans = new Translator();
57
+
58
+ // ── Shorthand functions ───────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * gettext — translate a string.
62
+ *
63
+ * __('Hello')
64
+ * __('Hello', 'sw') // explicit locale override
65
+ *
66
+ * @param {string} key
67
+ * @param {string} [locale]
68
+ * @returns {string}
69
+ */
70
+ function __(key, locale) {
71
+ return Trans.translate(key, locale);
72
+ }
73
+
74
+ /**
75
+ * ngettext — singular/plural translation.
76
+ *
77
+ * _n('You have %d message', 'You have %d messages', count)
78
+ *
79
+ * Note: this does NOT interpolate %d — call _fn() if you also need
80
+ * the count substituted into the string.
81
+ *
82
+ * @param {string} singular
83
+ * @param {string} plural
84
+ * @param {number} count
85
+ * @param {string} [locale]
86
+ * @returns {string}
87
+ */
88
+ function _n(singular, plural, count, locale) {
89
+ return Trans.ngettext(singular, plural, count, locale);
90
+ }
91
+
92
+ /**
93
+ * pgettext — contextual translation.
94
+ *
95
+ * _p('menu', 'File') // the menu item "File"
96
+ * _p('action', 'File') // the action "to file something"
97
+ *
98
+ * @param {string} context
99
+ * @param {string} key
100
+ * @param {string} [locale]
101
+ * @returns {string}
102
+ */
103
+ function _p(context, key, locale) {
104
+ return Trans.pgettext(context, key, locale);
105
+ }
106
+
107
+ /**
108
+ * format — translate + interpolate named or positional variables.
109
+ *
110
+ * _f('Welcome, {name}!', { name: 'Alice' })
111
+ * _f('Hello %s, you have %d items', 'Alice', 5)
112
+ *
113
+ * @param {string} key
114
+ * @param {...*} args
115
+ * @returns {string}
116
+ */
117
+ function _f(key, ...args) {
118
+ return Trans.format(key, ...args);
119
+ }
120
+
121
+ /**
122
+ * nformat — plural translation + interpolation.
123
+ *
124
+ * _fn('You have %d item', 'You have %d items', count)
125
+ * // → 'You have 3 items' (count is auto-interpolated as first %d)
126
+ *
127
+ * @param {string} singular
128
+ * @param {string} plural
129
+ * @param {number} count
130
+ * @param {...*} args — additional interpolation values after count
131
+ * @returns {string}
132
+ */
133
+ function _fn(singular, plural, count, ...args) {
134
+ return Trans.nformat(singular, plural, count, ...args);
135
+ }
136
+
137
+ /**
138
+ * lazy — returns a lazy-evaluated translation proxy.
139
+ *
140
+ * Useful when the translation needs to be stored before the locale is set
141
+ * (e.g. in class-level constants, model field labels).
142
+ *
143
+ * const label = lazy__('Email address');
144
+ * // label() → 'Anwani ya barua pepe' (evaluated at call time)
145
+ *
146
+ * @param {string} key
147
+ * @returns {Function}
148
+ */
149
+ function lazy__(key) {
150
+ return () => Trans.translate(key);
151
+ }
152
+
153
+ module.exports = {
154
+ // Singleton for locale control + advanced usage
155
+ Trans,
156
+
157
+ // Shorthand functions
158
+ __,
159
+ _n,
160
+ _p,
161
+ _f,
162
+ _fn,
163
+ lazy__,
164
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * English (en) — source language catalogue.
5
+ *
6
+ * For the source language this file acts as documentation:
7
+ * it lists every translatable string in the application.
8
+ * Values are the same as keys — no actual translation needed.
9
+ *
10
+ * Other locale files only need to include keys where the translation
11
+ * differs. Missing keys fall back to this file, then to the raw key.
12
+ *
13
+ * ── Auth ──────────────────────────────────────────────────────────────────
14
+ */
15
+ module.exports = {
16
+
17
+ // ── Auth / validation ────────────────────────────────────────────────────
18
+ 'Email is required': 'Email is required',
19
+ 'Password is required': 'Password is required',
20
+ 'Invalid email or password': 'Invalid email or password',
21
+ 'Your account has been deactivated': 'Your account has been deactivated',
22
+ 'Email already in use': 'Email already in use',
23
+ 'Password must be at least 8 characters': 'Password must be at least 8 characters',
24
+ 'Passwords do not match': 'Passwords do not match',
25
+
26
+ // ── CRUD messages ────────────────────────────────────────────────────────
27
+ '%s created successfully': '%s created successfully',
28
+ '%s updated successfully': '%s updated successfully',
29
+ '%s deleted': '%s deleted',
30
+ 'No records found': 'No records found',
31
+ 'Record not found': 'Record not found',
32
+
33
+ // ── Pagination ───────────────────────────────────────────────────────────
34
+ 'Showing %d to %d of %d results': 'Showing %d to %d of %d results',
35
+ 'Previous': 'Previous',
36
+ 'Next': 'Next',
37
+
38
+ // ── Plural examples ──────────────────────────────────────────────────────
39
+ // Array format: [singular_form, plural_form, ...]
40
+ 'You have %d message': ['You have %d message', 'You have %d messages'],
41
+ '%d item selected': ['%d item selected', '%d items selected'],
42
+ '%d record deleted': ['%d record deleted', '%d records deleted'],
43
+
44
+ // ── Contextual examples (context|key) ────────────────────────────────────
45
+ 'menu|File': 'File',
46
+ 'menu|Edit': 'Edit',
47
+ 'status|active': 'Active',
48
+ 'status|inactive': 'Inactive',
49
+ 'status|pending': 'Pending',
50
+
51
+ // ── Named interpolation examples ─────────────────────────────────────────
52
+ 'Welcome, {name}!': 'Welcome, {name}!',
53
+ 'Hello, {name}. You have {count} notifications.':
54
+ 'Hello, {name}. You have {count} notifications.',
55
+ };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swahili (sw) translation catalogue.
5
+ *
6
+ * Only include keys where the translation differs from the source.
7
+ * Missing keys automatically fall back to the fallback locale (en).
8
+ */
9
+ module.exports = {
10
+
11
+ // ── Auth / validation ────────────────────────────────────────────────────
12
+ 'Email is required': 'Barua pepe inahitajika',
13
+ 'Password is required': 'Nywila inahitajika',
14
+ 'Invalid email or password': 'Barua pepe au nywila si sahihi',
15
+ 'Your account has been deactivated': 'Akaunti yako imezimwa',
16
+ 'Email already in use': 'Barua pepe hiyo tayari inatumika',
17
+ 'Password must be at least 8 characters': 'Nywila lazima iwe na herufi angalau 8',
18
+ 'Passwords do not match': 'Nywila hazifanani',
19
+
20
+ // ── CRUD messages ────────────────────────────────────────────────────────
21
+ '%s created successfully': '%s imeundwa kikamilifu',
22
+ '%s updated successfully': '%s imesasishwa kikamilifu',
23
+ '%s deleted': '%s imefutwa',
24
+ 'No records found': 'Hakuna rekodi zilizopatikana',
25
+ 'Record not found': 'Rekodi haijapatikana',
26
+
27
+ // ── Pagination ───────────────────────────────────────────────────────────
28
+ 'Showing %d to %d of %d results': 'Inaonyesha %d hadi %d kati ya %d matokeo',
29
+ 'Previous': 'Iliyotangulia',
30
+ 'Next': 'Inayofuata',
31
+
32
+ // ── Plural forms (Swahili uses same form for all counts) ─────────────────
33
+ 'You have %d message': ['Una ujumbe %d', 'Una ujumbe %d'],
34
+ '%d item selected': ['%d kipengele kimechaguliwa', '%d vipengele vimechaguliwa'],
35
+ '%d record deleted': ['Rekodi %d imefutwa', 'Rekodi %d zimefutwa'],
36
+
37
+ // ── Contextual ───────────────────────────────────────────────────────────
38
+ 'menu|File': 'Faili',
39
+ 'menu|Edit': 'Hariri',
40
+ 'status|active': 'Amilifu',
41
+ 'status|inactive':'Haifanyi kazi',
42
+ 'status|pending': 'Inasubiri',
43
+
44
+ // ── Named interpolation ──────────────────────────────────────────────────
45
+ 'Welcome, {name}!': 'Karibu, {name}!',
46
+ 'Hello, {name}. You have {count} notifications.':
47
+ 'Habari {name}. Una arifa {count}.',
48
+ };
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LogRedactor
5
+ *
6
+ * Scrubs sensitive field values from log context objects before they
7
+ * are serialised and written to any log channel.
8
+ *
9
+ * ── Why this matters ─────────────────────────────────────────────────────────
10
+ *
11
+ * Log.d('Auth', 'Login', { email, password }); // password logged ✗
12
+ * Log.d('AI', 'Config', this._config); // API keys logged ✗
13
+ * Log.d('HTTP', 'Request', req.body); // form fields logged ✗
14
+ *
15
+ * With redaction enabled (default):
16
+ * Log.d('Auth', 'Login', { email, password });
17
+ * → { email: 'alice@example.com', password: '[REDACTED]' }
18
+ *
19
+ * ── Default sensitive field names ────────────────────────────────────────────
20
+ *
21
+ * password, passwd, secret, token, apikey, api_key, authorization,
22
+ * cookie, access_token, refresh_token, private_key, private, credential,
23
+ * ssn, credit_card, card_number, cvv, pin
24
+ *
25
+ * Matching is case-insensitive and substring-based:
26
+ * 'userPassword' matches 'password'
27
+ * 'X-API-Key' matches 'apikey'
28
+ * 'AUTHORIZATION' matches 'authorization'
29
+ *
30
+ * ── Configuration ─────────────────────────────────────────────────────────────
31
+ *
32
+ * // Add custom sensitive field names (globally):
33
+ * LogRedactor.addSensitiveKeys(['mpesa_pin', 'stk_passkey', 'webhook_secret']);
34
+ *
35
+ * // Replace the entire list:
36
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_secret']);
37
+ *
38
+ * // Change the redaction placeholder:
39
+ * LogRedactor.setPlaceholder('***');
40
+ *
41
+ * // Disable redaction entirely (not recommended for production):
42
+ * LogRedactor.disable();
43
+ *
44
+ * ── Integration ───────────────────────────────────────────────────────────────
45
+ *
46
+ * Redaction is applied automatically inside every formatter's format() call.
47
+ * No action needed — it is on by default.
48
+ */
49
+
50
+ // ── Default sensitive key fragments ───────────────────────────────────────────
51
+
52
+ const DEFAULT_KEYS = [
53
+ 'password', 'passwd', 'pass',
54
+ 'secret',
55
+ 'token',
56
+ 'apikey', 'api_key',
57
+ 'authorization', 'auth',
58
+ 'cookie',
59
+ 'access_token', 'accesstoken',
60
+ 'refresh_token', 'refreshtoken',
61
+ 'private_key', 'privatekey', 'private',
62
+ 'credential', 'credentials',
63
+ 'ssn',
64
+ 'credit_card', 'creditcard', 'card_number', 'cardnumber',
65
+ 'cvv', 'cvc',
66
+ 'pin',
67
+ 'passphrase',
68
+ 'webhook_secret', 'signing_secret',
69
+ ];
70
+
71
+ // ── Module-level state ────────────────────────────────────────────────────────
72
+
73
+ let _sensitiveKeys = [...DEFAULT_KEYS];
74
+ let _placeholder = '[REDACTED]';
75
+ let _enabled = true;
76
+ let _cachedLower = null; // lazily built lowercased copy
77
+
78
+ function _getLower() {
79
+ if (!_cachedLower) _cachedLower = _sensitiveKeys.map(k => k.toLowerCase());
80
+ return _cachedLower;
81
+ }
82
+
83
+ function _invalidateCache() {
84
+ _cachedLower = null;
85
+ }
86
+
87
+ // ── Core redaction ────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Check if a key name contains any sensitive fragment.
91
+ *
92
+ * @param {string} key
93
+ * @returns {boolean}
94
+ */
95
+ function isSensitiveKey(key) {
96
+ const lower = String(key).toLowerCase().replace(/[-_\s]/g, '');
97
+ const frags = _getLower().map(k => k.replace(/[-_\s]/g, ''));
98
+ return frags.some(frag => lower.includes(frag));
99
+ }
100
+
101
+ /**
102
+ * Redact sensitive values from an object (shallow or deep).
103
+ * Returns a new object — never mutates the original.
104
+ *
105
+ * Handles:
106
+ * - Plain objects (nested recursively up to depth 10)
107
+ * - Arrays (each element redacted)
108
+ * - Primitives (returned as-is unless the key is sensitive)
109
+ * - Circular references (detected and replaced with '[Circular]')
110
+ *
111
+ * @param {*} value — the context value to redact
112
+ * @param {number} [depth] — internal recursion counter
113
+ * @param {Set} [seen] — internal circular reference tracker
114
+ * @returns {*}
115
+ */
116
+ function redact(value, depth = 0, seen = new Set()) {
117
+ if (!_enabled) return value;
118
+
119
+ // Depth guard — don't recurse into deeply nested structures
120
+ if (depth > 10) return value;
121
+
122
+ if (value === null || value === undefined) return value;
123
+ if (typeof value !== 'object') return value;
124
+
125
+ // Circular reference guard
126
+ if (seen.has(value)) return '[Circular]';
127
+ seen.add(value);
128
+
129
+ if (Array.isArray(value)) {
130
+ const result = value.map(item => redact(item, depth + 1, seen));
131
+ seen.delete(value);
132
+ return result;
133
+ }
134
+
135
+ const result = {};
136
+ for (const [k, v] of Object.entries(value)) {
137
+ if (isSensitiveKey(k)) {
138
+ result[k] = _placeholder;
139
+ } else if (v !== null && typeof v === 'object') {
140
+ result[k] = redact(v, depth + 1, seen);
141
+ } else {
142
+ result[k] = v;
143
+ }
144
+ }
145
+ seen.delete(value);
146
+ return result;
147
+ }
148
+
149
+ // ── LogRedactor class ─────────────────────────────────────────────────────────
150
+
151
+ class LogRedactor {
152
+ /**
153
+ * The default list of sensitive key fragments.
154
+ * Useful when callers want to extend it:
155
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_key']);
156
+ */
157
+ static get DEFAULT_KEYS() { return [...DEFAULT_KEYS]; }
158
+
159
+ /**
160
+ * Redact sensitive fields from a log context value.
161
+ * Non-objects are returned unchanged.
162
+ *
163
+ * @param {*} context
164
+ * @returns {*}
165
+ */
166
+ static redact(context) {
167
+ if (!_enabled) return context;
168
+ if (context === null || context === undefined) return context;
169
+ if (typeof context !== 'object') return context;
170
+ return redact(context);
171
+ }
172
+
173
+ /**
174
+ * Add extra sensitive key fragments to the global list.
175
+ *
176
+ * LogRedactor.addSensitiveKeys(['mpesa_pin', 'stk_passkey']);
177
+ *
178
+ * @param {string[]} keys
179
+ */
180
+ static addSensitiveKeys(keys) {
181
+ _sensitiveKeys = [...new Set([..._sensitiveKeys, ...keys.map(k => k.toLowerCase())])];
182
+ _invalidateCache();
183
+ }
184
+
185
+ /**
186
+ * Replace the entire sensitive key list.
187
+ *
188
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_secret']);
189
+ *
190
+ * @param {string[]} keys
191
+ */
192
+ static setSensitiveKeys(keys) {
193
+ _sensitiveKeys = keys.map(k => k.toLowerCase());
194
+ _invalidateCache();
195
+ }
196
+
197
+ /**
198
+ * Get a copy of the current sensitive key list.
199
+ *
200
+ * @returns {string[]}
201
+ */
202
+ static getSensitiveKeys() {
203
+ return [..._sensitiveKeys];
204
+ }
205
+
206
+ /**
207
+ * Change the redaction placeholder string.
208
+ * LogRedactor.setPlaceholder('***');
209
+ *
210
+ * @param {string} placeholder
211
+ */
212
+ static setPlaceholder(placeholder) {
213
+ _placeholder = String(placeholder);
214
+ }
215
+
216
+ /**
217
+ * Enable redaction (default).
218
+ */
219
+ static enable() {
220
+ _enabled = true;
221
+ }
222
+
223
+ /**
224
+ * Disable redaction. Use only in test environments where you need
225
+ * to inspect exact log context values.
226
+ */
227
+ static disable() {
228
+ _enabled = false;
229
+ }
230
+
231
+ /**
232
+ * Whether redaction is currently enabled.
233
+ */
234
+ static get enabled() { return _enabled; }
235
+
236
+ /**
237
+ * Check if a specific key would be redacted.
238
+ *
239
+ * LogRedactor.isSensitive('userPassword') // true
240
+ * LogRedactor.isSensitive('userId') // false
241
+ */
242
+ static isSensitive(key) {
243
+ return isSensitiveKey(key);
244
+ }
245
+ }
246
+
247
+ module.exports = { LogRedactor, redact, isSensitiveKey };
@@ -181,7 +181,7 @@ class Logger {
181
181
  ctx.slow = true;
182
182
  }
183
183
 
184
- self._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
184
+ self._log(level, `HTTP [${method}]`, `${url} ${status} ${ms}ms`);
185
185
  });
186
186
 
187
187
  next();