fusion-framework 1.2.5 → 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.5',
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()
@@ -208,6 +508,26 @@ class FusionBaseApi {
208
508
  if (keys.length) out.headers = { ...headers }
209
509
  return out
210
510
  }
511
+
512
+ pagination({
513
+ page,
514
+ pageSize,
515
+ offset,
516
+ defaultPageSize = 20,
517
+ maxPageSize = 100,
518
+ } = {}) {
519
+ const query = { ...(this.query || {}) }
520
+ if (page != null) query.page = String(page)
521
+ if (pageSize != null) query.page_size = String(pageSize)
522
+ if (offset != null) query.offset = String(offset)
523
+ return parsePagination(query, { defaultPageSize, maxPageSize })
524
+ }
525
+
526
+ paginated(items, total, params = null, { page, pageSize, status = 200, headers } = {}) {
527
+ const p = params ?? this.pagination({ page, pageSize })
528
+ const body = paginatedBody(items, total, p)
529
+ return this.response(body, status, headers || {})
530
+ }
211
531
  }
212
532
 
213
533
  function apiResourceName(cls) {
@@ -870,8 +1190,7 @@ class FusionApp {
870
1190
  this.settings = getSettings()
871
1191
  this.engine = new NativeApp()
872
1192
  this.mounted = false
873
- // Default: advertise Fusion to clients / Wappalyzer-style detectors.
874
- this._middleware = [frameworkHeaders()]
1193
+ this._middleware = []
875
1194
  }
876
1195
 
877
1196
  use(middleware) {
@@ -973,6 +1292,18 @@ function pathToFileUrl(filePath) {
973
1292
  return require('url').pathToFileURL(resolved).href
974
1293
  }
975
1294
 
1295
+ function parsePagination(query, { defaultPageSize = 20, maxPageSize = 100 } = {}) {
1296
+ try {
1297
+ return native.parsePagination(query, defaultPageSize, maxPageSize)
1298
+ } catch (err) {
1299
+ throw new HTTPException(400, err?.message || 'invalid pagination')
1300
+ }
1301
+ }
1302
+
1303
+ function paginatedBody(items, total, params) {
1304
+ return native.paginatedBody(items, total, params)
1305
+ }
1306
+
976
1307
  const route = router
977
1308
 
978
1309
  module.exports = {
@@ -1004,8 +1335,17 @@ module.exports = {
1004
1335
  bearerJwt,
1005
1336
  requireRoles,
1006
1337
  frameworkHeaders,
1338
+ securityHeaders,
1339
+ cors,
1340
+ cacheHeaders,
1341
+ requestId,
1342
+ defaultBuiltinMiddleware,
1343
+ addHeader,
1344
+ deleteHeader,
1007
1345
  runMiddlewareChain,
1008
1346
  coerceParam,
1347
+ parsePagination,
1348
+ paginatedBody,
1009
1349
  getHttpMethods: () => HTTP_METHODS,
1010
1350
  apiResourceNameJs: native.apiResourceNameJs,
1011
1351
  resolveRoutePathJs: native.resolveRoutePathJs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fusion-framework",
3
- "version": "1.2.5",
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",