fusion-framework 1.2.6 → 1.3.0

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/README.md CHANGED
@@ -35,7 +35,7 @@ export const ItemModule = route('/api/[module]/{id}')(
35
35
  },
36
36
  )
37
37
 
38
- const MIDDLEWARE = [] // your middleware; Fusion already adds frameworkHeaders() by default
38
+ const MIDDLEWARE = [] // register with app.use() see securityHeaders, cors, requestId, …
39
39
 
40
40
  settings.ensureLoaded()
41
41
  const app = new FusionApp(getSettings())
Binary file
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -134,7 +134,7 @@ header.fingerprint = () =>
134
134
  : {
135
135
  'X-Powered-By': 'Fusion Framework',
136
136
  'X-Framework': 'Fusion',
137
- ['X-Fusion-Version']: '1.2.6',
137
+ ['X-Fusion-Version']: '1.3.0',
138
138
  }
139
139
 
140
140
  function isThenable(value) {
@@ -155,10 +155,125 @@ function mergeResponseHeaders(result, extra) {
155
155
  if (!result || typeof result !== 'object') {
156
156
  return { status: 200, body: result, headers: { ...extra } }
157
157
  }
158
- const headers = { ...extra, ...(result.headers || {}) }
158
+ const suppressed = new Set(
159
+ (Array.isArray(result.suppress_headers) ? result.suppress_headers : []).map((n) =>
160
+ String(n).toLowerCase(),
161
+ ),
162
+ )
163
+ const filtered = {}
164
+ for (const [k, v] of Object.entries(extra || {})) {
165
+ if (!suppressed.has(String(k).toLowerCase())) filtered[k] = v
166
+ }
167
+ const headers = { ...filtered, ...(result.headers || {}) }
159
168
  return { ...result, headers }
160
169
  }
161
170
 
171
+ function stripResponseHeaders(result, names) {
172
+ const drop = new Set(names.map((n) => String(n).toLowerCase()))
173
+ const out =
174
+ result && typeof result === 'object' && 'status' in result
175
+ ? { ...result }
176
+ : { status: 200, body: result }
177
+ if (out.headers && typeof out.headers === 'object') {
178
+ const headers = {}
179
+ for (const [k, v] of Object.entries(out.headers)) {
180
+ if (!drop.has(String(k).toLowerCase())) headers[k] = v
181
+ }
182
+ out.headers = headers
183
+ }
184
+ const suppressed = Array.isArray(out.suppress_headers) ? [...out.suppress_headers] : []
185
+ const seen = new Set(suppressed.map((s) => String(s).toLowerCase()))
186
+ for (const name of names) {
187
+ const key = String(name).toLowerCase()
188
+ if (!seen.has(key)) {
189
+ suppressed.push(String(name))
190
+ seen.add(key)
191
+ }
192
+ }
193
+ out.suppress_headers = suppressed
194
+ return out
195
+ }
196
+
197
+ function flattenHeaderMaps(...items) {
198
+ const out = {}
199
+ const strBuf = []
200
+ for (const item of items) {
201
+ if (item == null) continue
202
+ if (typeof item === 'object' && !Array.isArray(item)) {
203
+ for (const [k, v] of Object.entries(item)) out[String(k)] = v == null ? '' : String(v)
204
+ } else if (Array.isArray(item) && item.length === 2 && typeof item[0] !== 'object') {
205
+ out[String(item[0])] = item[1] == null ? '' : String(item[1])
206
+ } else if (typeof item === 'string') {
207
+ strBuf.push(item)
208
+ }
209
+ }
210
+ if (strBuf.length >= 2 && strBuf.length % 2 === 0) {
211
+ for (let i = 0; i < strBuf.length; i += 2) out[String(strBuf[i])] = String(strBuf[i + 1])
212
+ }
213
+ return out
214
+ }
215
+
216
+ function flattenHeaderNames(...items) {
217
+ const names = []
218
+ const seen = new Set()
219
+ const add = (name) => {
220
+ const key = String(name).toLowerCase()
221
+ if (!key || seen.has(key)) return
222
+ seen.add(key)
223
+ names.push(String(name))
224
+ }
225
+ for (const item of items) {
226
+ if (item == null) continue
227
+ if (typeof item === 'string') add(item)
228
+ else if (typeof item === 'object' && !Array.isArray(item)) {
229
+ for (const k of Object.keys(item)) add(k)
230
+ } else if (Array.isArray(item)) {
231
+ if (item.length === 2 && typeof item[0] !== 'object') add(item[0])
232
+ else item.forEach((x) => (typeof x === 'string' ? add(x) : null))
233
+ }
234
+ }
235
+ return names
236
+ }
237
+
238
+ function wrapHeaderOp(fn, after) {
239
+ const wrapped = function (...args) {
240
+ const result = fn.apply(this, args)
241
+ return Promise.resolve(result).then(after)
242
+ }
243
+ for (const key of Object.keys(fn)) {
244
+ if (key.startsWith('__fusion')) wrapped[key] = fn[key]
245
+ }
246
+ if (fn.__fusionHttpRoute) wrapped.__fusionHttpRoute = fn.__fusionHttpRoute
247
+ return wrapped
248
+ }
249
+
250
+ /** Merge headers into the method response (wins over middleware). */
251
+ function addHeader(...items) {
252
+ const headers = flattenHeaderMaps(...items)
253
+ return (fn) => {
254
+ const merged = { ...(fn.__fusionAddHeaders || {}), ...headers }
255
+ const wrapped = wrapHeaderOp(fn, (result) => {
256
+ if (!result || typeof result !== 'object') {
257
+ return { status: 200, body: result, headers: { ...merged } }
258
+ }
259
+ return { ...result, headers: { ...(result.headers || {}), ...merged } }
260
+ })
261
+ wrapped.__fusionAddHeaders = merged
262
+ return wrapped
263
+ }
264
+ }
265
+
266
+ /** Strip headers from the method response and suppress wire fingerprint re-add. */
267
+ function deleteHeader(...items) {
268
+ const names = flattenHeaderNames(...items)
269
+ return (fn) => {
270
+ const merged = flattenHeaderNames(...(fn.__fusionDeleteHeaders || []), ...names)
271
+ const wrapped = wrapHeaderOp(fn, (result) => stripResponseHeaders(result, merged))
272
+ wrapped.__fusionDeleteHeaders = merged
273
+ return wrapped
274
+ }
275
+ }
276
+
162
277
  function frameworkHeaders() {
163
278
  const extra = header.fingerprint()
164
279
  // Async so Promises from callNext are never stuffed into `body` as objects.
@@ -168,6 +283,191 @@ function frameworkHeaders() {
168
283
  }
169
284
  }
170
285
 
286
+ function truthySetting(value, defaultValue = false) {
287
+ if (value == null) return defaultValue
288
+ if (typeof value === 'boolean') return value
289
+ if (typeof value === 'number') return value !== 0
290
+ if (typeof value === 'string') {
291
+ const s = value.trim().toLowerCase()
292
+ if (['', '0', 'false', 'off', 'no'].includes(s)) return false
293
+ if (['1', 'true', 'on', 'yes'].includes(s)) return true
294
+ }
295
+ return Boolean(value)
296
+ }
297
+
298
+ function settingsSection(name) {
299
+ const root = asObject(settings.get('middleware', {}))
300
+ if (root && root[name] && typeof root[name] === 'object') return asObject(root[name])
301
+ return asObject(settings.get(name, {}))
302
+ }
303
+
304
+ function asStringList(value, fallback = []) {
305
+ if (value == null) return [...fallback]
306
+ if (typeof value === 'string') {
307
+ return value
308
+ .split(',')
309
+ .map((s) => s.trim())
310
+ .filter(Boolean)
311
+ }
312
+ if (Array.isArray(value)) return value.map(String).filter(Boolean)
313
+ return [String(value)]
314
+ }
315
+
316
+ function securityHeaders(config = {}) {
317
+ const headers = {
318
+ 'X-Content-Type-Options': String(config.content_type_options || config.x_content_type_options || 'nosniff'),
319
+ 'X-Frame-Options': String(config.frame_options || config.x_frame_options || 'DENY'),
320
+ 'Referrer-Policy': String(config.referrer_policy || 'strict-origin-when-cross-origin'),
321
+ 'Permissions-Policy': String(
322
+ config.permissions_policy || 'camera=(), microphone=(), geolocation=(), payment=()',
323
+ ),
324
+ 'X-XSS-Protection': String(config.xss_protection || '0'),
325
+ 'Cross-Origin-Opener-Policy': String(config.coop || 'same-origin'),
326
+ 'Cross-Origin-Resource-Policy': String(config.corp || 'same-origin'),
327
+ }
328
+ const csp = config.csp || config.content_security_policy
329
+ if (csp) headers['Content-Security-Policy'] = String(csp)
330
+ const hsts = config.hsts && typeof config.hsts === 'object' ? config.hsts : {}
331
+ if (truthySetting(hsts.enabled, false)) {
332
+ const maxAge = Number(hsts.max_age || 31536000)
333
+ const parts = [`max-age=${maxAge}`]
334
+ if (truthySetting(hsts.include_subdomains, true)) parts.push('includeSubDomains')
335
+ if (truthySetting(hsts.preload, false)) parts.push('preload')
336
+ headers['Strict-Transport-Security'] = parts.join('; ')
337
+ }
338
+ if (config.headers && typeof config.headers === 'object') {
339
+ for (const [k, v] of Object.entries(config.headers)) {
340
+ if (v == null) delete headers[k]
341
+ else headers[k] = String(v)
342
+ }
343
+ }
344
+ return async (request, callNext) => {
345
+ const result = await awaitMaybe(callNext(request))
346
+ return mergeResponseHeaders(result, headers)
347
+ }
348
+ }
349
+
350
+ function cors(config = {}) {
351
+ const allowOrigins = asStringList(config.allow_origins || config.origins, ['*'])
352
+ const allowMethods = asStringList(config.allow_methods, [
353
+ 'GET',
354
+ 'POST',
355
+ 'PUT',
356
+ 'PATCH',
357
+ 'DELETE',
358
+ 'OPTIONS',
359
+ 'HEAD',
360
+ ])
361
+ const allowHeaders = asStringList(config.allow_headers, [
362
+ 'Authorization',
363
+ 'Content-Type',
364
+ 'Accept',
365
+ 'Origin',
366
+ 'X-Request-Id',
367
+ ])
368
+ const exposeHeaders = asStringList(config.expose_headers, ['X-Request-Id'])
369
+ const allowCredentials = truthySetting(config.allow_credentials, false)
370
+ const maxAge = Number(config.max_age || 600)
371
+
372
+ function corsHeaders(origin) {
373
+ if (!allowOrigins.length) return null
374
+ const wildcard = allowOrigins.includes('*')
375
+ let allow
376
+ if (origin && (wildcard || allowOrigins.includes(origin))) {
377
+ allow = wildcard && !allowCredentials ? '*' : origin
378
+ } else if (wildcard && !allowCredentials) {
379
+ allow = '*'
380
+ } else {
381
+ return null
382
+ }
383
+ const headers = {
384
+ 'Access-Control-Allow-Origin': allow,
385
+ 'Access-Control-Allow-Methods': allowMethods.join(', '),
386
+ 'Access-Control-Allow-Headers': allowHeaders.join(', '),
387
+ 'Access-Control-Max-Age': String(maxAge),
388
+ Vary: 'Origin',
389
+ }
390
+ if (exposeHeaders.length) headers['Access-Control-Expose-Headers'] = exposeHeaders.join(', ')
391
+ if (allowCredentials) headers['Access-Control-Allow-Credentials'] = 'true'
392
+ return headers
393
+ }
394
+
395
+ return async (request, callNext) => {
396
+ const origin = request?.headers?.Origin || request?.headers?.origin || null
397
+ const hdrs = corsHeaders(origin)
398
+ const method = String(request?.method || '').toUpperCase()
399
+ if (method === 'OPTIONS') {
400
+ if (!hdrs) return { status: 403, body: { detail: 'CORS origin not allowed' } }
401
+ return { status: 204, body: '', headers: hdrs }
402
+ }
403
+ const result = await awaitMaybe(callNext(request))
404
+ return hdrs ? mergeResponseHeaders(result, hdrs) : result
405
+ }
406
+ }
407
+
408
+ function cacheHeaders(config = {}) {
409
+ const value = String(config.default || config.cache_control || 'no-store')
410
+ const extra = { 'Cache-Control': value }
411
+ if (config.pragma) extra.Pragma = String(config.pragma)
412
+ else if (value === 'no-store') extra.Pragma = 'no-cache'
413
+ return async (request, callNext) => {
414
+ const result = await awaitMaybe(callNext(request))
415
+ return mergeResponseHeaders(result, extra)
416
+ }
417
+ }
418
+
419
+ function requestId(config = {}) {
420
+ const headerName = String(config.header || 'X-Request-Id')
421
+ const acceptIncoming = truthySetting(config.incoming, true)
422
+ const stateKey = String(config.state_key || 'request_id')
423
+ return async (request, callNext) => {
424
+ const headers = request?.headers || {}
425
+ let rid = null
426
+ if (acceptIncoming) {
427
+ for (const [k, v] of Object.entries(headers)) {
428
+ if (String(k).toLowerCase() === headerName.toLowerCase()) {
429
+ rid = String(v)
430
+ break
431
+ }
432
+ }
433
+ }
434
+ if (!rid) {
435
+ rid =
436
+ typeof crypto !== 'undefined' && crypto.randomUUID
437
+ ? crypto.randomUUID().replace(/-/g, '')
438
+ : `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`
439
+ }
440
+ ensureState(request)[stateKey] = rid
441
+ const result = await awaitMaybe(callNext(request))
442
+ return mergeResponseHeaders(result, { [headerName]: rid })
443
+ }
444
+ }
445
+
446
+ function defaultBuiltinMiddleware() {
447
+ settings.ensureLoaded()
448
+ const root = asObject(settings.get('middleware', {}))
449
+ const enabled = (name, defaultOn) => {
450
+ const cfg = settingsSection(name)
451
+ if (Object.prototype.hasOwnProperty.call(cfg, 'enabled')) {
452
+ return [truthySetting(cfg.enabled, defaultOn), cfg]
453
+ }
454
+ if (root && Object.keys(root).length) return [defaultOn, cfg]
455
+ return [defaultOn, cfg]
456
+ }
457
+ const out = []
458
+ let cfg
459
+ let on
460
+ ;[on, cfg] = enabled('security', true)
461
+ if (on) out.push(securityHeaders(cfg))
462
+ ;[on, cfg] = enabled('cors', false)
463
+ if (on) out.push(cors(cfg))
464
+ ;[on, cfg] = enabled('cache', false)
465
+ if (on) out.push(cacheHeaders(cfg))
466
+ ;[on, cfg] = enabled('request_id', true)
467
+ if (on) out.push(requestId(cfg))
468
+ return out
469
+ }
470
+
171
471
  class FusionBaseApi {
172
472
  constructor(request) {
173
473
  this.request = request && typeof request === 'object' ? request : emptyRequest()
@@ -890,8 +1190,7 @@ class FusionApp {
890
1190
  this.settings = getSettings()
891
1191
  this.engine = new NativeApp()
892
1192
  this.mounted = false
893
- // Default: advertise Fusion to clients / Wappalyzer-style detectors.
894
- this._middleware = [frameworkHeaders()]
1193
+ this._middleware = []
895
1194
  }
896
1195
 
897
1196
  use(middleware) {
@@ -1036,6 +1335,13 @@ module.exports = {
1036
1335
  bearerJwt,
1037
1336
  requireRoles,
1038
1337
  frameworkHeaders,
1338
+ securityHeaders,
1339
+ cors,
1340
+ cacheHeaders,
1341
+ requestId,
1342
+ defaultBuiltinMiddleware,
1343
+ addHeader,
1344
+ deleteHeader,
1039
1345
  runMiddlewareChain,
1040
1346
  coerceParam,
1041
1347
  parsePagination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusion-framework",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Class-based HTTP framework for Node.js, powered by a shared Rust core via N-API",
5
5
  "keywords": [
6
6
  "http",