@flowerforce/flowerbase 1.1.2-beta.9 → 1.2.1-beta.10
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/CHANGELOG.md +12 -0
- package/README.md +28 -3
- package/dist/auth/controller.d.ts.map +1 -1
- package/dist/auth/controller.js +57 -3
- package/dist/auth/plugins/jwt.d.ts.map +1 -1
- package/dist/auth/plugins/jwt.js +49 -3
- package/dist/auth/providers/custom-function/controller.d.ts.map +1 -1
- package/dist/auth/providers/custom-function/controller.js +19 -3
- package/dist/auth/providers/local-userpass/controller.d.ts.map +1 -1
- package/dist/auth/providers/local-userpass/controller.js +125 -71
- package/dist/auth/providers/local-userpass/dtos.d.ts +11 -2
- package/dist/auth/providers/local-userpass/dtos.d.ts.map +1 -1
- package/dist/auth/utils.d.ts +53 -14
- package/dist/auth/utils.d.ts.map +1 -1
- package/dist/auth/utils.js +46 -63
- package/dist/constants.d.ts +13 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +14 -2
- package/dist/features/functions/controller.d.ts.map +1 -1
- package/dist/features/functions/controller.js +52 -11
- package/dist/features/functions/dtos.d.ts +3 -0
- package/dist/features/functions/dtos.d.ts.map +1 -1
- package/dist/features/functions/interface.d.ts +3 -0
- package/dist/features/functions/interface.d.ts.map +1 -1
- package/dist/features/functions/utils.d.ts +2 -1
- package/dist/features/functions/utils.d.ts.map +1 -1
- package/dist/features/functions/utils.js +19 -7
- package/dist/features/rules/utils.d.ts.map +1 -1
- package/dist/features/rules/utils.js +11 -2
- package/dist/features/triggers/index.d.ts.map +1 -1
- package/dist/features/triggers/index.js +48 -7
- package/dist/features/triggers/utils.d.ts.map +1 -1
- package/dist/features/triggers/utils.js +118 -27
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -21
- package/dist/services/mongodb-atlas/index.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/index.js +605 -478
- package/dist/services/mongodb-atlas/model.d.ts +2 -1
- package/dist/services/mongodb-atlas/model.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.d.ts +9 -2
- package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.js +113 -23
- package/dist/shared/handleUserRegistration.d.ts.map +1 -1
- package/dist/shared/handleUserRegistration.js +1 -0
- package/dist/shared/models/handleUserRegistration.model.d.ts +6 -2
- package/dist/shared/models/handleUserRegistration.model.d.ts.map +1 -1
- package/dist/utils/context/helpers.d.ts +7 -6
- package/dist/utils/context/helpers.d.ts.map +1 -1
- package/dist/utils/context/helpers.js +3 -0
- package/dist/utils/context/index.d.ts +1 -1
- package/dist/utils/context/index.d.ts.map +1 -1
- package/dist/utils/context/index.js +176 -5
- package/dist/utils/context/interface.d.ts +1 -1
- package/dist/utils/context/interface.d.ts.map +1 -1
- package/dist/utils/crypto/index.d.ts +1 -0
- package/dist/utils/crypto/index.d.ts.map +1 -1
- package/dist/utils/crypto/index.js +6 -2
- package/dist/utils/initializer/exposeRoutes.d.ts.map +1 -1
- package/dist/utils/initializer/exposeRoutes.js +11 -4
- package/dist/utils/initializer/registerPlugins.d.ts +3 -1
- package/dist/utils/initializer/registerPlugins.d.ts.map +1 -1
- package/dist/utils/initializer/registerPlugins.js +9 -6
- package/dist/utils/roles/helpers.js +11 -3
- package/dist/utils/roles/machines/commonValidators.d.ts.map +1 -1
- package/dist/utils/roles/machines/commonValidators.js +10 -6
- package/dist/utils/roles/machines/read/B/validators.d.ts +4 -0
- package/dist/utils/roles/machines/read/B/validators.d.ts.map +1 -0
- package/dist/utils/roles/machines/read/B/validators.js +8 -0
- package/dist/utils/roles/machines/read/C/index.d.ts.map +1 -1
- package/dist/utils/roles/machines/read/C/index.js +10 -7
- package/dist/utils/roles/machines/read/C/validators.d.ts +5 -0
- package/dist/utils/roles/machines/read/C/validators.d.ts.map +1 -0
- package/dist/utils/roles/machines/read/C/validators.js +29 -0
- package/dist/utils/roles/machines/read/D/index.d.ts.map +1 -1
- package/dist/utils/roles/machines/read/D/index.js +13 -11
- package/dist/utils/rules.d.ts +1 -1
- package/dist/utils/rules.d.ts.map +1 -1
- package/dist/utils/rules.js +26 -17
- package/jest.config.ts +2 -12
- package/jest.setup.ts +28 -0
- package/package.json +1 -2
- package/src/auth/controller.ts +70 -4
- package/src/auth/plugins/jwt.test.ts +93 -0
- package/src/auth/plugins/jwt.ts +62 -3
- package/src/auth/providers/custom-function/controller.ts +22 -5
- package/src/auth/providers/local-userpass/controller.ts +168 -96
- package/src/auth/providers/local-userpass/dtos.ts +13 -2
- package/src/auth/utils.ts +51 -86
- package/src/constants.ts +16 -3
- package/src/fastify.d.ts +32 -15
- package/src/features/functions/controller.ts +81 -14
- package/src/features/functions/dtos.ts +3 -0
- package/src/features/functions/interface.ts +3 -0
- package/src/features/functions/utils.ts +29 -8
- package/src/features/rules/utils.ts +11 -2
- package/src/features/triggers/index.ts +43 -1
- package/src/features/triggers/utils.ts +146 -38
- package/src/index.ts +69 -20
- package/src/services/mongodb-atlas/__tests__/findOneAndUpdate.test.ts +95 -0
- package/src/services/mongodb-atlas/__tests__/utils.test.ts +141 -0
- package/src/services/mongodb-atlas/index.ts +241 -90
- package/src/services/mongodb-atlas/model.ts +15 -2
- package/src/services/mongodb-atlas/utils.ts +158 -22
- package/src/shared/handleUserRegistration.ts +3 -3
- package/src/shared/models/handleUserRegistration.model.ts +8 -3
- package/src/types/fastify-raw-body.d.ts +22 -0
- package/src/utils/__tests__/STEP_B_STATES.test.ts +1 -1
- package/src/utils/__tests__/STEP_C_STATES.test.ts +1 -1
- package/src/utils/__tests__/STEP_D_STATES.test.ts +2 -2
- package/src/utils/__tests__/checkIsValidFieldNameFn.test.ts +9 -4
- package/src/utils/__tests__/registerPlugins.test.ts +16 -1
- package/src/utils/context/helpers.ts +3 -0
- package/src/utils/context/index.ts +238 -13
- package/src/utils/context/interface.ts +1 -1
- package/src/utils/crypto/index.ts +5 -1
- package/src/utils/initializer/exposeRoutes.ts +15 -8
- package/src/utils/initializer/registerPlugins.ts +15 -7
- package/src/utils/roles/helpers.ts +23 -5
- package/src/utils/roles/machines/commonValidators.ts +10 -5
- package/src/utils/roles/machines/read/B/validators.ts +8 -0
- package/src/utils/roles/machines/read/C/index.ts +11 -7
- package/src/utils/roles/machines/read/C/validators.ts +21 -0
- package/src/utils/roles/machines/read/D/index.ts +22 -12
- package/src/utils/rules.ts +31 -22
- package/tsconfig.spec.json +7 -0
|
@@ -7,6 +7,41 @@ import { readJsonContent } from '../../utils'
|
|
|
7
7
|
import { GenerateContext } from '../../utils/context'
|
|
8
8
|
import { HandlerParams, Trigger, Triggers } from './interface'
|
|
9
9
|
|
|
10
|
+
const registerOnClose = (
|
|
11
|
+
app: HandlerParams['app'],
|
|
12
|
+
handler: () => Promise<void> | void,
|
|
13
|
+
label: string
|
|
14
|
+
) => {
|
|
15
|
+
if (app.server) {
|
|
16
|
+
app.server.once('close', () => {
|
|
17
|
+
Promise.resolve(handler()).catch((error) => {
|
|
18
|
+
console.error(`${label} close error`, error)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
app.addHook('onClose', async () => {
|
|
26
|
+
try {
|
|
27
|
+
await handler()
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(`${label} close error`, error)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`${label} hook registration error`, error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shouldIgnoreStreamError = (error: unknown) => {
|
|
38
|
+
const err = error as { name?: string; message?: string }
|
|
39
|
+
if (err?.name === 'MongoClientClosedError') return true
|
|
40
|
+
if (err?.message?.includes('client was closed')) return true
|
|
41
|
+
if (err?.message?.includes('Client is closed')) return true
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
10
45
|
/**
|
|
11
46
|
* Loads trigger files from the specified directory and returns them as an array of objects.
|
|
12
47
|
* Each object contains the file name and the parsed JSON content.
|
|
@@ -54,7 +89,7 @@ const handleCronTrigger = async ({
|
|
|
54
89
|
services,
|
|
55
90
|
app
|
|
56
91
|
}: HandlerParams) => {
|
|
57
|
-
cron.schedule(config.schedule, async () => {
|
|
92
|
+
const task = cron.schedule(config.schedule, async () => {
|
|
58
93
|
await GenerateContext({
|
|
59
94
|
args: [],
|
|
60
95
|
app,
|
|
@@ -65,6 +100,7 @@ const handleCronTrigger = async ({
|
|
|
65
100
|
services
|
|
66
101
|
})
|
|
67
102
|
})
|
|
103
|
+
registerOnClose(app, () => task.stop(), 'Scheduled trigger')
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
const handleAuthenticationTrigger = async ({
|
|
@@ -75,51 +111,113 @@ const handleAuthenticationTrigger = async ({
|
|
|
75
111
|
app
|
|
76
112
|
}: HandlerParams) => {
|
|
77
113
|
const { database } = config
|
|
114
|
+
const authCollection = AUTH_CONFIG.authCollection ?? 'auth_users'
|
|
115
|
+
const collection = app.mongo.client.db(database || DB_NAME).collection(authCollection)
|
|
78
116
|
const pipeline = [
|
|
79
117
|
{
|
|
80
118
|
$match: {
|
|
81
|
-
operationType: { $in: ['insert'] }
|
|
119
|
+
operationType: { $in: ['insert', 'update', 'replace'] }
|
|
82
120
|
}
|
|
83
121
|
}
|
|
84
122
|
]
|
|
85
|
-
const changeStream =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
const changeStream = collection.watch(pipeline, {
|
|
124
|
+
fullDocument: 'whenAvailable'
|
|
125
|
+
})
|
|
126
|
+
changeStream.on('error', (error) => {
|
|
127
|
+
if (shouldIgnoreStreamError(error)) return
|
|
128
|
+
console.error('Authentication trigger change stream error', error)
|
|
129
|
+
})
|
|
91
130
|
changeStream.on('change', async function (change) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
131
|
+
const operationType = change['operationType' as keyof typeof change] as string | undefined
|
|
132
|
+
const documentKey = change['documentKey' as keyof typeof change] as
|
|
133
|
+
| { _id?: unknown }
|
|
134
|
+
| undefined
|
|
135
|
+
const fullDocument = change['fullDocument' as keyof typeof change] as
|
|
136
|
+
| Record<string, unknown>
|
|
137
|
+
| null
|
|
138
|
+
if (!documentKey?._id) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const updateDescription = change[
|
|
143
|
+
'updateDescription' as keyof typeof change
|
|
144
|
+
] as { updatedFields?: Record<string, unknown> } | undefined
|
|
145
|
+
const updatedStatus = updateDescription?.updatedFields?.status
|
|
146
|
+
let confirmedCandidate = false
|
|
147
|
+
let confirmedDocument =
|
|
148
|
+
fullDocument as Record<string, unknown> | null
|
|
149
|
+
|
|
150
|
+
if (operationType === 'update') {
|
|
151
|
+
if (updatedStatus === 'confirmed') {
|
|
152
|
+
confirmedCandidate = true
|
|
153
|
+
} else if (updatedStatus === undefined) {
|
|
154
|
+
const fetched = await collection.findOne({
|
|
155
|
+
_id: documentKey._id
|
|
156
|
+
}) as Record<string, unknown> | null
|
|
157
|
+
confirmedDocument = fetched ?? confirmedDocument
|
|
158
|
+
confirmedCandidate = (confirmedDocument as { status?: string } | null)?.status === 'confirmed'
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
confirmedCandidate = (confirmedDocument as { status?: string } | null)?.status === 'confirmed'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!confirmedCandidate) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const updateResult = await collection.findOneAndUpdate(
|
|
169
|
+
{
|
|
170
|
+
_id: documentKey._id,
|
|
171
|
+
status: 'confirmed',
|
|
172
|
+
on_user_creation_triggered_at: { $exists: false }
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
$set: {
|
|
176
|
+
on_user_creation_triggered_at: new Date()
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
returnDocument: 'after'
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const document =
|
|
185
|
+
(updateResult?.value as Record<string, unknown> | null) ?? confirmedDocument
|
|
186
|
+
if (!document) {
|
|
187
|
+
return
|
|
121
188
|
}
|
|
189
|
+
|
|
190
|
+
delete (document as { password?: unknown }).password
|
|
191
|
+
|
|
192
|
+
const currentUser = { ...document }
|
|
193
|
+
delete (currentUser as { password?: unknown }).password
|
|
194
|
+
await GenerateContext({
|
|
195
|
+
args: [{
|
|
196
|
+
user: {
|
|
197
|
+
...currentUser,
|
|
198
|
+
id: (currentUser as { _id: { toString: () => string } })._id.toString(),
|
|
199
|
+
data: {
|
|
200
|
+
_id: (currentUser as { _id: { toString: () => string } })._id.toString(),
|
|
201
|
+
email: (currentUser as { email?: string }).email
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}],
|
|
205
|
+
app,
|
|
206
|
+
rules: StateManager.select("rules"),
|
|
207
|
+
user: {}, // TODO from currentUser ??
|
|
208
|
+
currentFunction: triggerHandler,
|
|
209
|
+
functionsList,
|
|
210
|
+
services,
|
|
211
|
+
runAsSystem: true
|
|
212
|
+
})
|
|
122
213
|
})
|
|
214
|
+
registerOnClose(
|
|
215
|
+
app,
|
|
216
|
+
async () => {
|
|
217
|
+
await changeStream.close()
|
|
218
|
+
},
|
|
219
|
+
'Authentication trigger'
|
|
220
|
+
)
|
|
123
221
|
}
|
|
124
222
|
|
|
125
223
|
/**
|
|
@@ -175,6 +273,10 @@ const handleDataBaseTrigger = async ({
|
|
|
175
273
|
? 'whenAvailable'
|
|
176
274
|
: undefined
|
|
177
275
|
})
|
|
276
|
+
changeStream.on('error', (error) => {
|
|
277
|
+
if (shouldIgnoreStreamError(error)) return
|
|
278
|
+
console.error('Database trigger change stream error', error)
|
|
279
|
+
})
|
|
178
280
|
changeStream.on('change', async function ({ clusterTime, ...change }) {
|
|
179
281
|
await GenerateContext({
|
|
180
282
|
args: [change],
|
|
@@ -186,7 +288,13 @@ const handleDataBaseTrigger = async ({
|
|
|
186
288
|
services
|
|
187
289
|
})
|
|
188
290
|
})
|
|
189
|
-
|
|
291
|
+
registerOnClose(
|
|
292
|
+
app,
|
|
293
|
+
async () => {
|
|
294
|
+
await changeStream.close()
|
|
295
|
+
},
|
|
296
|
+
'Database trigger'
|
|
297
|
+
)
|
|
190
298
|
}
|
|
191
299
|
|
|
192
300
|
export const TRIGGER_HANDLERS = {
|
package/src/index.ts
CHANGED
|
@@ -14,12 +14,22 @@ import { exposeRoutes } from './utils/initializer/exposeRoutes'
|
|
|
14
14
|
import { registerPlugins } from './utils/initializer/registerPlugins'
|
|
15
15
|
export * from './model'
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
export type ALLOWED_METHODS = "GET" | "POST" | "PUT" | "DELETE"
|
|
19
|
+
|
|
20
|
+
export type CorsConfig = {
|
|
21
|
+
origin: string
|
|
22
|
+
methods: ALLOWED_METHODS[]
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export type InitializeConfig = {
|
|
18
26
|
projectId: string
|
|
19
27
|
mongodbUrl?: string
|
|
20
28
|
jwtSecret?: string
|
|
21
29
|
port?: number
|
|
22
30
|
host?: string
|
|
31
|
+
corsConfig?: CorsConfig
|
|
32
|
+
basePath?: string
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -35,26 +45,38 @@ export async function initialize({
|
|
|
35
45
|
host = DEFAULT_CONFIG.HOST,
|
|
36
46
|
jwtSecret = DEFAULT_CONFIG.JWT_SECRET,
|
|
37
47
|
port = DEFAULT_CONFIG.PORT,
|
|
38
|
-
mongodbUrl = DEFAULT_CONFIG.MONGODB_URL
|
|
48
|
+
mongodbUrl = DEFAULT_CONFIG.MONGODB_URL,
|
|
49
|
+
corsConfig = DEFAULT_CONFIG.CORS_OPTIONS,
|
|
50
|
+
basePath
|
|
39
51
|
}: InitializeConfig) {
|
|
52
|
+
if (!jwtSecret || jwtSecret.trim().length === 0) {
|
|
53
|
+
throw new Error('JWT secret missing: set JWT_SECRET or pass jwtSecret to initialize()')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resolvedBasePath = basePath ?? require.main?.path ?? process.cwd()
|
|
40
57
|
const fastify = Fastify({
|
|
41
58
|
logger: !!DEFAULT_CONFIG.ENABLE_LOGGER
|
|
42
59
|
})
|
|
43
60
|
|
|
44
|
-
const
|
|
45
|
-
|
|
61
|
+
const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined
|
|
62
|
+
const logInfo = (...args: unknown[]) => {
|
|
63
|
+
if (!isTest) {
|
|
64
|
+
console.log(...args)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
46
67
|
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
logInfo("BASE PATH", resolvedBasePath)
|
|
69
|
+
logInfo("CURRENT PORT", port)
|
|
70
|
+
logInfo("CURRENT HOST", host)
|
|
49
71
|
|
|
50
|
-
const functionsList = await loadFunctions(
|
|
51
|
-
|
|
52
|
-
const triggersList = await loadTriggers(
|
|
53
|
-
|
|
54
|
-
const endpointsList = await loadEndpoints(
|
|
55
|
-
|
|
56
|
-
const rulesList = await loadRules(
|
|
57
|
-
|
|
72
|
+
const functionsList = await loadFunctions(resolvedBasePath)
|
|
73
|
+
logInfo("Functions LOADED")
|
|
74
|
+
const triggersList = await loadTriggers(resolvedBasePath)
|
|
75
|
+
logInfo("Triggers LOADED")
|
|
76
|
+
const endpointsList = await loadEndpoints(resolvedBasePath)
|
|
77
|
+
logInfo("Endpoints LOADED")
|
|
78
|
+
const rulesList = await loadRules(resolvedBasePath)
|
|
79
|
+
logInfo("Rules LOADED")
|
|
58
80
|
|
|
59
81
|
const stateConfig = {
|
|
60
82
|
functions: functionsList,
|
|
@@ -78,7 +100,33 @@ export async function initialize({
|
|
|
78
100
|
deepLinking: false
|
|
79
101
|
},
|
|
80
102
|
uiHooks: {
|
|
81
|
-
onRequest: function (request, reply, next) {
|
|
103
|
+
onRequest: function (request, reply, next) {
|
|
104
|
+
const swaggerUser = DEFAULT_CONFIG.SWAGGER_UI_USER
|
|
105
|
+
const swaggerPassword = DEFAULT_CONFIG.SWAGGER_UI_PASSWORD
|
|
106
|
+
if (!swaggerUser && !swaggerPassword) {
|
|
107
|
+
next()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const authHeader = request.headers.authorization
|
|
111
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
112
|
+
reply
|
|
113
|
+
.code(401)
|
|
114
|
+
.header('WWW-Authenticate', 'Basic realm="Swagger UI"')
|
|
115
|
+
.send({ message: 'Unauthorized' })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const encoded = authHeader.slice('Basic '.length)
|
|
119
|
+
const decoded = Buffer.from(encoded, 'base64').toString('utf8')
|
|
120
|
+
const [user, pass] = decoded.split(':')
|
|
121
|
+
if (user !== swaggerUser || pass !== swaggerPassword) {
|
|
122
|
+
reply
|
|
123
|
+
.code(401)
|
|
124
|
+
.header('WWW-Authenticate', 'Basic realm="Swagger UI"')
|
|
125
|
+
.send({ message: 'Unauthorized' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
next()
|
|
129
|
+
},
|
|
82
130
|
preHandler: function (request, reply, next) { next() }
|
|
83
131
|
},
|
|
84
132
|
staticCSP: true,
|
|
@@ -91,18 +139,19 @@ export async function initialize({
|
|
|
91
139
|
register: fastify.register,
|
|
92
140
|
mongodbUrl,
|
|
93
141
|
jwtSecret,
|
|
94
|
-
functionsList
|
|
142
|
+
functionsList,
|
|
143
|
+
corsConfig
|
|
95
144
|
})
|
|
96
145
|
|
|
97
|
-
|
|
146
|
+
logInfo('Plugins registration COMPLETED')
|
|
98
147
|
await exposeRoutes(fastify)
|
|
99
|
-
|
|
148
|
+
logInfo('APP Routes registration COMPLETED')
|
|
100
149
|
await registerFunctions({ app: fastify, functionsList, rulesList })
|
|
101
|
-
|
|
150
|
+
logInfo('Functions registration COMPLETED')
|
|
102
151
|
await generateEndpoints({ app: fastify, functionsList, endpointsList, rulesList })
|
|
103
|
-
|
|
152
|
+
logInfo('HTTP Endpoints registration COMPLETED')
|
|
104
153
|
fastify.ready(() => {
|
|
105
|
-
|
|
154
|
+
logInfo("FASTIFY IS READY")
|
|
106
155
|
if (triggersList?.length > 0) activateTriggers({ fastify, triggersList, functionsList })
|
|
107
156
|
})
|
|
108
157
|
await fastify.listen({ port, host })
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Document, ObjectId } from 'mongodb'
|
|
2
|
+
import MongoDbAtlas from '..'
|
|
3
|
+
import { Role, Rules } from '../../../features/rules/interface'
|
|
4
|
+
|
|
5
|
+
const createAppWithCollection = (collection: Record<string, unknown>) => ({
|
|
6
|
+
mongo: {
|
|
7
|
+
client: {
|
|
8
|
+
db: jest.fn().mockReturnValue({
|
|
9
|
+
collection: jest.fn().mockReturnValue(collection)
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const createRules = (roleOverrides: Partial<Role> = {}): Rules => ({
|
|
16
|
+
todos: {
|
|
17
|
+
database: 'db',
|
|
18
|
+
collection: 'todos',
|
|
19
|
+
filters: [],
|
|
20
|
+
roles: [
|
|
21
|
+
{
|
|
22
|
+
name: 'owner',
|
|
23
|
+
apply_when: {},
|
|
24
|
+
insert: true,
|
|
25
|
+
delete: true,
|
|
26
|
+
search: true,
|
|
27
|
+
read: true,
|
|
28
|
+
write: true,
|
|
29
|
+
...roleOverrides
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('mongodb-atlas findOneAndUpdate', () => {
|
|
36
|
+
it('applies write/read validation and returns the updated document', async () => {
|
|
37
|
+
const id = new ObjectId()
|
|
38
|
+
const existingDoc = { _id: id, title: 'Old', userId: 'user-1' }
|
|
39
|
+
const updatedDoc = { _id: id, title: 'New', userId: 'user-1' }
|
|
40
|
+
const findOne = jest.fn().mockResolvedValue(existingDoc)
|
|
41
|
+
const aggregate = jest.fn().mockReturnValue({
|
|
42
|
+
toArray: jest.fn().mockResolvedValue([updatedDoc])
|
|
43
|
+
})
|
|
44
|
+
const findOneAndUpdate = jest.fn().mockResolvedValue(updatedDoc)
|
|
45
|
+
const collection = {
|
|
46
|
+
collectionName: 'todos',
|
|
47
|
+
findOne,
|
|
48
|
+
aggregate,
|
|
49
|
+
findOneAndUpdate
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const app = createAppWithCollection(collection)
|
|
53
|
+
const operators = MongoDbAtlas(app as any, {
|
|
54
|
+
rules: createRules(),
|
|
55
|
+
user: { id: 'user-1' }
|
|
56
|
+
})
|
|
57
|
+
.db('db')
|
|
58
|
+
.collection('todos')
|
|
59
|
+
|
|
60
|
+
const result = await operators.findOneAndUpdate({ _id: id }, { $set: { title: 'New' } })
|
|
61
|
+
|
|
62
|
+
expect(findOne).toHaveBeenCalled()
|
|
63
|
+
expect(aggregate).toHaveBeenCalled()
|
|
64
|
+
expect(findOneAndUpdate).toHaveBeenCalledWith(
|
|
65
|
+
{ $and: [{ _id: id }] },
|
|
66
|
+
{ $set: { title: 'New' } }
|
|
67
|
+
)
|
|
68
|
+
expect(result).toEqual(updatedDoc)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects updates when write permission is denied', async () => {
|
|
72
|
+
const id = new ObjectId()
|
|
73
|
+
const existingDoc = { _id: id, title: 'Old', userId: 'user-1' }
|
|
74
|
+
const findOne = jest.fn().mockResolvedValue(existingDoc)
|
|
75
|
+
const findOneAndUpdate = jest.fn()
|
|
76
|
+
const collection = {
|
|
77
|
+
collectionName: 'todos',
|
|
78
|
+
findOne,
|
|
79
|
+
findOneAndUpdate
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const app = createAppWithCollection(collection)
|
|
83
|
+
const operators = MongoDbAtlas(app as any, {
|
|
84
|
+
rules: createRules({ write: false }),
|
|
85
|
+
user: { id: 'user-1' }
|
|
86
|
+
})
|
|
87
|
+
.db('db')
|
|
88
|
+
.collection('todos')
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
operators.findOneAndUpdate({ _id: id }, { title: 'Denied' } as Document)
|
|
92
|
+
).rejects.toThrow('Update not permitted')
|
|
93
|
+
expect(findOneAndUpdate).not.toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline } from '../utils'
|
|
2
|
+
import { Role } from '../../../utils/roles/interface'
|
|
3
|
+
|
|
4
|
+
describe('MongoDB Atlas aggregate helpers', () => {
|
|
5
|
+
describe('ensureClientPipelineStages', () => {
|
|
6
|
+
it('allows safe stages', () => {
|
|
7
|
+
expect(() =>
|
|
8
|
+
ensureClientPipelineStages([{ $match: { active: true } }])
|
|
9
|
+
).not.toThrow()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('throws when unsupported stage is used', () => {
|
|
13
|
+
expect(() =>
|
|
14
|
+
ensureClientPipelineStages([{ $replaceRoot: { newRoot: '$$ROOT' } }])
|
|
15
|
+
).toThrow('Stage $replaceRoot is not allowed in client aggregate pipelines')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('recurses into nested lookups and facets without throwing', () => {
|
|
19
|
+
const pipeline = [
|
|
20
|
+
{
|
|
21
|
+
$lookup: {
|
|
22
|
+
from: 'other',
|
|
23
|
+
localField: 'ref',
|
|
24
|
+
foreignField: '_id',
|
|
25
|
+
as: 'joined',
|
|
26
|
+
pipeline: [
|
|
27
|
+
{
|
|
28
|
+
$facet: {
|
|
29
|
+
safe: [{ $match: { foo: 'bar' } }]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
expect(() => ensureClientPipelineStages(pipeline)).not.toThrow()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('getHiddenFieldsFromRulesConfig', () => {
|
|
42
|
+
it('returns fields marked as unreadable', () => {
|
|
43
|
+
const roles: Role[] = [
|
|
44
|
+
{
|
|
45
|
+
name: 'demo',
|
|
46
|
+
apply_when: {},
|
|
47
|
+
insert: true,
|
|
48
|
+
delete: true,
|
|
49
|
+
search: true,
|
|
50
|
+
read: true,
|
|
51
|
+
write: true,
|
|
52
|
+
fields: {
|
|
53
|
+
secret: { read: false, write: false },
|
|
54
|
+
visible: { read: true, write: false }
|
|
55
|
+
},
|
|
56
|
+
additional_fields: {
|
|
57
|
+
hiddenExtra: { read: false, write: false }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const hidden = getHiddenFieldsFromRulesConfig({
|
|
63
|
+
roles
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(hidden).toEqual(expect.arrayContaining(['secret', 'hiddenExtra']))
|
|
67
|
+
expect(hidden).not.toContain('visible')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('prependUnsetStage', () => {
|
|
72
|
+
it('inserts an $unset stage when hidden fields are present', () => {
|
|
73
|
+
const pipeline = [{ $match: { active: true } }]
|
|
74
|
+
const result = prependUnsetStage(pipeline, ['password', 'secret'])
|
|
75
|
+
|
|
76
|
+
expect(result[0]).toEqual({ $unset: ['password', 'secret'] })
|
|
77
|
+
expect(result[1]).toEqual(pipeline[0])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns original pipeline if no hidden fields exist', () => {
|
|
81
|
+
const pipeline = [{ $match: { active: true } }]
|
|
82
|
+
expect(prependUnsetStage(pipeline, [])).toEqual(pipeline)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('applyAccessControlToPipeline', () => {
|
|
87
|
+
it('prepends hidden-field $unset inside lookup pipelines for client requests', () => {
|
|
88
|
+
const rules = {
|
|
89
|
+
main: {
|
|
90
|
+
filters: [],
|
|
91
|
+
roles: []
|
|
92
|
+
},
|
|
93
|
+
other: {
|
|
94
|
+
filters: [],
|
|
95
|
+
roles: [
|
|
96
|
+
{
|
|
97
|
+
name: 'lookup-role',
|
|
98
|
+
apply_when: {},
|
|
99
|
+
insert: true,
|
|
100
|
+
delete: true,
|
|
101
|
+
search: true,
|
|
102
|
+
read: true,
|
|
103
|
+
write: true,
|
|
104
|
+
fields: {
|
|
105
|
+
secretField: { read: false, write: false }
|
|
106
|
+
},
|
|
107
|
+
additional_fields: {
|
|
108
|
+
secretAux: { read: false, write: false }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pipeline = [
|
|
116
|
+
{
|
|
117
|
+
$lookup: {
|
|
118
|
+
from: 'other',
|
|
119
|
+
localField: 'ref',
|
|
120
|
+
foreignField: '_id',
|
|
121
|
+
as: 'joined',
|
|
122
|
+
pipeline: [{ $match: { active: true } }]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
const sanitized = applyAccessControlToPipeline(
|
|
128
|
+
pipeline,
|
|
129
|
+
rules,
|
|
130
|
+
{},
|
|
131
|
+
'main',
|
|
132
|
+
{ isClientPipeline: true }
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const lookupPipeline = sanitized[0].$lookup.pipeline
|
|
136
|
+
expect(lookupPipeline?.[0]).toEqual({
|
|
137
|
+
$unset: ['secretField', 'secretAux']
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|