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 = [] //
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|