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 = [] //
|
|
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()
|
|
@@ -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
|
-
|
|
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,
|