@intranefr/superbackend 1.4.4 → 1.5.1

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 (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,535 @@
1
+ const crypto = require('crypto');
2
+ const axios = require('axios');
3
+ const { VM } = require('vm2');
4
+
5
+ const ProxyEntry = require('../models/ProxyEntry');
6
+ const cacheLayer = require('./cacheLayer.service');
7
+ const rateLimiter = require('./rateLimiter.service');
8
+ const { logAuditSync } = require('./auditLogger');
9
+
10
+ function sha256(value) {
11
+ return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
12
+ }
13
+
14
+ function safeJsonParse(bufOrString) {
15
+ try {
16
+ if (Buffer.isBuffer(bufOrString)) {
17
+ return JSON.parse(bufOrString.toString('utf8'));
18
+ }
19
+ return JSON.parse(String(bufOrString || ''));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function normalizeForAudit(value, depth = 0) {
26
+ if (depth > 6) return null;
27
+ if (value === null || value === undefined) return value;
28
+ if (typeof value !== 'object') return value;
29
+
30
+ if (Array.isArray(value)) {
31
+ return {
32
+ __arrayLength: value.length,
33
+ items: value.length > 0 ? [normalizeForAudit(value[0], depth + 1)] : [],
34
+ };
35
+ }
36
+
37
+ const out = {};
38
+ for (const [k, v] of Object.entries(value)) {
39
+ out[k] = normalizeForAudit(v, depth + 1);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function stripHopByHopHeaders(headers) {
45
+ const hopByHop = new Set([
46
+ 'connection',
47
+ 'keep-alive',
48
+ 'proxy-authenticate',
49
+ 'proxy-authorization',
50
+ 'te',
51
+ 'trailer',
52
+ 'transfer-encoding',
53
+ 'upgrade',
54
+ 'host',
55
+ ]);
56
+
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(headers || {})) {
59
+ const key = String(k).toLowerCase();
60
+ if (hopByHop.has(key)) continue;
61
+ if (v === undefined) continue;
62
+ out[key] = v;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function applyHeaderPolicy(entry, incoming) {
68
+ const cfg = entry?.headers || {};
69
+ const allowList = Array.isArray(cfg.allowList) ? cfg.allowList.map((h) => String(h).toLowerCase()) : [];
70
+ const denyList = Array.isArray(cfg.denyList) ? cfg.denyList.map((h) => String(h).toLowerCase()) : [];
71
+
72
+ const out = {};
73
+ const base = stripHopByHopHeaders(incoming);
74
+
75
+ for (const [k, v] of Object.entries(base)) {
76
+ const key = String(k).toLowerCase();
77
+
78
+ if (key === 'authorization' && cfg.forwardAuthorization === false) continue;
79
+ if (key === 'cookie' && cfg.forwardCookie === false) continue;
80
+
81
+ if (denyList.includes(key)) continue;
82
+ if (allowList.length > 0 && !allowList.includes(key)) continue;
83
+
84
+ out[key] = v;
85
+ }
86
+
87
+ return out;
88
+ }
89
+
90
+ function matchValueForRule(rule, { targetUrl, host, path }) {
91
+ const applyTo = String(rule.applyTo || 'targetUrl');
92
+ if (applyTo === 'host') return host;
93
+ if (applyTo === 'path') return path;
94
+ return targetUrl;
95
+ }
96
+
97
+ function ruleMatches(rule, ctx) {
98
+ if (!rule || rule.enabled === false) return false;
99
+ const type = String(rule.type || '').toLowerCase();
100
+ const value = String(rule.value || '');
101
+ const input = String(matchValueForRule(rule, ctx) || '');
102
+
103
+ if (type === 'contains') {
104
+ return input.toLowerCase().includes(value.toLowerCase());
105
+ }
106
+ if (type === 'regexp') {
107
+ try {
108
+ const flags = String(rule.flags || 'i');
109
+ const re = new RegExp(value, flags);
110
+ return re.test(input);
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+
118
+ function entryMatches(entry, ctx) {
119
+ const match = entry?.match || {};
120
+ const type = String(match.type || 'contains').toLowerCase();
121
+ const value = String(match.value || '');
122
+ const applyTo = String(match.applyTo || 'host');
123
+
124
+ const input = applyTo === 'path'
125
+ ? String(ctx.path || '')
126
+ : (applyTo === 'targetUrl' ? String(ctx.targetUrl || '') : String(ctx.host || ''));
127
+
128
+ if (!value) return false;
129
+
130
+ if (type === 'exact') {
131
+ return input.toLowerCase() === value.toLowerCase();
132
+ }
133
+ if (type === 'contains') {
134
+ return input.toLowerCase().includes(value.toLowerCase());
135
+ }
136
+ if (type === 'regexp') {
137
+ try {
138
+ const flags = String(match.flags || 'i');
139
+ const re = new RegExp(value, flags);
140
+ return re.test(input);
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ function compareEntriesSpecificity(a, b) {
150
+ const order = { exact: 3, contains: 2, regexp: 1 };
151
+ const ta = String(a?.match?.type || 'contains').toLowerCase();
152
+ const tb = String(b?.match?.type || 'contains').toLowerCase();
153
+ const oa = order[ta] || 0;
154
+ const ob = order[tb] || 0;
155
+ if (oa !== ob) return ob - oa;
156
+
157
+ const va = String(a?.match?.value || '');
158
+ const vb = String(b?.match?.value || '');
159
+ return vb.length - va.length;
160
+ }
161
+
162
+ async function upsertDiscovery({ targetUrl, host, path }) {
163
+ const ns = 'proxy:discoveries';
164
+ const key = sha256(`${host}|${path}|${targetUrl}`);
165
+
166
+ const existing = await cacheLayer.get(key, { namespace: ns }).catch(() => null);
167
+ const next = existing && typeof existing === 'object'
168
+ ? { ...existing }
169
+ : {
170
+ key,
171
+ targetUrl,
172
+ host,
173
+ path,
174
+ firstSeenAt: new Date().toISOString(),
175
+ lastSeenAt: new Date().toISOString(),
176
+ count: 0,
177
+ };
178
+
179
+ next.lastSeenAt = new Date().toISOString();
180
+ next.count = Number(next.count || 0) + 1;
181
+
182
+ await cacheLayer.set(key, next, { namespace: ns, ttlSeconds: 24 * 60 * 60 }).catch(() => {});
183
+ return next;
184
+ }
185
+
186
+ async function listDiscoveries() {
187
+ const ns = 'proxy:discoveries';
188
+ const keys = await cacheLayer.listKeys({ namespace: ns }).catch(() => null);
189
+
190
+ const items = [];
191
+ const list = Array.isArray(keys)
192
+ ? keys
193
+ : [
194
+ ...((keys && Array.isArray(keys.memory)) ? keys.memory : []),
195
+ ...((keys && Array.isArray(keys.mongo)) ? keys.mongo : []),
196
+ ];
197
+
198
+ for (const k of list) {
199
+ const key = String(k?.key || '');
200
+ if (!key) continue;
201
+ // eslint-disable-next-line no-await-in-loop
202
+ const val = await cacheLayer.get(key, { namespace: ns, rehydrate: false }).catch(() => null);
203
+ if (!val || typeof val !== 'object') continue;
204
+ items.push(val);
205
+ }
206
+
207
+ items.sort((a, b) => {
208
+ const ta = new Date(a.lastSeenAt || 0).getTime();
209
+ const tb = new Date(b.lastSeenAt || 0).getTime();
210
+ return tb - ta;
211
+ });
212
+
213
+ return items;
214
+ }
215
+
216
+ async function findMatchingEntry(ctx) {
217
+ const entries = await ProxyEntry.find({}).lean();
218
+ const matched = (entries || []).filter((e) => entryMatches(e, ctx));
219
+ matched.sort(compareEntriesSpecificity);
220
+ return matched[0] || null;
221
+ }
222
+
223
+ function evaluatePolicy(entry, ctx) {
224
+ const mode = String(entry?.policy?.mode || 'whitelist');
225
+ const rules = Array.isArray(entry?.policy?.rules) ? entry.policy.rules : [];
226
+
227
+ if (mode === 'allowAll') return { allowed: true, reason: 'ALLOW_ALL' };
228
+ if (mode === 'denyAll') return { allowed: false, reason: 'DENY_ALL' };
229
+
230
+ const anyMatch = rules.some((r) => ruleMatches(r, ctx));
231
+
232
+ if (mode === 'blacklist') {
233
+ return anyMatch ? { allowed: false, reason: 'BLACKLIST_MATCH' } : { allowed: true, reason: 'BLACKLIST_DEFAULT_ALLOW' };
234
+ }
235
+
236
+ // whitelist (default)
237
+ return anyMatch ? { allowed: true, reason: 'WHITELIST_MATCH' } : { allowed: false, reason: 'WHITELIST_DEFAULT_DENY' };
238
+ }
239
+
240
+ function computeCacheKey(entry, { method, targetUrl, query, body, headers }) {
241
+ const keyParts = entry?.cache?.keyParts || {};
242
+ const headerAllow = Array.isArray(entry?.cache?.keyHeaderAllowList)
243
+ ? entry.cache.keyHeaderAllowList.map((h) => String(h).toLowerCase())
244
+ : [];
245
+
246
+ const parts = [];
247
+
248
+ if (keyParts.url !== false) {
249
+ parts.push(`u:${targetUrl}`);
250
+ }
251
+ if (keyParts.query !== false && query && typeof query === 'object') {
252
+ try {
253
+ parts.push(`q:${JSON.stringify(query)}`);
254
+ } catch {
255
+ }
256
+ }
257
+
258
+ if (keyParts.bodyHash !== false) {
259
+ const buf = body && Buffer.isBuffer(body) ? body : Buffer.from('');
260
+ const bh = sha256(buf);
261
+ parts.push(`b:${bh}`);
262
+ }
263
+
264
+ if (keyParts.headersHash !== false) {
265
+ const subset = {};
266
+ const src = headers && typeof headers === 'object' ? headers : {};
267
+ if (headerAllow.length > 0) {
268
+ for (const h of headerAllow) {
269
+ if (src[h] !== undefined) subset[h] = src[h];
270
+ }
271
+ }
272
+ const hh = sha256(JSON.stringify(subset));
273
+ parts.push(`h:${hh}`);
274
+ }
275
+
276
+ parts.push(`m:${String(method || '').toUpperCase()}`);
277
+
278
+ return sha256(parts.join('|'));
279
+ }
280
+
281
+ function runTransform(entry, ctx) {
282
+ const cfg = entry?.transform || {};
283
+ if (!cfg.enabled) return null;
284
+
285
+ const timeoutMs = Math.max(1, Number(cfg.timeoutMs || 200) || 200);
286
+ const code = String(cfg.code || '');
287
+ if (!code.trim()) return null;
288
+
289
+ const vm = new VM({ timeout: timeoutMs, sandbox: {} });
290
+
291
+ const fn = vm.run(`(function(){\n${code}\n;\nreturn (typeof transform === 'function') ? transform : null;\n})()`);
292
+ if (typeof fn !== 'function') {
293
+ return { error: 'Transform code did not export a function named transform(ctx)' };
294
+ }
295
+
296
+ const out = fn(ctx);
297
+ if (!out || typeof out !== 'object') return {};
298
+ return out;
299
+ }
300
+
301
+ async function proxyRequest(req) {
302
+ const method = String(req.method || 'GET').toUpperCase();
303
+ const targetUrl = String(req.proxyTargetUrl || '').trim();
304
+
305
+ if (!targetUrl) {
306
+ return { status: 400, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Missing target URL' })) };
307
+ }
308
+
309
+ let url;
310
+ try {
311
+ url = new URL(targetUrl);
312
+ } catch {
313
+ return { status: 400, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Invalid target URL' })) };
314
+ }
315
+
316
+ if (!['http:', 'https:'].includes(url.protocol)) {
317
+ return { status: 400, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Only http/https are supported' })) };
318
+ }
319
+
320
+ const ctx = {
321
+ targetUrl: url.toString(),
322
+ host: url.host,
323
+ path: url.pathname,
324
+ };
325
+
326
+ const entry = await findMatchingEntry(ctx);
327
+ if (!entry || entry.enabled !== true) {
328
+ await upsertDiscovery(ctx).catch(() => {});
329
+ logAuditSync({
330
+ req,
331
+ action: 'proxy.blocked',
332
+ outcome: 'failure',
333
+ targetType: 'ProxyRequest',
334
+ targetId: ctx.targetUrl,
335
+ details: { reason: 'NO_ENABLED_ENTRY', targetUrl: ctx.targetUrl, host: ctx.host, path: ctx.path },
336
+ });
337
+ return { status: 403, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Proxy request blocked' })) };
338
+ }
339
+
340
+ const decision = evaluatePolicy(entry, ctx);
341
+ if (!decision.allowed) {
342
+ logAuditSync({
343
+ req,
344
+ action: 'proxy.blocked',
345
+ outcome: 'failure',
346
+ targetType: 'ProxyRequest',
347
+ targetId: ctx.targetUrl,
348
+ details: { reason: decision.reason, targetUrl: ctx.targetUrl, host: ctx.host, path: ctx.path, entryId: String(entry._id) },
349
+ });
350
+ return { status: 403, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Proxy request blocked' })) };
351
+ }
352
+
353
+ if (entry.rateLimit?.enabled) {
354
+ const limiterId = String(entry.rateLimit?.limiterId || `proxy:${entry._id}`);
355
+ const result = await rateLimiter.check(limiterId, { req });
356
+ if (!result.allowed) {
357
+ logAuditSync({
358
+ req,
359
+ action: 'proxy.rate_limited',
360
+ outcome: 'failure',
361
+ targetType: 'ProxyRequest',
362
+ targetId: ctx.targetUrl,
363
+ details: { limiterId, targetUrl: ctx.targetUrl, entryId: String(entry._id) },
364
+ });
365
+ return { status: 429, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ error: 'Too many requests' })) };
366
+ }
367
+ }
368
+
369
+ const incomingHeaders = req.headers || {};
370
+ const outgoingHeaders = applyHeaderPolicy(entry, incomingHeaders);
371
+
372
+ const bodyBuf = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
373
+
374
+ const cacheCfg = entry.cache || {};
375
+ const cacheEnabled = Boolean(cacheCfg.enabled);
376
+ const allowedMethods = Array.isArray(cacheCfg.methods) ? cacheCfg.methods.map((m) => String(m).toUpperCase()) : ['GET', 'HEAD'];
377
+ const cacheAllowedForMethod = cacheEnabled && allowedMethods.includes(method);
378
+
379
+ const cacheNamespace = String(cacheCfg.namespace || 'proxy');
380
+
381
+ if (cacheAllowedForMethod) {
382
+ const cacheKey = computeCacheKey(entry, {
383
+ method,
384
+ targetUrl: ctx.targetUrl,
385
+ query: Object.fromEntries(url.searchParams.entries()),
386
+ body: bodyBuf,
387
+ headers: outgoingHeaders,
388
+ });
389
+
390
+ const cached = await cacheLayer.get(cacheKey, { namespace: cacheNamespace }).catch(() => null);
391
+ if (cached && typeof cached === 'object' && cached.bodyBase64) {
392
+ logAuditSync({
393
+ req,
394
+ action: 'proxy.cache.hit',
395
+ outcome: 'success',
396
+ targetType: 'ProxyRequest',
397
+ targetId: ctx.targetUrl,
398
+ details: { cacheKey, namespace: cacheNamespace, entryId: String(entry._id) },
399
+ });
400
+
401
+ const resBody = Buffer.from(String(cached.bodyBase64), 'base64');
402
+ const headers = cached.headers && typeof cached.headers === 'object' ? cached.headers : {};
403
+ return { status: Number(cached.status || 200) || 200, headers, body: resBody };
404
+ }
405
+
406
+ logAuditSync({
407
+ req,
408
+ action: 'proxy.cache.miss',
409
+ outcome: 'success',
410
+ targetType: 'ProxyRequest',
411
+ targetId: ctx.targetUrl,
412
+ details: { cacheKey, namespace: cacheNamespace, entryId: String(entry._id) },
413
+ });
414
+ }
415
+
416
+ const upstream = await axios({
417
+ url: ctx.targetUrl,
418
+ method,
419
+ headers: outgoingHeaders,
420
+ data: ['GET', 'HEAD'].includes(method) ? undefined : bodyBuf,
421
+ responseType: 'arraybuffer',
422
+ validateStatus: () => true,
423
+ timeout: 30000,
424
+ maxContentLength: 10 * 1024 * 1024,
425
+ maxBodyLength: 10 * 1024 * 1024,
426
+ });
427
+
428
+ const responseHeaders = stripHopByHopHeaders(upstream.headers || {});
429
+ const responseBody = Buffer.from(upstream.data || Buffer.from(''));
430
+
431
+ const contentType = String(responseHeaders['content-type'] || '');
432
+ const isJson = contentType.includes('application/json') || contentType.includes('+json');
433
+
434
+ let transformed = null;
435
+ if (entry.transform?.enabled) {
436
+ const jsonBody = isJson ? safeJsonParse(responseBody) : null;
437
+
438
+ transformed = runTransform(entry, {
439
+ request: {
440
+ method,
441
+ targetUrl: ctx.targetUrl,
442
+ headers: outgoingHeaders,
443
+ bodyBase64: bodyBuf.length ? bodyBuf.toString('base64') : null,
444
+ },
445
+ response: {
446
+ status: upstream.status,
447
+ headers: responseHeaders,
448
+ bodyBase64: responseBody.length ? responseBody.toString('base64') : null,
449
+ json: jsonBody,
450
+ },
451
+ });
452
+ }
453
+
454
+ if (transformed && transformed.error) {
455
+ logAuditSync({
456
+ req,
457
+ action: 'proxy.transform.error',
458
+ outcome: 'failure',
459
+ targetType: 'ProxyRequest',
460
+ targetId: ctx.targetUrl,
461
+ details: { error: transformed.error, entryId: String(entry._id) },
462
+ });
463
+ }
464
+
465
+ let finalStatus = upstream.status;
466
+ let finalHeaders = { ...responseHeaders };
467
+ let finalBody = responseBody;
468
+
469
+ if (transformed && typeof transformed === 'object') {
470
+ if (transformed.status !== undefined) {
471
+ finalStatus = Number(transformed.status) || finalStatus;
472
+ }
473
+ if (transformed.headers && typeof transformed.headers === 'object') {
474
+ finalHeaders = stripHopByHopHeaders({ ...finalHeaders, ...transformed.headers });
475
+ }
476
+ if (transformed.bodyBase64) {
477
+ finalBody = Buffer.from(String(transformed.bodyBase64), 'base64');
478
+ } else if (transformed.bodyText !== undefined) {
479
+ finalBody = Buffer.from(String(transformed.bodyText || ''), 'utf8');
480
+ } else if (transformed.json !== undefined) {
481
+ finalHeaders['content-type'] = 'application/json';
482
+ finalBody = Buffer.from(JSON.stringify(transformed.json), 'utf8');
483
+ }
484
+ }
485
+
486
+ if (cacheAllowedForMethod) {
487
+ const cacheKey = computeCacheKey(entry, {
488
+ method,
489
+ targetUrl: ctx.targetUrl,
490
+ query: Object.fromEntries(url.searchParams.entries()),
491
+ body: bodyBuf,
492
+ headers: outgoingHeaders,
493
+ });
494
+
495
+ if (finalStatus >= 200 && finalStatus < 300) {
496
+ const toStore = {
497
+ status: finalStatus,
498
+ headers: finalHeaders,
499
+ bodyBase64: finalBody.toString('base64'),
500
+ storedAt: new Date().toISOString(),
501
+ };
502
+ await cacheLayer.set(cacheKey, toStore, { namespace: cacheNamespace, ttlSeconds: cacheCfg.ttlSeconds }).catch(() => {});
503
+ logAuditSync({
504
+ req,
505
+ action: 'proxy.cache.store',
506
+ outcome: 'success',
507
+ targetType: 'ProxyRequest',
508
+ targetId: ctx.targetUrl,
509
+ details: { cacheKey, namespace: cacheNamespace, entryId: String(entry._id), status: finalStatus },
510
+ });
511
+ }
512
+ }
513
+
514
+ const normalizedBody = isJson ? normalizeForAudit(safeJsonParse(finalBody)) : null;
515
+ logAuditSync({
516
+ req,
517
+ action: 'proxy.response',
518
+ outcome: finalStatus >= 400 ? 'failure' : 'success',
519
+ targetType: 'ProxyRequest',
520
+ targetId: ctx.targetUrl,
521
+ details: {
522
+ entryId: String(entry._id),
523
+ targetUrl: ctx.targetUrl,
524
+ status: finalStatus,
525
+ normalizedBody,
526
+ },
527
+ });
528
+
529
+ return { status: finalStatus, headers: finalHeaders, body: finalBody };
530
+ }
531
+
532
+ module.exports = {
533
+ proxyRequest,
534
+ listDiscoveries,
535
+ };