@flowerforce/flowerbase 1.7.5-beta.2 → 1.7.5-beta.4
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/dist/features/functions/controller.d.ts +2 -0
- package/dist/features/functions/controller.d.ts.map +1 -1
- package/dist/features/functions/controller.js +244 -19
- package/dist/services/api/index.d.ts +4 -0
- package/dist/services/api/index.d.ts.map +1 -1
- package/dist/services/api/utils.d.ts +1 -0
- package/dist/services/api/utils.d.ts.map +1 -1
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.js +17 -1
- package/dist/utils/context/helpers.d.ts +12 -0
- package/dist/utils/context/helpers.d.ts.map +1 -1
- package/dist/utils/roles/helpers.d.ts.map +1 -1
- package/dist/utils/roles/helpers.js +19 -4
- package/dist/utils/roles/interface.d.ts +10 -6
- package/dist/utils/roles/interface.d.ts.map +1 -1
- package/dist/utils/roles/machines/commonValidators.js +2 -2
- package/dist/utils/roles/machines/fieldPermissions.d.ts +8 -0
- package/dist/utils/roles/machines/fieldPermissions.d.ts.map +1 -0
- package/dist/utils/roles/machines/fieldPermissions.js +67 -0
- package/dist/utils/roles/machines/read/A/index.d.ts.map +1 -1
- package/dist/utils/roles/machines/read/A/index.js +4 -3
- package/dist/utils/roles/machines/read/C/index.d.ts.map +1 -1
- package/dist/utils/roles/machines/read/C/index.js +16 -16
- package/dist/utils/roles/machines/read/C/validators.js +2 -2
- package/dist/utils/roles/machines/read/D/index.js +1 -1
- package/dist/utils/roles/machines/read/D/validators.d.ts +1 -1
- package/dist/utils/roles/machines/read/D/validators.d.ts.map +1 -1
- package/dist/utils/roles/machines/read/D/validators.js +19 -21
- package/dist/utils/roles/machines/write/B/index.d.ts.map +1 -1
- package/dist/utils/roles/machines/write/B/index.js +12 -9
- package/dist/utils/roles/machines/write/C/index.js +1 -1
- package/dist/utils/roles/machines/write/C/validators.d.ts +1 -1
- package/dist/utils/roles/machines/write/C/validators.d.ts.map +1 -1
- package/dist/utils/roles/machines/write/C/validators.js +16 -21
- package/package.json +1 -1
- package/src/features/functions/__tests__/watch-filter.test.ts +116 -0
- package/src/features/functions/controller.ts +282 -22
- package/src/features/triggers/__tests__/index.test.ts +2 -2
- package/src/services/mongodb-atlas/__tests__/findOneAndUpdate.test.ts +1 -1
- package/src/services/mongodb-atlas/utils.ts +19 -4
- package/src/utils/__tests__/STEP_A_STATES.test.ts +24 -2
- package/src/utils/__tests__/STEP_C_STATES.test.ts +61 -27
- package/src/utils/__tests__/STEP_D_STATES.test.ts +9 -9
- package/src/utils/__tests__/WRITE_STEP_B_STATES.test.ts +184 -0
- package/src/utils/__tests__/checkAdditionalFieldsFn.test.ts +2 -2
- package/src/utils/__tests__/checkFieldsPropertyExists.test.ts +13 -0
- package/src/utils/__tests__/checkIsValidFieldNameFn.test.ts +52 -121
- package/src/utils/__tests__/evaluateTopLevelReadFn.test.ts +10 -1
- package/src/utils/__tests__/evaluateTopLevelWriteFn.test.ts +21 -5
- package/src/utils/roles/helpers.ts +18 -4
- package/src/utils/roles/interface.ts +13 -6
- package/src/utils/roles/machines/commonValidators.ts +1 -1
- package/src/utils/roles/machines/fieldPermissions.ts +86 -0
- package/src/utils/roles/machines/read/A/index.ts +4 -3
- package/src/utils/roles/machines/read/C/index.ts +18 -18
- package/src/utils/roles/machines/read/C/validators.ts +2 -2
- package/src/utils/roles/machines/read/D/index.ts +1 -1
- package/src/utils/roles/machines/read/D/validators.ts +12 -25
- package/src/utils/roles/machines/write/B/index.ts +12 -9
- package/src/utils/roles/machines/write/C/index.ts +1 -1
- package/src/utils/roles/machines/write/C/validators.ts +9 -26
|
@@ -54,11 +54,29 @@ const serializeEjson = (value: unknown) =>
|
|
|
54
54
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
55
55
|
!!value && typeof value === 'object' && !Array.isArray(value)
|
|
56
56
|
|
|
57
|
+
const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
|
|
58
|
+
if (!isRecord(value)) return false
|
|
59
|
+
const prototype = Object.getPrototypeOf(value)
|
|
60
|
+
return prototype === Object.prototype || prototype === null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isCursorLike = (
|
|
64
|
+
value: unknown
|
|
65
|
+
): value is { toArray: () => Promise<unknown> | unknown } => {
|
|
66
|
+
if (!value || typeof value !== 'object') return false
|
|
67
|
+
return typeof (value as { toArray?: unknown }).toArray === 'function'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const normalizeFunctionResult = async (value: unknown) => {
|
|
71
|
+
if (!isCursorLike(value)) return value
|
|
72
|
+
return await value.toArray()
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
type WatchSubscriber = {
|
|
58
76
|
id: string
|
|
59
77
|
user: Record<string, any>
|
|
60
78
|
response: ServerResponse
|
|
61
|
-
|
|
79
|
+
documentFilter?: Document
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
type SharedWatchStream = {
|
|
@@ -74,13 +92,221 @@ type SharedWatchStream = {
|
|
|
74
92
|
|
|
75
93
|
const sharedWatchStreams = new Map<string, SharedWatchStream>()
|
|
76
94
|
let watchSubscriberCounter = 0
|
|
95
|
+
const maxSharedWatchStreams = Number(process.env.MAX_SHARED_WATCH_STREAMS || 200)
|
|
96
|
+
const debugWatchStreams = process.env.DEBUG_FUNCTIONS === 'true'
|
|
97
|
+
|
|
98
|
+
const changeEventRootKeys = new Set([
|
|
99
|
+
'_id',
|
|
100
|
+
'operationType',
|
|
101
|
+
'clusterTime',
|
|
102
|
+
'txnNumber',
|
|
103
|
+
'lsid',
|
|
104
|
+
'ns',
|
|
105
|
+
'documentKey',
|
|
106
|
+
'fullDocument',
|
|
107
|
+
'updateDescription'
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
const isChangeEventPath = (key: string) => {
|
|
111
|
+
if (changeEventRootKeys.has(key)) return true
|
|
112
|
+
return (
|
|
113
|
+
key.startsWith('ns.') ||
|
|
114
|
+
key.startsWith('documentKey.') ||
|
|
115
|
+
key.startsWith('fullDocument.') ||
|
|
116
|
+
key.startsWith('updateDescription.')
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isOpaqueChangeEventObjectKey = (key: string) =>
|
|
121
|
+
key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription'
|
|
122
|
+
|
|
123
|
+
export const mapWatchFilterToChangeStreamMatch = (value: unknown): unknown => {
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
return value.map((item) => mapWatchFilterToChangeStreamMatch(item))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isPlainRecord(value)) return value
|
|
129
|
+
|
|
130
|
+
return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
|
|
131
|
+
if (key.startsWith('$')) {
|
|
132
|
+
acc[key] = mapWatchFilterToChangeStreamMatch(current)
|
|
133
|
+
return acc
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isOpaqueChangeEventObjectKey(key)) {
|
|
137
|
+
acc[key] = current
|
|
138
|
+
return acc
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isChangeEventPath(key)) {
|
|
142
|
+
acc[key] = mapWatchFilterToChangeStreamMatch(current)
|
|
143
|
+
return acc
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
acc[`fullDocument.${key}`] = mapWatchFilterToChangeStreamMatch(current)
|
|
147
|
+
return acc
|
|
148
|
+
}, {})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const isLogicalOperator = (key: string) => key === '$and' || key === '$or' || key === '$nor'
|
|
152
|
+
|
|
153
|
+
export const mapWatchFilterToDocumentQuery = (value: unknown): unknown => {
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
const mapped = value
|
|
156
|
+
.map((item) => mapWatchFilterToDocumentQuery(item))
|
|
157
|
+
.filter((item) => !(isRecord(item) && Object.keys(item).length === 0))
|
|
158
|
+
return mapped
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!isPlainRecord(value)) return value
|
|
162
|
+
|
|
163
|
+
return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
|
|
164
|
+
if (key.startsWith('$')) {
|
|
165
|
+
const mapped = mapWatchFilterToDocumentQuery(current)
|
|
166
|
+
if (isLogicalOperator(key) && Array.isArray(mapped)) {
|
|
167
|
+
if (mapped.length > 0) {
|
|
168
|
+
acc[key] = mapped
|
|
169
|
+
}
|
|
170
|
+
return acc
|
|
171
|
+
}
|
|
172
|
+
if (typeof mapped !== 'undefined') {
|
|
173
|
+
acc[key] = mapped
|
|
174
|
+
}
|
|
175
|
+
return acc
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (key === 'fullDocument') {
|
|
179
|
+
if (!isPlainRecord(current)) return acc
|
|
180
|
+
const mapped = mapWatchFilterToDocumentQuery(current)
|
|
181
|
+
if (isRecord(mapped)) {
|
|
182
|
+
Object.assign(acc, mapped)
|
|
183
|
+
}
|
|
184
|
+
return acc
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (key.startsWith('fullDocument.')) {
|
|
188
|
+
const docKey = key.slice('fullDocument.'.length)
|
|
189
|
+
if (!docKey) return acc
|
|
190
|
+
acc[docKey] = mapWatchFilterToDocumentQuery(current)
|
|
191
|
+
return acc
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (isChangeEventPath(key)) {
|
|
195
|
+
return acc
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
acc[key] = mapWatchFilterToDocumentQuery(current)
|
|
199
|
+
return acc
|
|
200
|
+
}, {})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const toStableValue = (value: unknown): unknown => {
|
|
204
|
+
if (Array.isArray(value)) {
|
|
205
|
+
return value.map((item) => toStableValue(item))
|
|
206
|
+
}
|
|
207
|
+
if (!isPlainRecord(value)) return value
|
|
208
|
+
|
|
209
|
+
const sortedEntries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
|
210
|
+
return sortedEntries.reduce<Record<string, unknown>>((acc, [key, current]) => {
|
|
211
|
+
acc[key] = toStableValue(current)
|
|
212
|
+
return acc
|
|
213
|
+
}, {})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const stableSerialize = (value: unknown) => {
|
|
217
|
+
const serialized = EJSON.serialize(value, { relaxed: false })
|
|
218
|
+
return JSON.stringify(toStableValue(serialized))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const getWatchPermissionContext = (user: Record<string, any>) => ({
|
|
222
|
+
role: user.role,
|
|
223
|
+
roles: user.roles,
|
|
224
|
+
data: user.data,
|
|
225
|
+
custom_data: user.custom_data,
|
|
226
|
+
user_data: user.user_data
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const resolveWatchStream = (
|
|
230
|
+
database: string,
|
|
231
|
+
collection: string,
|
|
232
|
+
watchArgs: Record<string, unknown>,
|
|
233
|
+
user: Record<string, any>
|
|
234
|
+
) => {
|
|
235
|
+
const keys = Object.keys(watchArgs)
|
|
236
|
+
const hasOnlyAllowedKeys = keys.every((key) => key === 'filter' || key === 'ids')
|
|
237
|
+
if (!hasOnlyAllowedKeys) {
|
|
238
|
+
throw new Error('watch options support only "filter" or "ids"')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const extraFilter = parseWatchFilter(watchArgs)
|
|
242
|
+
const ids = watchArgs.ids
|
|
243
|
+
if (extraFilter && typeof ids !== 'undefined') {
|
|
244
|
+
throw new Error('watch options cannot include both "ids" and "filter"')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const pipeline: Document[] = []
|
|
248
|
+
if (extraFilter) {
|
|
249
|
+
pipeline.push({ $match: mapWatchFilterToChangeStreamMatch(extraFilter) as Document })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (typeof ids !== 'undefined') {
|
|
253
|
+
if (!Array.isArray(ids)) {
|
|
254
|
+
throw new Error('watch ids must be an array')
|
|
255
|
+
}
|
|
256
|
+
pipeline.push({
|
|
257
|
+
$match: {
|
|
258
|
+
$or: [
|
|
259
|
+
{ 'documentKey._id': { $in: ids } },
|
|
260
|
+
{ 'fullDocument._id': { $in: ids } }
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const options = { fullDocument: 'updateLookup' }
|
|
267
|
+
const streamKey = stableSerialize({
|
|
268
|
+
database,
|
|
269
|
+
collection,
|
|
270
|
+
pipeline,
|
|
271
|
+
options,
|
|
272
|
+
permissionContext: getWatchPermissionContext(user)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
return { extraFilter, options, pipeline, streamKey }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const getWatchStats = () => {
|
|
279
|
+
let subscribers = 0
|
|
280
|
+
for (const hub of sharedWatchStreams.values()) {
|
|
281
|
+
subscribers += hub.subscribers.size
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
hubs: sharedWatchStreams.size,
|
|
285
|
+
subscribers
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const logWatchStats = (
|
|
290
|
+
event: string,
|
|
291
|
+
details?: Record<string, unknown>
|
|
292
|
+
) => {
|
|
293
|
+
if (!debugWatchStreams) return
|
|
294
|
+
const stats = getWatchStats()
|
|
295
|
+
console.log('[watch-pool]', event, {
|
|
296
|
+
hubs: stats.hubs,
|
|
297
|
+
subscribers: stats.subscribers,
|
|
298
|
+
...details
|
|
299
|
+
})
|
|
300
|
+
}
|
|
77
301
|
|
|
78
302
|
const parseWatchFilter = (args: unknown): Document | undefined => {
|
|
79
303
|
if (!isRecord(args)) return undefined
|
|
80
|
-
const candidate =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
304
|
+
const candidate = isRecord(args.filter) ? args.filter : undefined
|
|
305
|
+
if (!candidate) return undefined
|
|
306
|
+
if ('$match' in candidate) {
|
|
307
|
+
throw new Error('watch filter must be a query object, not a $match stage')
|
|
308
|
+
}
|
|
309
|
+
return candidate as Document
|
|
84
310
|
}
|
|
85
311
|
|
|
86
312
|
const isReadableDocumentResult = (value: unknown) =>
|
|
@@ -177,12 +403,13 @@ export const functionsController: FunctionController = async (
|
|
|
177
403
|
functionsList,
|
|
178
404
|
services
|
|
179
405
|
})
|
|
180
|
-
|
|
406
|
+
const normalizedResult = await normalizeFunctionResult(result)
|
|
407
|
+
if (isReturnedError(normalizedResult)) {
|
|
181
408
|
res.type('application/json')
|
|
182
|
-
return JSON.stringify({ message:
|
|
409
|
+
return JSON.stringify({ message: normalizedResult.message, name: normalizedResult.name })
|
|
183
410
|
}
|
|
184
411
|
res.type('application/json')
|
|
185
|
-
return serializeEjson(
|
|
412
|
+
return serializeEjson(normalizedResult)
|
|
186
413
|
} catch (error) {
|
|
187
414
|
res.status(400)
|
|
188
415
|
res.type('application/json')
|
|
@@ -211,7 +438,9 @@ export const functionsController: FunctionController = async (
|
|
|
211
438
|
)
|
|
212
439
|
const config = EJSON.deserialize(decodedConfig) as Base64Function
|
|
213
440
|
|
|
214
|
-
const [{ database, collection, ...
|
|
441
|
+
const [{ database, collection, ...watchArgsInput }] = config.arguments
|
|
442
|
+
const watchArgs = isRecord(watchArgsInput) ? watchArgsInput : {}
|
|
443
|
+
console.log("🚀 ~ functionsController ~ watchArgs:", watchArgs)
|
|
215
444
|
|
|
216
445
|
const headers = {
|
|
217
446
|
'Content-Type': 'text/event-stream',
|
|
@@ -222,21 +451,33 @@ export const functionsController: FunctionController = async (
|
|
|
222
451
|
"access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
|
|
223
452
|
};
|
|
224
453
|
|
|
225
|
-
res.raw.writeHead(200, headers)
|
|
226
|
-
res.raw.flushHeaders();
|
|
227
|
-
|
|
228
|
-
const streamKey = `${database}::${collection}`
|
|
229
454
|
const subscriberId = `${Date.now()}-${watchSubscriberCounter++}`
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
455
|
+
const {
|
|
456
|
+
streamKey,
|
|
457
|
+
extraFilter,
|
|
458
|
+
options: watchOptions,
|
|
459
|
+
pipeline: watchPipeline
|
|
460
|
+
} = resolveWatchStream(database, collection, watchArgs, user)
|
|
234
461
|
|
|
235
462
|
let hub = sharedWatchStreams.get(streamKey)
|
|
236
463
|
if (!hub) {
|
|
237
|
-
|
|
238
|
-
|
|
464
|
+
if (sharedWatchStreams.size >= maxSharedWatchStreams) {
|
|
465
|
+
res.status(503)
|
|
466
|
+
return JSON.stringify({
|
|
467
|
+
error: JSON.stringify({
|
|
468
|
+
message: 'Watch stream limit reached',
|
|
469
|
+
name: 'WatchStreamLimitError'
|
|
470
|
+
}),
|
|
471
|
+
error_code: 'WatchStreamLimitError'
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
const stream = services['mongodb-atlas'](app, {
|
|
475
|
+
user,
|
|
476
|
+
rules
|
|
239
477
|
})
|
|
478
|
+
.db(database)
|
|
479
|
+
.collection(collection)
|
|
480
|
+
.watch(watchPipeline, watchOptions)
|
|
240
481
|
hub = {
|
|
241
482
|
database,
|
|
242
483
|
collection,
|
|
@@ -244,8 +485,14 @@ export const functionsController: FunctionController = async (
|
|
|
244
485
|
subscribers: new Map<string, WatchSubscriber>()
|
|
245
486
|
}
|
|
246
487
|
sharedWatchStreams.set(streamKey, hub)
|
|
488
|
+
logWatchStats('hub-created', { streamKey, database, collection })
|
|
489
|
+
} else {
|
|
490
|
+
logWatchStats('hub-reused', { streamKey, database, collection })
|
|
247
491
|
}
|
|
248
492
|
|
|
493
|
+
res.raw.writeHead(200, headers)
|
|
494
|
+
res.raw.flushHeaders();
|
|
495
|
+
|
|
249
496
|
const ensureHubListeners = (currentHub: SharedWatchStream) => {
|
|
250
497
|
if ((currentHub as SharedWatchStream & { listenersBound?: boolean }).listenersBound) {
|
|
251
498
|
return
|
|
@@ -255,6 +502,7 @@ export const functionsController: FunctionController = async (
|
|
|
255
502
|
currentHub.stream.off('change', onHubChange)
|
|
256
503
|
currentHub.stream.off('error', onHubError)
|
|
257
504
|
sharedWatchStreams.delete(streamKey)
|
|
505
|
+
logWatchStats('hub-closed', { streamKey, database, collection })
|
|
258
506
|
try {
|
|
259
507
|
await currentHub.stream.close()
|
|
260
508
|
} catch {
|
|
@@ -263,11 +511,13 @@ export const functionsController: FunctionController = async (
|
|
|
263
511
|
}
|
|
264
512
|
|
|
265
513
|
const onHubChange = async (change: Document) => {
|
|
514
|
+
console.log("🚀 ~ onHubChange ~ change:", change)
|
|
266
515
|
const subscribers = Array.from(currentHub.subscribers.values())
|
|
267
516
|
await Promise.all(subscribers.map(async (subscriber) => {
|
|
268
517
|
const subscriberRes = subscriber.response
|
|
269
518
|
if (subscriberRes.writableEnded || subscriberRes.destroyed) {
|
|
270
519
|
currentHub.subscribers.delete(subscriber.id)
|
|
520
|
+
logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id })
|
|
271
521
|
return
|
|
272
522
|
}
|
|
273
523
|
|
|
@@ -276,8 +526,8 @@ export const functionsController: FunctionController = async (
|
|
|
276
526
|
(change as { fullDocument?: { _id?: unknown } })?.fullDocument?._id
|
|
277
527
|
if (typeof docId === 'undefined') return
|
|
278
528
|
|
|
279
|
-
const readQuery = subscriber.
|
|
280
|
-
? ({ $and: [subscriber.
|
|
529
|
+
const readQuery = subscriber.documentFilter
|
|
530
|
+
? ({ $and: [subscriber.documentFilter, { _id: docId }] } as Document)
|
|
281
531
|
: ({ _id: docId } as Document)
|
|
282
532
|
|
|
283
533
|
try {
|
|
@@ -288,6 +538,7 @@ export const functionsController: FunctionController = async (
|
|
|
288
538
|
.db(currentHub.database)
|
|
289
539
|
.collection(currentHub.collection)
|
|
290
540
|
.findOne(readQuery)
|
|
541
|
+
console.log("🚀 ~ onHubChange ~ readableDoc:", readableDoc)
|
|
291
542
|
|
|
292
543
|
if (!isReadableDocumentResult(readableDoc)) return
|
|
293
544
|
subscriberRes.write(`data: ${serializeEjson(change)}\n\n`)
|
|
@@ -295,6 +546,7 @@ export const functionsController: FunctionController = async (
|
|
|
295
546
|
subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`)
|
|
296
547
|
subscriberRes.end()
|
|
297
548
|
currentHub.subscribers.delete(subscriber.id)
|
|
549
|
+
logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id })
|
|
298
550
|
}
|
|
299
551
|
}))
|
|
300
552
|
|
|
@@ -326,17 +578,25 @@ export const functionsController: FunctionController = async (
|
|
|
326
578
|
id: subscriberId,
|
|
327
579
|
user,
|
|
328
580
|
response: res.raw,
|
|
329
|
-
|
|
581
|
+
documentFilter: (() => {
|
|
582
|
+
if (!extraFilter) return undefined
|
|
583
|
+
const mapped = mapWatchFilterToDocumentQuery(extraFilter)
|
|
584
|
+
if (!isRecord(mapped) || Object.keys(mapped).length === 0) return undefined
|
|
585
|
+
return mapped as Document
|
|
586
|
+
})()
|
|
330
587
|
}
|
|
331
588
|
hub.subscribers.set(subscriberId, subscriber)
|
|
589
|
+
logWatchStats('subscriber-added', { streamKey, subscriberId })
|
|
332
590
|
|
|
333
591
|
req.raw.on('close', () => {
|
|
334
592
|
const currentHub = sharedWatchStreams.get(streamKey)
|
|
335
593
|
if (!currentHub) return
|
|
336
594
|
currentHub.subscribers.delete(subscriberId)
|
|
595
|
+
logWatchStats('subscriber-closed', { streamKey, subscriberId })
|
|
337
596
|
if (!currentHub.subscribers.size) {
|
|
338
597
|
void currentHub.stream.close()
|
|
339
598
|
sharedWatchStreams.delete(streamKey)
|
|
599
|
+
logWatchStats('hub-empty-closed', { streamKey })
|
|
340
600
|
}
|
|
341
601
|
})
|
|
342
602
|
})
|
|
@@ -21,8 +21,8 @@ describe('activateTriggers', () => {
|
|
|
21
21
|
|
|
22
22
|
it('skips triggers marked as disabled', async () => {
|
|
23
23
|
const functionsList = {
|
|
24
|
-
runEnabled:
|
|
25
|
-
runDisabled:
|
|
24
|
+
runEnabled: { code: 'exports = async () => {}' },
|
|
25
|
+
runDisabled: { code: 'exports = async () => {}' }
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
await activateTriggers({
|
|
@@ -171,7 +171,7 @@ describe('mongodb-atlas findOneAndUpdate', () => {
|
|
|
171
171
|
|
|
172
172
|
const app = createAppWithCollection(collection)
|
|
173
173
|
const operators = MongoDbAtlas(app as any, {
|
|
174
|
-
rules: createRules({ insert: false }),
|
|
174
|
+
rules: createRules({ write: undefined, insert: false }),
|
|
175
175
|
user: { id: 'user-1' }
|
|
176
176
|
})
|
|
177
177
|
.db('db')
|
|
@@ -323,9 +323,10 @@ export function getHiddenFieldsFromRulesConfig(rulesConfig?: { roles?: Role[] })
|
|
|
323
323
|
function collectHiddenFieldsFromRoles(roles: Role[] = []) {
|
|
324
324
|
const hiddenFields = new Set<string>()
|
|
325
325
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
const isFieldPermissionObject = (value: unknown): value is { read?: unknown; write?: unknown } =>
|
|
327
|
+
!!value && typeof value === 'object' && ('read' in value || 'write' in value)
|
|
328
|
+
|
|
329
|
+
const collectFromFields = (fields?: Role['fields']) => {
|
|
329
330
|
if (!fields) return
|
|
330
331
|
Object.entries(fields).forEach(([fieldName, permissions]) => {
|
|
331
332
|
const canRead = Boolean(permissions?.read || permissions?.write)
|
|
@@ -335,9 +336,23 @@ function collectHiddenFieldsFromRoles(roles: Role[] = []) {
|
|
|
335
336
|
})
|
|
336
337
|
}
|
|
337
338
|
|
|
339
|
+
const collectFromAdditionalFields = (fields?: Role['additional_fields']) => {
|
|
340
|
+
if (!fields || typeof fields !== 'object') return
|
|
341
|
+
// Global additional_fields permissions (read/write) apply to unknown fields and cannot be mapped.
|
|
342
|
+
if (isFieldPermissionObject(fields)) return
|
|
343
|
+
|
|
344
|
+
Object.entries(fields).forEach(([fieldName, permissions]) => {
|
|
345
|
+
if (!isFieldPermissionObject(permissions)) return
|
|
346
|
+
const canRead = Boolean(permissions.read || permissions.write)
|
|
347
|
+
if (!canRead) {
|
|
348
|
+
hiddenFields.add(fieldName)
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
338
353
|
roles.forEach((role) => {
|
|
339
354
|
collectFromFields(role.fields)
|
|
340
|
-
|
|
355
|
+
collectFromAdditionalFields(role.additional_fields)
|
|
341
356
|
})
|
|
342
357
|
|
|
343
358
|
return Array.from(hiddenFields)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { evaluateTopLevelPermissionsFn } from '../roles/machines/commonValidators'
|
|
1
2
|
import { MachineContext } from '../roles/machines/interface'
|
|
2
3
|
import { STEP_A_STATES } from '../roles/machines/read/A'
|
|
3
4
|
import * as Utils from '../roles/machines/utils'
|
|
4
5
|
const { evaluateSearch, checkSearchRequest } = STEP_A_STATES
|
|
5
6
|
|
|
7
|
+
jest.mock('../roles/machines/commonValidators', () => ({
|
|
8
|
+
evaluateTopLevelPermissionsFn: jest.fn()
|
|
9
|
+
}))
|
|
10
|
+
|
|
6
11
|
const endValidation = jest.fn()
|
|
7
12
|
const goToNextValidationStage = jest.fn()
|
|
8
13
|
const next = jest.fn()
|
|
@@ -57,16 +62,33 @@ describe('STEP_A_STATES', () => {
|
|
|
57
62
|
})
|
|
58
63
|
expect(goToNextValidationStage).toHaveBeenCalled()
|
|
59
64
|
})
|
|
60
|
-
it('evaluateSearch should
|
|
65
|
+
it('evaluateSearch should go to next validation stage when search is allowed', async () => {
|
|
66
|
+
(evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(true)
|
|
67
|
+
const mockContext = {
|
|
68
|
+
role: {
|
|
69
|
+
name: 'test'
|
|
70
|
+
}
|
|
71
|
+
} as MachineContext
|
|
72
|
+
await evaluateSearch({
|
|
73
|
+
endValidation,
|
|
74
|
+
context: mockContext,
|
|
75
|
+
goToNextValidationStage,
|
|
76
|
+
next,
|
|
77
|
+
initialStep: null
|
|
78
|
+
})
|
|
79
|
+
expect(goToNextValidationStage).toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
it('evaluateSearch should end a failed validation when search is denied', async () => {
|
|
61
82
|
const mockedLogInfo = jest
|
|
62
83
|
.spyOn(Utils, 'logMachineInfo')
|
|
63
84
|
.mockImplementation(() => 'Mocked Value')
|
|
85
|
+
; (evaluateTopLevelPermissionsFn as jest.Mock).mockResolvedValueOnce(false)
|
|
64
86
|
const mockContext = {
|
|
65
87
|
role: {
|
|
66
88
|
name: 'test'
|
|
67
89
|
}
|
|
68
90
|
} as MachineContext
|
|
69
|
-
evaluateSearch({
|
|
91
|
+
await evaluateSearch({
|
|
70
92
|
endValidation,
|
|
71
93
|
context: mockContext,
|
|
72
94
|
goToNextValidationStage,
|
|
@@ -22,7 +22,7 @@ describe('STEP_C_STATES', () => {
|
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
jest.clearAllMocks()
|
|
24
24
|
})
|
|
25
|
-
it('evaluateTopLevelRead should
|
|
25
|
+
it('evaluateTopLevelRead should pass readCheck=false to evaluateTopLevelWrite', async () => {
|
|
26
26
|
const mockedLogInfo = jest
|
|
27
27
|
.spyOn(Utils, 'logMachineInfo')
|
|
28
28
|
.mockImplementation(() => 'Mocked Value')
|
|
@@ -35,7 +35,7 @@ describe('STEP_C_STATES', () => {
|
|
|
35
35
|
next,
|
|
36
36
|
initialStep: null
|
|
37
37
|
})
|
|
38
|
-
expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', {
|
|
38
|
+
expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: false })
|
|
39
39
|
expect(mockedLogInfo).toHaveBeenCalledWith({
|
|
40
40
|
enabled: mockContext.enableLog,
|
|
41
41
|
machine: 'C',
|
|
@@ -44,7 +44,7 @@ describe('STEP_C_STATES', () => {
|
|
|
44
44
|
})
|
|
45
45
|
mockedLogInfo.mockRestore()
|
|
46
46
|
})
|
|
47
|
-
it('evaluateTopLevelRead should
|
|
47
|
+
it('evaluateTopLevelRead should pass readCheck=undefined to evaluateTopLevelWrite', async () => {
|
|
48
48
|
(evaluateTopLevelReadFn as jest.Mock).mockReturnValueOnce(undefined)
|
|
49
49
|
const mockContext = {} as MachineContext
|
|
50
50
|
await evaluateTopLevelRead({
|
|
@@ -54,9 +54,9 @@ describe('STEP_C_STATES', () => {
|
|
|
54
54
|
next,
|
|
55
55
|
initialStep: null
|
|
56
56
|
})
|
|
57
|
-
expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', {
|
|
57
|
+
expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: undefined })
|
|
58
58
|
})
|
|
59
|
-
it('evaluateTopLevelRead should
|
|
59
|
+
it('evaluateTopLevelRead should pass readCheck=true to evaluateTopLevelWrite', async () => {
|
|
60
60
|
(evaluateTopLevelReadFn as jest.Mock).mockReturnValueOnce(true)
|
|
61
61
|
const mockContext = {} as MachineContext
|
|
62
62
|
await evaluateTopLevelRead({
|
|
@@ -66,13 +66,12 @@ describe('STEP_C_STATES', () => {
|
|
|
66
66
|
next,
|
|
67
67
|
initialStep: null
|
|
68
68
|
})
|
|
69
|
-
expect(
|
|
69
|
+
expect(next).toHaveBeenCalledWith('evaluateTopLevelWrite', { readCheck: true })
|
|
70
70
|
})
|
|
71
|
-
it('checkFieldsProperty should go to next validation stage
|
|
71
|
+
it('checkFieldsProperty should go to next validation stage with initialStep checkIsValidFieldName', async () => {
|
|
72
72
|
const mockedLogInfo = jest
|
|
73
73
|
.spyOn(Utils, 'logMachineInfo')
|
|
74
74
|
.mockImplementation(() => 'Mocked Value')
|
|
75
|
-
;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(true)
|
|
76
75
|
const mockContext = {} as MachineContext
|
|
77
76
|
await checkFieldsProperty({
|
|
78
77
|
endValidation,
|
|
@@ -90,24 +89,13 @@ describe('STEP_C_STATES', () => {
|
|
|
90
89
|
})
|
|
91
90
|
mockedLogInfo.mockRestore()
|
|
92
91
|
})
|
|
93
|
-
it('
|
|
94
|
-
(checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
|
|
95
|
-
const mockContext = {} as MachineContext
|
|
96
|
-
await checkFieldsProperty({
|
|
97
|
-
endValidation,
|
|
98
|
-
context: mockContext,
|
|
99
|
-
goToNextValidationStage,
|
|
100
|
-
next,
|
|
101
|
-
initialStep: null
|
|
102
|
-
})
|
|
103
|
-
expect(goToNextValidationStage).toHaveBeenCalledWith('checkAdditionalFields')
|
|
104
|
-
})
|
|
105
|
-
it('evaluateTopLevelWrite should end a success validation if evaluateTopLevelWriteFn returns true', async () => {
|
|
92
|
+
it('evaluateTopLevelWrite should end a success validation when read is true', async () => {
|
|
106
93
|
const mockedLogInfo = jest
|
|
107
94
|
.spyOn(Utils, 'logMachineInfo')
|
|
108
95
|
.mockImplementation(() => 'Mocked Value')
|
|
109
|
-
;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(
|
|
110
|
-
|
|
96
|
+
;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
|
|
97
|
+
;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
|
|
98
|
+
const mockContext = { prevParams: { readCheck: true } } as unknown as MachineContext
|
|
111
99
|
await evaluateTopLevelWrite({
|
|
112
100
|
endValidation,
|
|
113
101
|
context: mockContext,
|
|
@@ -124,11 +112,23 @@ describe('STEP_C_STATES', () => {
|
|
|
124
112
|
})
|
|
125
113
|
mockedLogInfo.mockRestore()
|
|
126
114
|
})
|
|
127
|
-
it('evaluateTopLevelWrite should end a
|
|
115
|
+
it('evaluateTopLevelWrite should end a success validation when read is false and write is true', async () => {
|
|
116
|
+
;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(true)
|
|
117
|
+
const mockContext = { prevParams: { readCheck: false } } as unknown as MachineContext
|
|
118
|
+
await evaluateTopLevelWrite({
|
|
119
|
+
endValidation,
|
|
120
|
+
context: mockContext,
|
|
121
|
+
goToNextValidationStage,
|
|
122
|
+
next,
|
|
123
|
+
initialStep: null
|
|
124
|
+
})
|
|
125
|
+
expect(endValidation).toHaveBeenCalledWith({ success: true })
|
|
126
|
+
})
|
|
127
|
+
it('evaluateTopLevelWrite should end a failed validation when read is false and write is not true', async () => {
|
|
128
128
|
(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
|
|
129
129
|
const mockContext = {
|
|
130
130
|
prevParams: {
|
|
131
|
-
|
|
131
|
+
readCheck: false
|
|
132
132
|
}
|
|
133
133
|
} as unknown as MachineContext
|
|
134
134
|
await evaluateTopLevelWrite({
|
|
@@ -140,11 +140,28 @@ describe('STEP_C_STATES', () => {
|
|
|
140
140
|
})
|
|
141
141
|
expect(endValidation).toHaveBeenCalledWith({ success: false })
|
|
142
142
|
})
|
|
143
|
-
it('evaluateTopLevelWrite should
|
|
143
|
+
it('evaluateTopLevelWrite should end a success validation when read is undefined and write is true', async () => {
|
|
144
|
+
;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(true)
|
|
145
|
+
const mockContext = {
|
|
146
|
+
prevParams: {
|
|
147
|
+
readCheck: undefined
|
|
148
|
+
}
|
|
149
|
+
} as unknown as MachineContext
|
|
150
|
+
await evaluateTopLevelWrite({
|
|
151
|
+
endValidation,
|
|
152
|
+
context: mockContext,
|
|
153
|
+
goToNextValidationStage,
|
|
154
|
+
next,
|
|
155
|
+
initialStep: null
|
|
156
|
+
})
|
|
157
|
+
expect(endValidation).toHaveBeenCalledWith({ success: true })
|
|
158
|
+
})
|
|
159
|
+
it('evaluateTopLevelWrite should go to checkFieldsProperty when read and write are undefined/false but fields exist', async () => {
|
|
144
160
|
(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(false)
|
|
161
|
+
;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(true)
|
|
145
162
|
const mockContext = {
|
|
146
163
|
prevParams: {
|
|
147
|
-
|
|
164
|
+
readCheck: undefined
|
|
148
165
|
}
|
|
149
166
|
} as unknown as MachineContext
|
|
150
167
|
await evaluateTopLevelWrite({
|
|
@@ -156,4 +173,21 @@ describe('STEP_C_STATES', () => {
|
|
|
156
173
|
})
|
|
157
174
|
expect(next).toHaveBeenCalledWith('checkFieldsProperty')
|
|
158
175
|
})
|
|
176
|
+
it('evaluateTopLevelWrite should end a failed validation when read and write are undefined/false and no field rules exist', async () => {
|
|
177
|
+
;(evaluateTopLevelWriteFn as jest.Mock).mockReturnValue(undefined)
|
|
178
|
+
;(checkFieldsPropertyExists as jest.Mock).mockReturnValue(false)
|
|
179
|
+
const mockContext = {
|
|
180
|
+
prevParams: {
|
|
181
|
+
readCheck: undefined
|
|
182
|
+
}
|
|
183
|
+
} as unknown as MachineContext
|
|
184
|
+
await evaluateTopLevelWrite({
|
|
185
|
+
endValidation,
|
|
186
|
+
context: mockContext,
|
|
187
|
+
goToNextValidationStage,
|
|
188
|
+
next,
|
|
189
|
+
initialStep: null
|
|
190
|
+
})
|
|
191
|
+
expect(endValidation).toHaveBeenCalledWith({ success: false })
|
|
192
|
+
})
|
|
159
193
|
})
|