@volcanicminds/backend 3.4.0 → 3.6.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.
- package/README.md +55 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api/admin/controller/manifest.d.ts.map +1 -1
- package/dist/lib/api/admin/controller/manifest.js.map +1 -1
- package/dist/lib/api/admin/routes.d.ts.map +1 -1
- package/dist/lib/api/admin/routes.js +1 -1
- package/dist/lib/api/admin/routes.js.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +1 -1
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +13 -4
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/config/general.d.ts +4 -1
- package/dist/lib/config/general.d.ts.map +1 -1
- package/dist/lib/config/general.js +4 -1
- package/dist/lib/config/general.js.map +1 -1
- package/dist/lib/hooks/onError.d.ts.map +1 -1
- package/dist/lib/hooks/onError.js +8 -7
- package/dist/lib/hooks/onError.js.map +1 -1
- package/dist/lib/hooks/onRequest.d.ts.map +1 -1
- package/dist/lib/hooks/onRequest.js +2 -1
- package/dist/lib/hooks/onRequest.js.map +1 -1
- package/dist/lib/loader/general.d.ts.map +1 -1
- package/dist/lib/loader/general.js +3 -1
- package/dist/lib/loader/general.js.map +1 -1
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +29 -5
- package/dist/lib/loader/router.js.map +1 -1
- package/dist/lib/util/cache.d.ts +37 -0
- package/dist/lib/util/cache.d.ts.map +1 -0
- package/dist/lib/util/cache.js +189 -0
- package/dist/lib/util/cache.js.map +1 -0
- package/dist/lib/util/tracker.d.ts +1 -0
- package/dist/lib/util/tracker.d.ts.map +1 -1
- package/dist/lib/util/tracker.js +3 -3
- package/dist/lib/util/tracker.js.map +1 -1
- package/dist/types/global.d.ts +31 -1
- package/lib/api/admin/controller/manifest.ts +2 -1
- package/lib/api/admin/routes.ts +4 -1
- package/lib/api/auth/controller/auth.ts +1 -1
- package/lib/api/users/controller/user.ts +22 -4
- package/lib/config/general.ts +10 -2
- package/lib/hooks/onError.ts +12 -7
- package/lib/hooks/onRequest.ts +7 -1
- package/lib/loader/general.ts +3 -1
- package/lib/loader/router.ts +40 -5
- package/lib/manifest/generator.ts +1 -1
- package/lib/util/cache.ts +258 -0
- package/lib/util/tracker.ts +5 -3
- package/package.json +4 -3
package/lib/config/general.ts
CHANGED
|
@@ -2,8 +2,10 @@ export default {
|
|
|
2
2
|
name: 'general',
|
|
3
3
|
options: {
|
|
4
4
|
allow_multiple_admin: false,
|
|
5
|
-
admin_can_change_passwords: false,
|
|
6
5
|
allow_admin_change_password_users: false,
|
|
6
|
+
// opt-in: users created by an admin (POST /users) start confirmed and can log in
|
|
7
|
+
// immediately, unless the payload explicitly sends confirmed:false.
|
|
8
|
+
allow_admin_create_confirmed_users: false,
|
|
7
9
|
reset_external_id_on_login: false,
|
|
8
10
|
scheduler: false,
|
|
9
11
|
embedded_auth: true,
|
|
@@ -17,7 +19,13 @@ export default {
|
|
|
17
19
|
query_key: 'tid'
|
|
18
20
|
},
|
|
19
21
|
manifest: {
|
|
20
|
-
|
|
22
|
+
// opt-in: exposes GET /admin/manifest (admin-only) for the backoffice engine
|
|
23
|
+
enabled: false
|
|
24
|
+
},
|
|
25
|
+
cache: {
|
|
26
|
+
// opt-in: in-memory LRU+TTL cache for routes that declare `cache:`.
|
|
27
|
+
// When enabled, ttl (default 3600s) and maxEntries (default 1000) can be set here.
|
|
28
|
+
enabled: false
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
}
|
package/lib/hooks/onError.ts
CHANGED
|
@@ -2,32 +2,37 @@
|
|
|
2
2
|
import { FastifyRequest, FastifyReply } from 'fastify'
|
|
3
3
|
|
|
4
4
|
export default async (_req: FastifyRequest, reply: FastifyReply, error: any) => {
|
|
5
|
-
|
|
5
|
+
// Normalize the message up-front: `error` may be a string, or an object without
|
|
6
|
+
// a `message` — reading `.includes` on an undefined message would crash the
|
|
7
|
+
// error handler itself (and mask the real error as an unhandled 500).
|
|
8
|
+
const message = typeof error?.message === 'string' ? error.message : String(error?.message ?? error ?? '')
|
|
9
|
+
|
|
10
|
+
if (log.e) log.error(message || `${error}`)
|
|
6
11
|
if (log.t) log.trace(error)
|
|
7
12
|
|
|
8
|
-
if (error
|
|
13
|
+
if (error?.statusCode && error.statusCode >= 400) {
|
|
9
14
|
return reply.code(error.statusCode).send(error)
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
if (
|
|
17
|
+
if (message === 'Wrong credentials' || message === 'Unauthorized') {
|
|
13
18
|
return reply.code(403).send({
|
|
14
19
|
statusCode: 403,
|
|
15
20
|
error: 'Forbidden',
|
|
16
|
-
message
|
|
21
|
+
message
|
|
17
22
|
})
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
if (
|
|
25
|
+
if (message.includes('not found')) {
|
|
21
26
|
return reply.code(404).send({
|
|
22
27
|
statusCode: 404,
|
|
23
28
|
error: 'Not Found',
|
|
24
|
-
message
|
|
29
|
+
message
|
|
25
30
|
})
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
return reply.code(500).send({
|
|
29
34
|
statusCode: 500,
|
|
30
35
|
error: 'Internal Server Error',
|
|
31
|
-
message
|
|
36
|
+
message
|
|
32
37
|
})
|
|
33
38
|
}
|
package/lib/hooks/onRequest.ts
CHANGED
|
@@ -147,7 +147,13 @@ export default async (req, reply) => {
|
|
|
147
147
|
if (cfg.requiredRoles?.length > 0) {
|
|
148
148
|
const { method = '', url = '', requiredRoles } = cfg
|
|
149
149
|
const authorizedRoles: string[] = req.roles()
|
|
150
|
-
|
|
150
|
+
// A route open to `public` is open to EVERY caller: anonymous requests already
|
|
151
|
+
// pass (they carry the `public` role), and an authenticated subject must never
|
|
152
|
+
// rank below anonymous — without this, a user whose roles don't include
|
|
153
|
+
// `public` (e.g. only `backoffice`) would get 403 on public routes such as
|
|
154
|
+
// /users/me or /auth/change-password.
|
|
155
|
+
const isPublicRoute = requiredRoles.some((r) => r.code === roles.public.code)
|
|
156
|
+
const hasPermission = isPublicRoute || requiredRoles.some((r) => authorizedRoles.includes(r.code))
|
|
151
157
|
|
|
152
158
|
if (!hasPermission) {
|
|
153
159
|
// 401 when there is no authenticated subject (must log in first); 403 when
|
package/lib/loader/general.ts
CHANGED
|
@@ -7,7 +7,6 @@ export async function load() {
|
|
|
7
7
|
name: 'general',
|
|
8
8
|
options: {
|
|
9
9
|
allow_multiple_admin: false,
|
|
10
|
-
admin_can_change_passwords: false,
|
|
11
10
|
allow_admin_change_password_users: false,
|
|
12
11
|
reset_external_id_on_login: false,
|
|
13
12
|
scheduler: false,
|
|
@@ -20,6 +19,9 @@ export async function load() {
|
|
|
20
19
|
},
|
|
21
20
|
manifest: {
|
|
22
21
|
enabled: false
|
|
22
|
+
},
|
|
23
|
+
cache: {
|
|
24
|
+
enabled: false
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
}
|
package/lib/loader/router.ts
CHANGED
|
@@ -3,6 +3,7 @@ import yn from '../util/yn.js'
|
|
|
3
3
|
import type { Role, Route, ConfiguredRoute, RouteConfig } from '../../types/global.js'
|
|
4
4
|
import { FastifyReply, FastifyRequest } from 'fastify'
|
|
5
5
|
import { normalizePatterns } from '../util/path.js'
|
|
6
|
+
import { normalizeRouteCache, buildCacheHooks, cacheEnabled } from '../util/cache.js'
|
|
6
7
|
import { globSync } from 'glob'
|
|
7
8
|
import path from 'path'
|
|
8
9
|
import { fileURLToPath } from 'url'
|
|
@@ -83,7 +84,8 @@ export function processRoute(
|
|
|
83
84
|
roles: rs = [],
|
|
84
85
|
config = {} as RouteConfig,
|
|
85
86
|
middlewares = [],
|
|
86
|
-
rateLimit
|
|
87
|
+
rateLimit,
|
|
88
|
+
cache: cacheInput
|
|
87
89
|
} = route
|
|
88
90
|
|
|
89
91
|
const rsp = !rs.length ? [roles.public] : rs
|
|
@@ -186,6 +188,9 @@ export function processRoute(
|
|
|
186
188
|
tenantContext,
|
|
187
189
|
rawBody,
|
|
188
190
|
rateLimit,
|
|
191
|
+
// Per-route cache (falls back to the file-level `config.cache`); the default
|
|
192
|
+
// key-group is the api folder (`dir`), overridable.
|
|
193
|
+
cache: normalizeRouteCache(cacheInput ?? defaultConfig?.cache, dir),
|
|
189
194
|
base,
|
|
190
195
|
file: path.join(base, defaultConfig.controller || 'controller', handlerParts[0]),
|
|
191
196
|
func: handlerParts[1],
|
|
@@ -245,13 +250,33 @@ async function applyRoutes(server: any, routes: ConfiguredRoute[]): Promise<void
|
|
|
245
250
|
let countRoutes = 0
|
|
246
251
|
for (const route of routes) {
|
|
247
252
|
if (route?.enable) {
|
|
248
|
-
const { handler, method, path, middlewares, roles, rawBody, rateLimit, base, file, func, doc, tenantContext } =
|
|
253
|
+
const { handler, method, path, middlewares, roles, rawBody, rateLimit, base, file, func, doc, tenantContext, cache } =
|
|
249
254
|
route
|
|
250
255
|
|
|
251
256
|
if (log.d) log.debug(`* Add path ${method} ${path} on handle ${handler}`)
|
|
252
257
|
const midds = await loadMiddlewares(base, middlewares)
|
|
253
258
|
|
|
254
|
-
|
|
259
|
+
// Attach the cache read (preHandler, runs after auth) and write (onSend)
|
|
260
|
+
// hooks only when the route opts in — no overhead on uncached routes.
|
|
261
|
+
const cacheHooks = cache && (cache.enabled || cache.invalidates?.length) ? buildCacheHooks(cache) : null
|
|
262
|
+
|
|
263
|
+
// Courtesy log at wiring time: describe each cache-declaring route, and warn
|
|
264
|
+
// when a route opts in while the global master switch is off (hooks are inert).
|
|
265
|
+
if (cacheHooks) {
|
|
266
|
+
if (!cacheEnabled()) {
|
|
267
|
+
if (log.w)
|
|
268
|
+
log.warn(
|
|
269
|
+
`Cache 🧊 route ${method} ${path} declares cache/invalidation but global cache is disabled (options.cache.enabled) — inert`
|
|
270
|
+
)
|
|
271
|
+
} else if (log.d) {
|
|
272
|
+
const parts: string[] = []
|
|
273
|
+
if (cache?.enabled) parts.push(`read+write ttl ${cache.ttl ?? 'default'}s keyGroup '${cache.keyGroup}'`)
|
|
274
|
+
if (cache?.invalidates?.length) parts.push(`invalidates [${cache.invalidates.join(', ')}]`)
|
|
275
|
+
log.debug(`Cache 🧊 route ${method} ${path} — ${parts.join(', ')}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const routeDef: any = {
|
|
255
280
|
method: method,
|
|
256
281
|
path: path,
|
|
257
282
|
schema: doc,
|
|
@@ -260,7 +285,8 @@ async function applyRoutes(server: any, routes: ConfiguredRoute[]): Promise<void
|
|
|
260
285
|
requiredRoles: roles || [],
|
|
261
286
|
rawBody: rawBody || false,
|
|
262
287
|
rateLimit: rateLimit || undefined,
|
|
263
|
-
tenantContext: tenantContext
|
|
288
|
+
tenantContext: tenantContext,
|
|
289
|
+
cache: cache || undefined
|
|
264
290
|
},
|
|
265
291
|
handler: async function (req: FastifyRequest, reply: FastifyReply) {
|
|
266
292
|
let module
|
|
@@ -286,7 +312,16 @@ async function applyRoutes(server: any, routes: ConfiguredRoute[]): Promise<void
|
|
|
286
312
|
|
|
287
313
|
return await module[func](req, reply)
|
|
288
314
|
}
|
|
289
|
-
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (cacheHooks?.preHandler) {
|
|
318
|
+
// Cache read runs before the route middlewares; auth/roles are already
|
|
319
|
+
// enforced in the global onRequest hook, so this is safe.
|
|
320
|
+
routeDef.preHandler = [cacheHooks.preHandler, ...((midds as any).preHandler || [])]
|
|
321
|
+
}
|
|
322
|
+
if (cacheHooks?.onSend) routeDef.onSend = cacheHooks.onSend
|
|
323
|
+
|
|
324
|
+
server.route(routeDef)
|
|
290
325
|
|
|
291
326
|
countRoutes++
|
|
292
327
|
}
|
|
@@ -313,7 +313,7 @@ function collectFields(
|
|
|
313
313
|
const kind = crudKind(r.method, rest)
|
|
314
314
|
// Write side: create/update bodies, plus a PUT/PATCH on the BASE path — the singleton
|
|
315
315
|
// update (e.g. PUT /company), which crudKind classifies as 'action'. Restricted to the
|
|
316
|
-
// base path so sub-route action payloads (e.g. /
|
|
316
|
+
// base path so sub-route action payloads (e.g. /posts/:id/attachments/reorder) don't leak in.
|
|
317
317
|
const isSingletonWrite = rest.length === 0 && (r.method === 'PUT' || r.method === 'PATCH')
|
|
318
318
|
if (kind === 'create' || kind === 'update' || isSingletonWrite) addSide(r.doc?.body, 'write')
|
|
319
319
|
if (kind === 'read' || kind === 'list') {
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
/**
|
|
3
|
+
* In-memory LRU + TTL cache backing the per-route `cache` capability.
|
|
4
|
+
*
|
|
5
|
+
* No external dependency: a JS `Map` preserves insertion order, so LRU is
|
|
6
|
+
* "delete + reinsert on read" (marks most-recently-used) and "evict the first key
|
|
7
|
+
* on overflow" (the least-recently-used). TTL is enforced lazily on read plus a
|
|
8
|
+
* periodic background sweep. Entries are keyed as `keyGroup :: scope :: method url`
|
|
9
|
+
* where `scope` isolates by tenant / authenticated subject / role set so a cached
|
|
10
|
+
* response is never served across tenants, users or privilege levels.
|
|
11
|
+
*
|
|
12
|
+
* The store is configured once at boot from `global.config.options.cache`
|
|
13
|
+
* (see index.ts) and exposed both as `global.cache` and via the package root
|
|
14
|
+
* exports `invalidateCache` / `cache`.
|
|
15
|
+
*/
|
|
16
|
+
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
17
|
+
import type { NormalizedRouteCache, RouteCache } from '../../types/global.js'
|
|
18
|
+
|
|
19
|
+
interface Entry {
|
|
20
|
+
value: any
|
|
21
|
+
expires: number // epoch ms; Infinity when no TTL
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CacheSettings {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
ttl: number // default TTL (seconds) for routes without an explicit ttl
|
|
27
|
+
maxEntries: number // LRU cap
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Single source of truth for the cache defaults. `enabled` is opt-in (off by
|
|
31
|
+
// default, consistent with the other optional features like `manifest` /
|
|
32
|
+
// `multi_tenant`); `ttl`/`maxEntries` are the numeric fallbacks applied per-field
|
|
33
|
+
// by configureCache, so no other module needs to restate them.
|
|
34
|
+
const DEFAULTS: CacheSettings = { enabled: false, ttl: 3600, maxEntries: 1000 }
|
|
35
|
+
|
|
36
|
+
let settings: CacheSettings = { ...DEFAULTS }
|
|
37
|
+
const store = new Map<string, Entry>()
|
|
38
|
+
let hits = 0
|
|
39
|
+
let misses = 0
|
|
40
|
+
let sweepTimer: ReturnType<typeof setInterval> | null = null
|
|
41
|
+
|
|
42
|
+
const nowMs = () => Date.now()
|
|
43
|
+
|
|
44
|
+
function startSweep(intervalMs = 60_000) {
|
|
45
|
+
if (sweepTimer) clearInterval(sweepTimer)
|
|
46
|
+
sweepTimer = setInterval(() => {
|
|
47
|
+
const t = nowMs()
|
|
48
|
+
for (const [k, e] of store) if (e.expires <= t) store.delete(k)
|
|
49
|
+
}, intervalMs)
|
|
50
|
+
// Never keep the event loop alive just for the sweep.
|
|
51
|
+
sweepTimer.unref?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** (Re)configure the cache from global options and log the effective config. */
|
|
55
|
+
export function configureCache(opts?: CacheSettings | Partial<CacheSettings>): CacheSettings {
|
|
56
|
+
settings = {
|
|
57
|
+
// Opt-in master switch: enabled only when explicitly set to true.
|
|
58
|
+
enabled: opts?.enabled === true,
|
|
59
|
+
ttl: Number(opts?.ttl) > 0 ? Number(opts?.ttl) : DEFAULTS.ttl,
|
|
60
|
+
maxEntries: Number(opts?.maxEntries) > 0 ? Number(opts?.maxEntries) : DEFAULTS.maxEntries
|
|
61
|
+
}
|
|
62
|
+
store.clear()
|
|
63
|
+
hits = 0
|
|
64
|
+
misses = 0
|
|
65
|
+
if (settings.enabled) {
|
|
66
|
+
startSweep()
|
|
67
|
+
if (log?.i)
|
|
68
|
+
log.info(
|
|
69
|
+
`Cache 🧊 enabled — ttl ${settings.ttl}s, maxEntries ${settings.maxEntries}, strategy LRU+TTL`
|
|
70
|
+
)
|
|
71
|
+
} else {
|
|
72
|
+
if (sweepTimer) {
|
|
73
|
+
clearInterval(sweepTimer)
|
|
74
|
+
sweepTimer = null
|
|
75
|
+
}
|
|
76
|
+
if (log?.i) log.info('Cache 🧊 disabled')
|
|
77
|
+
}
|
|
78
|
+
return settings
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const cacheEnabled = () => settings.enabled
|
|
82
|
+
export const defaultTtl = () => settings.ttl
|
|
83
|
+
|
|
84
|
+
export function cacheGet(key: string): any {
|
|
85
|
+
const e = store.get(key)
|
|
86
|
+
if (!e) {
|
|
87
|
+
misses++
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
if (e.expires <= nowMs()) {
|
|
91
|
+
store.delete(key)
|
|
92
|
+
misses++
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
// LRU touch: re-insert to mark as most-recently used.
|
|
96
|
+
store.delete(key)
|
|
97
|
+
store.set(key, e)
|
|
98
|
+
hits++
|
|
99
|
+
return e.value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function cacheSet(key: string, value: any, ttlSec?: number): void {
|
|
103
|
+
const ttl = Number(ttlSec) > 0 ? Number(ttlSec) : settings.ttl
|
|
104
|
+
if (store.has(key)) store.delete(key)
|
|
105
|
+
store.set(key, { value, expires: ttl > 0 ? nowMs() + ttl * 1000 : Infinity })
|
|
106
|
+
// Evict least-recently-used entries beyond the cap.
|
|
107
|
+
while (store.size > settings.maxEntries) {
|
|
108
|
+
const oldest = store.keys().next().value
|
|
109
|
+
if (oldest === undefined) break
|
|
110
|
+
store.delete(oldest)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Invalidate cached entries. With no argument clears everything; otherwise removes
|
|
116
|
+
* every entry belonging to the given key-group(s). Returns the number removed.
|
|
117
|
+
*/
|
|
118
|
+
export function invalidateCache(keyGroups?: string | string[]): number {
|
|
119
|
+
if (keyGroups == null) {
|
|
120
|
+
const n = store.size
|
|
121
|
+
store.clear()
|
|
122
|
+
if (log?.d) log.debug(`Cache 🧊 flushAll — ${n} entries removed`)
|
|
123
|
+
return n
|
|
124
|
+
}
|
|
125
|
+
const list = Array.isArray(keyGroups) ? keyGroups : [keyGroups]
|
|
126
|
+
const prefixes = list.map((g) => `${g}::`)
|
|
127
|
+
let n = 0
|
|
128
|
+
for (const k of [...store.keys()]) {
|
|
129
|
+
if (prefixes.some((p) => k.startsWith(p))) {
|
|
130
|
+
store.delete(k)
|
|
131
|
+
n++
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (log?.d) log.debug(`Cache 🧊 invalidate [${list.join(', ')}] — ${n} entries removed`)
|
|
135
|
+
return n
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function cacheStats() {
|
|
139
|
+
return { size: store.size, hits, misses, ...settings }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Facade exposed as `global.cache` and re-exported from the package root. */
|
|
143
|
+
export const cache = {
|
|
144
|
+
get: cacheGet,
|
|
145
|
+
set: cacheSet,
|
|
146
|
+
del: (key: string) => store.delete(key),
|
|
147
|
+
invalidate: invalidateCache,
|
|
148
|
+
flushAll: () => invalidateCache(),
|
|
149
|
+
stats: cacheStats,
|
|
150
|
+
enabled: cacheEnabled
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Route-config normalization + request scoping + Fastify hook factory
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** Normalize the authored `cache` prop (`true` | number | object) into the stored
|
|
158
|
+
* form, resolving the key-group to the api folder (area) when omitted. Returns
|
|
159
|
+
* undefined when there is nothing to do (absent/false and no invalidation). */
|
|
160
|
+
export function normalizeRouteCache(
|
|
161
|
+
input: boolean | number | RouteCache | undefined,
|
|
162
|
+
defaultKeyGroup: string
|
|
163
|
+
): NormalizedRouteCache | undefined {
|
|
164
|
+
if (input == null || input === false) return undefined
|
|
165
|
+
const obj: RouteCache = input === true ? {} : typeof input === 'number' ? { ttl: input } : input
|
|
166
|
+
const enabled = obj.enabled !== false
|
|
167
|
+
const invalidates =
|
|
168
|
+
obj.invalidates == null ? undefined : Array.isArray(obj.invalidates) ? obj.invalidates : [obj.invalidates]
|
|
169
|
+
if (!enabled && !invalidates?.length) return undefined
|
|
170
|
+
return {
|
|
171
|
+
enabled,
|
|
172
|
+
ttl: typeof obj.ttl === 'number' && obj.ttl > 0 ? obj.ttl : undefined,
|
|
173
|
+
keyGroup: obj.keyGroup || defaultKeyGroup,
|
|
174
|
+
invalidates
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const HIT = Symbol('volcanic.cacheHit')
|
|
179
|
+
const HEADER_ALLOW = /^(content-type|v-)/i
|
|
180
|
+
|
|
181
|
+
/** Scope segment: isolates cached data by tenant, authenticated subject and roles. */
|
|
182
|
+
function scope(req: any): string {
|
|
183
|
+
const tenant = req.tenant?.id ?? ''
|
|
184
|
+
const subject = req.user?.externalId ?? req.token?.getId?.() ?? 'anon'
|
|
185
|
+
const rolesList = typeof req.roles === 'function' ? req.roles() : []
|
|
186
|
+
const rolesKey = [...(rolesList || [])].sort().join(',')
|
|
187
|
+
return `${tenant}|${subject}|${rolesKey}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function keyFor(req: any, keyGroup: string): string {
|
|
191
|
+
return `${keyGroup}::${scope(req)}::${req.method} ${req.url}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Skip caching when a tenant is expected (multi-tenant + tenantContext) but missing,
|
|
195
|
+
* to avoid ever leaking data across tenants. */
|
|
196
|
+
function tenantMissing(req: any): boolean {
|
|
197
|
+
const mt = global.config?.options?.multi_tenant?.enabled
|
|
198
|
+
const tenantCtx = req.routeOptions?.config?.tenantContext
|
|
199
|
+
return Boolean(mt && tenantCtx !== false && !req.tenant)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Build the per-route read (preHandler) and write (onSend) hooks for a cached route. */
|
|
203
|
+
export function buildCacheHooks(routeCache: NormalizedRouteCache) {
|
|
204
|
+
const cacheable = routeCache.enabled === true
|
|
205
|
+
const keyGroup = routeCache.keyGroup
|
|
206
|
+
const ttl = routeCache.ttl
|
|
207
|
+
const invalidates = routeCache.invalidates
|
|
208
|
+
|
|
209
|
+
const preHandler = cacheable
|
|
210
|
+
? async (req: FastifyRequest, reply: FastifyReply) => {
|
|
211
|
+
if (!cacheEnabled() || (req as any).method !== 'GET' || tenantMissing(req)) return
|
|
212
|
+
const hit = cacheGet(keyFor(req, keyGroup))
|
|
213
|
+
if (hit) {
|
|
214
|
+
for (const [h, v] of Object.entries(hit.headers as Record<string, any>)) reply.header(h, v as any)
|
|
215
|
+
;(req as any)[HIT] = true
|
|
216
|
+
reply.code(hit.statusCode)
|
|
217
|
+
if (log?.d) log.debug(`Cache 🧊 hit ${keyGroup} ${(req as any).method} ${(req as any).url}`)
|
|
218
|
+
return reply.send(hit.payload)
|
|
219
|
+
}
|
|
220
|
+
if (log?.d) log.debug(`Cache 🧊 miss ${keyGroup} ${(req as any).method} ${(req as any).url}`)
|
|
221
|
+
}
|
|
222
|
+
: undefined
|
|
223
|
+
|
|
224
|
+
const onSend = async (req: FastifyRequest, reply: FastifyReply, payload: any) => {
|
|
225
|
+
// Master switch off: nothing to store and — on a volatile in-memory cache — nothing
|
|
226
|
+
// to invalidate (the store is already empty), so skip the whole hook. Also avoids
|
|
227
|
+
// scanning the store on every mutation when caching is globally disabled.
|
|
228
|
+
if (!cacheEnabled()) return payload
|
|
229
|
+
// Store fresh GET 2xx responses (skip replays and anything with Set-Cookie).
|
|
230
|
+
if (
|
|
231
|
+
cacheable &&
|
|
232
|
+
!(req as any)[HIT] &&
|
|
233
|
+
(req as any).method === 'GET' &&
|
|
234
|
+
reply.statusCode >= 200 &&
|
|
235
|
+
reply.statusCode < 300 &&
|
|
236
|
+
typeof payload === 'string' &&
|
|
237
|
+
!tenantMissing(req) &&
|
|
238
|
+
!reply.getHeader('set-cookie')
|
|
239
|
+
) {
|
|
240
|
+
const headers: Record<string, any> = {}
|
|
241
|
+
const all = reply.getHeaders()
|
|
242
|
+
for (const h of Object.keys(all)) if (HEADER_ALLOW.test(h)) headers[h] = all[h]
|
|
243
|
+
cacheSet(keyFor(req, keyGroup), { payload, headers, statusCode: reply.statusCode }, ttl)
|
|
244
|
+
}
|
|
245
|
+
// Invalidate declared key-groups after a successful mutation.
|
|
246
|
+
if (
|
|
247
|
+
invalidates?.length &&
|
|
248
|
+
(req as any).method !== 'GET' &&
|
|
249
|
+
reply.statusCode >= 200 &&
|
|
250
|
+
reply.statusCode < 300
|
|
251
|
+
) {
|
|
252
|
+
invalidateCache(invalidates)
|
|
253
|
+
}
|
|
254
|
+
return payload
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { preHandler, onSend }
|
|
258
|
+
}
|
package/lib/util/tracker.ts
CHANGED
|
@@ -88,21 +88,23 @@ function getTrackingConfigIfEnabled(req) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function isFieldChanged(oldValue, newValue) {
|
|
91
|
+
export function isFieldChanged(oldValue, newValue) {
|
|
92
92
|
if ((oldValue instanceof Date || newValue instanceof Date) && oldValue != null && newValue != undefined) {
|
|
93
93
|
return !dayjs(oldValue).isSame(dayjs(newValue))
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
if ((oldValue instanceof Object || newValue instanceof Object) && oldValue != null && newValue != undefined) {
|
|
97
97
|
const primaryKey = global.trackingConfig?.primaryKey
|
|
98
|
+
// Guard the `in` operator with an object check: `'id' in 'someString'` throws
|
|
99
|
+
// on a string primitive, so reach the string fallback instead of crashing.
|
|
98
100
|
const oldId =
|
|
99
|
-
oldValue != null && primaryKey in oldValue
|
|
101
|
+
oldValue != null && typeof oldValue === 'object' && primaryKey in oldValue
|
|
100
102
|
? oldValue[primaryKey]
|
|
101
103
|
: typeof oldValue === 'string'
|
|
102
104
|
? oldValue
|
|
103
105
|
: undefined
|
|
104
106
|
const newId =
|
|
105
|
-
newValue != null && primaryKey in newValue
|
|
107
|
+
newValue != null && typeof newValue === 'object' && primaryKey in newValue
|
|
106
108
|
? newValue[primaryKey]
|
|
107
109
|
: typeof newValue === 'string'
|
|
108
110
|
? newValue
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volcanicminds/backend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"codename": "rome",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "Volcanic (minds) backend",
|
|
8
8
|
"keywords": [
|
|
9
9
|
"volcanic",
|
|
10
10
|
"open source",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"prod": "cd dist && node server.js",
|
|
59
59
|
"start": "tsx --env-file .env server.ts",
|
|
60
60
|
"dev": "tsx watch --env-file .env server.ts",
|
|
61
|
-
"
|
|
61
|
+
"pretest": "npm run build",
|
|
62
|
+
"test": "node scripts/run-tests.mjs",
|
|
62
63
|
"test:core": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2231 NODE_ENV=memory BROWSER=false JWT_SECRET=tEst0nly_k7Qx2mNp9vLrT4wYbE6sH1jD8fG3aZ5c JWT_REFRESH_SECRET=tEst0nly_R3frsh9vLrT4wYbE6sH1jD8fG3aZ5cK7 mocha --import=tsx ./test/index.spec.ts -t 100000 --exit",
|
|
63
64
|
"test:typeorm": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/typeorm/**/*.spec.ts -t 20000 --exit",
|
|
64
65
|
"test:pglite": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/pglite/**/*.spec.ts -t 60000 --exit",
|