@terreno/api 0.13.2 → 0.14.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.
Files changed (175) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +53 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.asyncHandler.test.d.ts +1 -0
  4. package/dist/api.asyncHandler.test.js +236 -0
  5. package/dist/api.d.ts +15 -4
  6. package/dist/api.errors.test.js +1 -0
  7. package/dist/api.hooks.test.js +1 -0
  8. package/dist/api.js +153 -104
  9. package/dist/api.query.test.js +1 -0
  10. package/dist/api.test.js +174 -0
  11. package/dist/auth.d.ts +10 -5
  12. package/dist/auth.js +163 -90
  13. package/dist/auth.test.js +159 -0
  14. package/dist/betterAuthApp.test.js +1 -0
  15. package/dist/betterAuthSetup.d.ts +5 -6
  16. package/dist/betterAuthSetup.js +17 -14
  17. package/dist/betterAuthSetup.test.js +1 -0
  18. package/dist/config.d.ts +48 -0
  19. package/dist/config.js +248 -0
  20. package/dist/config.test.d.ts +1 -0
  21. package/dist/config.test.js +328 -0
  22. package/dist/configuration.test.js +1 -0
  23. package/dist/configurationApp.d.ts +1 -1
  24. package/dist/configurationApp.js +17 -13
  25. package/dist/configurationPlugin.test.js +1 -0
  26. package/dist/consentApp.test.js +1 -0
  27. package/dist/envConfigurationPlugin.d.ts +2 -0
  28. package/dist/envConfigurationPlugin.js +173 -0
  29. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  30. package/dist/envConfigurationPlugin.test.js +322 -0
  31. package/dist/errors.d.ts +18 -7
  32. package/dist/errors.js +106 -10
  33. package/dist/errors.test.js +16 -1
  34. package/dist/example.js +16 -7
  35. package/dist/expressServer.d.ts +10 -9
  36. package/dist/expressServer.js +62 -53
  37. package/dist/expressServer.test.js +53 -2
  38. package/dist/githubAuth.d.ts +2 -1
  39. package/dist/githubAuth.js +41 -26
  40. package/dist/githubAuth.test.js +1 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +4 -0
  43. package/dist/logger.d.ts +1 -1
  44. package/dist/logger.js +42 -20
  45. package/dist/models/versionConfig.d.ts +2 -0
  46. package/dist/models/versionConfig.js +8 -0
  47. package/dist/notifiers/googleChatNotifier.js +14 -16
  48. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  49. package/dist/notifiers/slackNotifier.js +16 -14
  50. package/dist/notifiers/slackNotifier.test.js +41 -3
  51. package/dist/notifiers/zoomNotifier.js +7 -10
  52. package/dist/notifiers/zoomNotifier.test.js +1 -0
  53. package/dist/openApi.d.ts +1 -1
  54. package/dist/openApi.test.js +1 -0
  55. package/dist/openApiBuilder.d.ts +39 -6
  56. package/dist/openApiBuilder.js +1 -31
  57. package/dist/openApiBuilder.test.js +1 -0
  58. package/dist/openApiValidator.js +1 -0
  59. package/dist/openApiValidator.test.js +65 -0
  60. package/dist/permissions.d.ts +4 -4
  61. package/dist/permissions.js +67 -65
  62. package/dist/permissions.middleware.test.js +1 -0
  63. package/dist/permissions.test.js +1 -0
  64. package/dist/plugins.d.ts +5 -5
  65. package/dist/plugins.js +18 -9
  66. package/dist/plugins.test.js +1 -1
  67. package/dist/populate.d.ts +15 -8
  68. package/dist/populate.js +23 -24
  69. package/dist/populate.test.js +1 -0
  70. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  71. package/dist/realtime/changeStreamWatcher.js +720 -0
  72. package/dist/realtime/index.d.ts +6 -0
  73. package/dist/realtime/index.js +27 -0
  74. package/dist/realtime/queryMatcher.d.ts +14 -0
  75. package/dist/realtime/queryMatcher.js +250 -0
  76. package/dist/realtime/queryStore.d.ts +37 -0
  77. package/dist/realtime/queryStore.js +195 -0
  78. package/dist/realtime/realtime.test.d.ts +10 -0
  79. package/dist/realtime/realtime.test.js +2158 -0
  80. package/dist/realtime/realtimeApp.d.ts +93 -0
  81. package/dist/realtime/realtimeApp.js +560 -0
  82. package/dist/realtime/registry.d.ts +40 -0
  83. package/dist/realtime/registry.js +38 -0
  84. package/dist/realtime/socketUser.d.ts +10 -0
  85. package/dist/realtime/socketUser.js +17 -0
  86. package/dist/realtime/types.d.ts +100 -0
  87. package/dist/realtime/types.js +2 -0
  88. package/dist/requestContext.d.ts +37 -0
  89. package/dist/requestContext.js +344 -0
  90. package/dist/requestContext.test.d.ts +1 -0
  91. package/dist/requestContext.test.js +241 -0
  92. package/dist/terrenoApp.d.ts +8 -0
  93. package/dist/terrenoApp.js +50 -13
  94. package/dist/terrenoApp.test.js +194 -21
  95. package/dist/terrenoPlugin.d.ts +11 -0
  96. package/dist/tests/bunSetup.js +1 -0
  97. package/dist/tests.js +1 -1
  98. package/dist/transformers.d.ts +2 -2
  99. package/dist/transformers.js +5 -3
  100. package/dist/transformers.test.js +90 -0
  101. package/dist/types/consentResponse.d.ts +6 -3
  102. package/dist/versionCheckPlugin.d.ts +2 -0
  103. package/dist/versionCheckPlugin.js +18 -12
  104. package/package.json +4 -2
  105. package/src/__tests__/versionCheckPlugin.test.ts +37 -3
  106. package/src/api.arrayOperations.test.ts +1 -0
  107. package/src/api.asyncHandler.test.ts +177 -0
  108. package/src/api.errors.test.ts +1 -0
  109. package/src/api.hooks.test.ts +1 -0
  110. package/src/api.query.test.ts +1 -0
  111. package/src/api.test.ts +132 -0
  112. package/src/api.ts +199 -84
  113. package/src/auth.test.ts +160 -0
  114. package/src/auth.ts +120 -50
  115. package/src/betterAuthApp.test.ts +1 -0
  116. package/src/betterAuthSetup.test.ts +1 -0
  117. package/src/betterAuthSetup.ts +46 -19
  118. package/src/config.test.ts +255 -0
  119. package/src/config.ts +206 -0
  120. package/src/configuration.test.ts +1 -0
  121. package/src/configurationApp.ts +59 -24
  122. package/src/configurationPlugin.test.ts +1 -0
  123. package/src/consentApp.test.ts +1 -0
  124. package/src/envConfigurationPlugin.test.ts +143 -0
  125. package/src/envConfigurationPlugin.ts +100 -0
  126. package/src/errors.test.ts +19 -1
  127. package/src/errors.ts +94 -20
  128. package/src/example.ts +46 -21
  129. package/src/express.d.ts +18 -1
  130. package/src/expressServer.test.ts +50 -2
  131. package/src/expressServer.ts +80 -50
  132. package/src/githubAuth.test.ts +1 -0
  133. package/src/githubAuth.ts +59 -38
  134. package/src/index.ts +4 -0
  135. package/src/logger.ts +47 -17
  136. package/src/models/versionConfig.ts +13 -2
  137. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  138. package/src/notifiers/googleChatNotifier.ts +7 -9
  139. package/src/notifiers/slackNotifier.test.ts +29 -3
  140. package/src/notifiers/slackNotifier.ts +9 -7
  141. package/src/notifiers/zoomNotifier.test.ts +1 -0
  142. package/src/notifiers/zoomNotifier.ts +8 -11
  143. package/src/openApi.test.ts +1 -0
  144. package/src/openApi.ts +4 -4
  145. package/src/openApiBuilder.test.ts +1 -0
  146. package/src/openApiBuilder.ts +14 -11
  147. package/src/openApiValidator.test.ts +59 -0
  148. package/src/openApiValidator.ts +3 -2
  149. package/src/permissions.middleware.test.ts +1 -0
  150. package/src/permissions.test.ts +1 -0
  151. package/src/permissions.ts +30 -25
  152. package/src/plugins.test.ts +1 -1
  153. package/src/plugins.ts +21 -14
  154. package/src/populate.test.ts +1 -0
  155. package/src/populate.ts +44 -36
  156. package/src/realtime/changeStreamWatcher.ts +568 -0
  157. package/src/realtime/index.ts +34 -0
  158. package/src/realtime/queryMatcher.ts +179 -0
  159. package/src/realtime/queryStore.ts +132 -0
  160. package/src/realtime/realtime.test.ts +1755 -0
  161. package/src/realtime/realtimeApp.ts +478 -0
  162. package/src/realtime/registry.ts +64 -0
  163. package/src/realtime/socketUser.ts +25 -0
  164. package/src/realtime/types.ts +112 -0
  165. package/src/requestContext.test.ts +196 -0
  166. package/src/requestContext.ts +368 -0
  167. package/src/terrenoApp.test.ts +137 -11
  168. package/src/terrenoApp.ts +64 -17
  169. package/src/terrenoPlugin.ts +12 -0
  170. package/src/tests/bunSetup.ts +1 -0
  171. package/src/tests.ts +7 -2
  172. package/src/transformers.test.ts +70 -2
  173. package/src/transformers.ts +15 -7
  174. package/src/types/consentResponse.ts +8 -10
  175. package/src/versionCheckPlugin.ts +15 -7
package/src/api.ts CHANGED
@@ -6,10 +6,18 @@
6
6
  import * as Sentry from "@sentry/bun";
7
7
  import express, {type NextFunction, type Request, type Response} from "express";
8
8
  import cloneDeep from "lodash/cloneDeep";
9
+ import {DateTime} from "luxon";
9
10
  import mongoose, {type Document, type Model} from "mongoose";
10
11
 
11
12
  import {authenticateMiddleware, type User} from "./auth";
12
- import {APIError, apiErrorMiddleware, getDisableExternalErrorTracking, isAPIError} from "./errors";
13
+ import {
14
+ APIError,
15
+ apiErrorMiddleware,
16
+ errorMessage,
17
+ errorStack,
18
+ getDisableExternalErrorTracking,
19
+ isAPIError,
20
+ } from "./errors";
13
21
  import {logger} from "./logger";
14
22
  import {
15
23
  createOpenApiMiddleware,
@@ -26,6 +34,8 @@ import {
26
34
  } from "./openApiValidator";
27
35
  import {checkPermissions, permissionMiddleware, type RESTPermissions} from "./permissions";
28
36
  import type {PopulatePath} from "./populate";
37
+ import {registerRealtime} from "./realtime/registry";
38
+ import type {RealtimeConfig} from "./realtime/types";
29
39
  import {
30
40
  defaultResponseHandler,
31
41
  serialize,
@@ -42,6 +52,7 @@ export interface JSONObject {
42
52
  export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
43
53
 
44
54
  export const addPopulateToQuery = (
55
+ // biome-ignore lint/suspicious/noExplicitAny: mongoose Query type parameters vary widely across populated/unpopulated documents — caller passes concrete types
45
56
  builtQuery: mongoose.Query<any[], any, Record<string, never>, any>,
46
57
  populatePaths?: PopulatePath[]
47
58
  ) => {
@@ -262,16 +273,16 @@ export interface ModelRouterOptions<T> {
262
273
  * @deprecated: Use responseHandler instead.
263
274
  */
264
275
  postList?: (
265
- value: (Document<any, any, any> & T)[],
276
+ value: (Document<unknown, unknown, unknown> & T)[],
266
277
  request: express.Request
267
- ) => Promise<(Document<any, any, any> & T)[]>;
278
+ ) => Promise<(Document<unknown, unknown, unknown> & T)[]>;
268
279
  /**
269
280
  * Serialize an object or list of objects before returning to the client.
270
281
  * This is a good spot to remove sensitive information from the object, such as passwords or API
271
282
  * keys. Throw an APIError to return a 400 with an error message.
272
283
  */
273
284
  responseHandler?: (
274
- value: (Document<any, any, any> & T) | (Document<any, any, any> & T)[],
285
+ value: (Document<unknown, unknown, unknown> & T) | (Document<unknown, unknown, unknown> & T)[],
275
286
  method: "list" | "create" | "read" | "update" | "delete",
276
287
  request: express.Request,
277
288
  options: ModelRouterOptions<T>
@@ -311,19 +322,29 @@ export interface ModelRouterOptions<T> {
311
322
  * This option overrides the global setting for this specific router.
312
323
  */
313
324
  validation?: boolean | ModelRouterValidationOptions;
325
+ /**
326
+ * Enable real-time sync for this model via WebSocket events.
327
+ * When configured, CRUD operations will emit events to connected clients
328
+ * through the RealtimeApp plugin's change stream watcher.
329
+ *
330
+ * Requires the RealtimeApp plugin to be registered with TerrenoApp.
331
+ */
332
+ realtime?: RealtimeConfig;
314
333
  }
315
334
 
316
335
  // Ensures query params are allowed. Also checks nested query params when using $and/$or.
317
336
  const checkQueryParamAllowed = (
318
337
  queryParam: string,
319
- queryParamValue: any,
338
+ queryParamValue: unknown,
320
339
  queryFields: string[] = []
321
340
  ) => {
341
+ // Cast for iteration through complex query values
342
+ const complexValue = queryParamValue as Array<Record<string, unknown>>;
322
343
  // Check the values of each of the complex query params. We don't support recursive queries here,
323
344
  // just one level of and/or
324
345
  if (COMPLEX_QUERY_PARAMS.includes(queryParam)) {
325
346
  // Complex query of the form `$and: [{key1: value1}, {key2: value2}]`
326
- for (const subQuery of queryParamValue) {
347
+ for (const subQuery of complexValue) {
327
348
  for (const subKey of Object.keys(subQuery)) {
328
349
  checkQueryParamAllowed(subKey, subQuery[subKey], queryFields);
329
350
  }
@@ -360,8 +381,8 @@ const checkQueryParamAllowed = (
360
381
  // Helper to determine if validation should be enabled for a specific operation.
361
382
  // When options.validation is not set, returns true — the middleware's own
362
383
  // isConfigured check will decide whether to actually validate.
363
- const shouldValidate = (
364
- options: ModelRouterOptions<any>,
384
+ const shouldValidate = <T>(
385
+ options: ModelRouterOptions<T>,
365
386
  operation: "create" | "update" | "query"
366
387
  ): boolean => {
367
388
  // Check route-specific validation option first
@@ -446,7 +467,7 @@ export interface ModelRouterRegistration {
446
467
  /** The Express router containing CRUD endpoints */
447
468
  router: express.Router;
448
469
  /** @internal Rebuilds the router with the openApi instance injected into options */
449
- _buildWithOpenApi: (openApi: any) => express.Router;
470
+ _buildWithOpenApi: (openApi: OpenApiMiddleware) => express.Router;
450
471
  }
451
472
 
452
473
  /**
@@ -490,13 +511,32 @@ export function modelRouter<T>(
490
511
  const router = _buildModelRouter(model, options);
491
512
 
492
513
  if (path !== undefined) {
514
+ // Register for real-time sync if configured
515
+ if (options.realtime) {
516
+ registerRealtime({
517
+ collectionName: model.collection.collectionName,
518
+ config: options.realtime,
519
+ modelName: model.modelName,
520
+ options,
521
+ routePath: path,
522
+ });
523
+ }
493
524
  return {
494
525
  __type: "modelRouter",
495
- _buildWithOpenApi: (openApi: any) => _buildModelRouter(model, {...options, openApi}),
526
+ _buildWithOpenApi: (openApi: OpenApiMiddleware) =>
527
+ _buildModelRouter(model, {...options, openApi}),
496
528
  path,
497
529
  router,
498
530
  };
499
531
  }
532
+
533
+ if (options.realtime) {
534
+ logger.warn(
535
+ `modelRouter for ${model.modelName} has realtime config but was called without a path. ` +
536
+ "Realtime sync only works with the three-argument form: modelRouter('/path', Model, options)"
537
+ );
538
+ }
539
+
500
540
  return router;
501
541
  }
502
542
 
@@ -527,7 +567,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
527
567
  let body: Partial<T> | (Partial<T> | undefined)[] | null | undefined;
528
568
  try {
529
569
  body = transform<T>(options, req.body, "create", req.user);
530
- } catch (error: any) {
570
+ } catch (error: unknown) {
531
571
  if (isAPIError(error)) {
532
572
  throw error;
533
573
  }
@@ -535,13 +575,13 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
535
575
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
536
576
  error,
537
577
  status: 400,
538
- title: error.message,
578
+ title: errorMessage(error),
539
579
  });
540
580
  }
541
581
  if (options.preCreate) {
542
582
  try {
543
583
  body = await options.preCreate(body, req);
544
- } catch (error: any) {
584
+ } catch (error: unknown) {
545
585
  if (isAPIError(error)) {
546
586
  throw error;
547
587
  }
@@ -549,7 +589,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
549
589
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
550
590
  error,
551
591
  status: 400,
552
- title: `preCreate hook error: ${error.message}`,
592
+ title: `preCreate hook error: ${errorMessage(error)}`,
553
593
  });
554
594
  }
555
595
  if (body === undefined) {
@@ -574,29 +614,30 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
574
614
  title: "Invalid request body",
575
615
  });
576
616
  }
577
- let data;
617
+ let data: Document<unknown, unknown, unknown> & T;
578
618
  try {
579
- data = await model.create(body as any);
580
- } catch (error: any) {
619
+ data = (await model.create(body as T)) as Document<unknown, unknown, unknown> & T;
620
+ } catch (error: unknown) {
581
621
  throw new APIError({
582
622
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
583
623
  error,
584
624
  status: 400,
585
- title: error.message,
625
+ title: errorMessage(error),
586
626
  });
587
627
  }
588
628
 
589
629
  if (options.populatePaths) {
590
630
  try {
631
+ // biome-ignore lint/suspicious/noExplicitAny: mongoose Query type varies based on populatePaths
591
632
  let populateQuery: any = model.findById(data._id);
592
633
  populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
593
634
  data = await populateQuery.exec();
594
- } catch (error: any) {
635
+ } catch (error: unknown) {
595
636
  throw new APIError({
596
637
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
597
638
  error,
598
639
  status: 400,
599
- title: `Populate error: ${error.message}`,
640
+ title: `Populate error: ${errorMessage(error)}`,
600
641
  });
601
642
  }
602
643
  }
@@ -604,23 +645,23 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
604
645
  if (options.postCreate) {
605
646
  try {
606
647
  await options.postCreate(data, req);
607
- } catch (error: any) {
648
+ } catch (error: unknown) {
608
649
  throw new APIError({
609
650
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
610
651
  error,
611
652
  status: 400,
612
- title: `postCreate hook error: ${error.message}`,
653
+ title: `postCreate hook error: ${errorMessage(error)}`,
613
654
  });
614
655
  }
615
656
  }
616
657
  try {
617
658
  const serialized = await responseHandler(data, "create", req, options);
618
659
  return res.status(201).json({data: serialized});
619
- } catch (error: any) {
660
+ } catch (error: unknown) {
620
661
  throw new APIError({
621
662
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
622
663
  error,
623
- title: `responseHandler error: ${error.message}`,
664
+ title: `responseHandler error: ${errorMessage(error)}`,
624
665
  });
625
666
  }
626
667
  })
@@ -636,7 +677,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
636
677
  queryValidation,
637
678
  ],
638
679
  asyncHandler(async (req: Request, res: Response) => {
639
- let query: any = {};
680
+ let query: Record<string, unknown> = {};
640
681
  for (const queryParam of Object.keys(options.defaultQueryParams ?? [])) {
641
682
  query[queryParam] = options.defaultQueryParams?.[queryParam];
642
683
  }
@@ -668,10 +709,10 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
668
709
 
669
710
  // Check if any of the keys in the query are not allowed by options.queryFilter
670
711
  if (options.queryFilter) {
671
- let queryFilter;
712
+ let queryFilter: Record<string, unknown> | null | undefined;
672
713
  try {
673
714
  queryFilter = await options.queryFilter(req.user, query);
674
- } catch (error: any) {
715
+ } catch (error: unknown) {
675
716
  throw new APIError({
676
717
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
677
718
  error,
@@ -720,30 +761,30 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
720
761
 
721
762
  const populatedQuery = addPopulateToQuery(builtQuery, options.populatePaths);
722
763
 
723
- let data: (Document<any, any, any> & T)[];
764
+ let data: (Document<unknown, unknown, unknown> & T)[];
724
765
  try {
725
- data = await populatedQuery.exec();
726
- } catch (error: any) {
766
+ data = (await populatedQuery.exec()) as (Document<unknown, unknown, unknown> & T)[];
767
+ } catch (error: unknown) {
727
768
  throw new APIError({
728
769
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
729
770
  error,
730
- title: `List error: ${error.stack}`,
771
+ title: `List error: ${errorStack(error)}`,
731
772
  });
732
773
  }
733
774
 
734
- let serialized;
775
+ let serialized: JSONValue | Partial<T> | (Partial<T> | undefined)[] | undefined;
735
776
 
736
777
  try {
737
778
  serialized = await responseHandler(data, "list", req, options);
738
- } catch (error: any) {
779
+ } catch (error: unknown) {
739
780
  throw new APIError({
740
781
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
741
782
  error,
742
- title: `responseHandler error: ${error.message}`,
783
+ title: `responseHandler error: ${errorMessage(error)}`,
743
784
  });
744
785
  }
745
786
 
746
- let more;
787
+ let more: boolean | undefined;
747
788
  try {
748
789
  if (serialized && Array.isArray(serialized)) {
749
790
  more = serialized.length === limit + 1 && serialized.length > 0;
@@ -770,11 +811,11 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
770
811
  });
771
812
  }
772
813
  return res.json({data: serialized});
773
- } catch (error: any) {
814
+ } catch (error: unknown) {
774
815
  throw new APIError({
775
816
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
776
817
  error,
777
- title: `Serialization error: ${error.message}`,
818
+ title: `Serialization error: ${errorMessage(error)}`,
778
819
  });
779
820
  }
780
821
  })
@@ -788,16 +829,16 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
788
829
  permissionMiddleware(model, options),
789
830
  ],
790
831
  asyncHandler(async (req: Request, res: Response) => {
791
- const data: mongoose.Document & T = (req as any).obj;
832
+ const data: mongoose.Document & T = (req as Request & {obj: mongoose.Document & T}).obj;
792
833
 
793
834
  try {
794
835
  const serialized = await responseHandler(data, "read", req, options);
795
836
  return res.json({data: serialized});
796
- } catch (error: any) {
837
+ } catch (error: unknown) {
797
838
  throw new APIError({
798
839
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
799
840
  error,
800
- title: `responseHandler error: ${error.message}`,
841
+ title: `responseHandler error: ${errorMessage(error)}`,
801
842
  });
802
843
  }
803
844
  })
@@ -823,13 +864,13 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
823
864
  updateValidation,
824
865
  ],
825
866
  asyncHandler(async (req: Request, res: Response) => {
826
- let doc: mongoose.Document & T = (req as any).obj;
867
+ let doc: mongoose.Document & T = (req as Request & {obj: mongoose.Document & T}).obj;
827
868
 
828
- let body;
869
+ let body: Partial<T> | T | null | undefined;
829
870
 
830
871
  try {
831
- body = transform<T>(options, req.body, "update", req.user);
832
- } catch (error: any) {
872
+ body = transform<T>(options, req.body, "update", req.user) as Partial<T>;
873
+ } catch (error: unknown) {
833
874
  if (isAPIError(error)) {
834
875
  throw error;
835
876
  }
@@ -837,18 +878,21 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
837
878
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
838
879
  error,
839
880
  status: 403,
840
- title: `PATCH failed on ${req.params.id} for user ${req.user?.id}: ${error.message}`,
881
+ title: `PATCH failed on ${req.params.id} for user ${req.user?.id}: ${errorMessage(error)}`,
841
882
  });
842
883
  }
843
884
 
885
+ // Remove _updatedAt from body before preUpdate processes it
886
+ const bodyUpdatedAt = req.body._updatedAt;
887
+ delete req.body._updatedAt;
888
+ if (body && typeof body === "object") {
889
+ delete (body as Record<string, unknown>)._updatedAt;
890
+ }
891
+
844
892
  if (options.preUpdate) {
845
893
  try {
846
- // TODO: Send flattened dot notation body to preUpdate, then merge the returned body
847
- // with the original body, maintaining the dot notation. This way we don't have to write
848
- // two preUpdate branches downstream, one looking at the dot notation style and
849
- // one looking at normal object style.
850
894
  body = await options.preUpdate(body, req);
851
- } catch (error: any) {
895
+ } catch (error: unknown) {
852
896
  if (isAPIError(error)) {
853
897
  throw error;
854
898
  }
@@ -856,7 +900,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
856
900
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
857
901
  error,
858
902
  status: 400,
859
- title: `preUpdate hook error on ${req.params.id}: ${error.message}`,
903
+ title: `preUpdate hook error on ${req.params.id}: ${errorMessage(error)}`,
860
904
  });
861
905
  }
862
906
  if (body === undefined) {
@@ -875,6 +919,64 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
875
919
  }
876
920
  }
877
921
 
922
+ // Conflict detection runs after preUpdate so that unauthorized mutations
923
+ // are rejected before we leak document data in a 409 response.
924
+ const preciseUnmodifiedSince = req.headers["x-unmodified-since-iso"];
925
+ const httpUnmodifiedSince = req.headers["if-unmodified-since"];
926
+ const timestampValue = Array.isArray(preciseUnmodifiedSince)
927
+ ? preciseUnmodifiedSince[0]
928
+ : preciseUnmodifiedSince;
929
+ const httpTimestampValue = Array.isArray(httpUnmodifiedSince)
930
+ ? httpUnmodifiedSince[0]
931
+ : httpUnmodifiedSince;
932
+ if (timestampValue || httpTimestampValue || bodyUpdatedAt) {
933
+ const usingPreciseHeader = Boolean(timestampValue);
934
+ const usingHttpHeader = !usingPreciseHeader && Boolean(httpTimestampValue);
935
+ const clientTimestamp = timestampValue
936
+ ? DateTime.fromISO(timestampValue)
937
+ : httpTimestampValue
938
+ ? DateTime.fromHTTP(httpTimestampValue)
939
+ : DateTime.fromISO(bodyUpdatedAt);
940
+
941
+ if (!clientTimestamp.isValid) {
942
+ throw new APIError({
943
+ detail: usingPreciseHeader
944
+ ? "X-Unmodified-Since-ISO header could not be parsed as an ISO date"
945
+ : usingHttpHeader
946
+ ? "If-Unmodified-Since header could not be parsed as an HTTP date"
947
+ : "_updatedAt body field could not be parsed as an ISO date",
948
+ status: 400,
949
+ title: "Invalid conflict-detection timestamp",
950
+ });
951
+ }
952
+
953
+ const docRecord = doc as {created?: Date | string; updated?: Date | string};
954
+ let serverTimestamp: DateTime | null = null;
955
+ const serverTimestampValue = docRecord.updated ?? docRecord.created;
956
+ if (serverTimestampValue instanceof Date) {
957
+ serverTimestamp = DateTime.fromJSDate(serverTimestampValue);
958
+ } else if (typeof serverTimestampValue === "string") {
959
+ serverTimestamp = DateTime.fromISO(serverTimestampValue);
960
+ }
961
+
962
+ if (serverTimestamp && !serverTimestamp.isValid) {
963
+ throw new APIError({
964
+ detail: "Document timestamp could not be parsed as a date",
965
+ status: 400,
966
+ title: "Invalid server timestamp",
967
+ });
968
+ }
969
+
970
+ if (serverTimestamp && clientTimestamp < serverTimestamp) {
971
+ const serialized = await responseHandler(doc, "update", req, options);
972
+ return res.status(409).json({
973
+ data: serialized,
974
+ error: "Conflict",
975
+ message: "Document was modified since your last read",
976
+ });
977
+ }
978
+ }
979
+
878
980
  // Make a copy for passing pre-saved values to hooks.
879
981
  const prevDoc = cloneDeep(doc);
880
982
 
@@ -883,16 +985,17 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
883
985
  try {
884
986
  doc.set(body);
885
987
  await doc.save();
886
- } catch (error: any) {
988
+ } catch (error: unknown) {
887
989
  throw new APIError({
888
990
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
889
991
  error,
890
992
  status: 400,
891
- title: `preUpdate hook save error on ${req.params.id}: ${error.message}`,
993
+ title: `preUpdate hook save error on ${req.params.id}: ${errorMessage(error)}`,
892
994
  });
893
995
  }
894
996
 
895
997
  if (options.populatePaths) {
998
+ // biome-ignore lint/suspicious/noExplicitAny: mongoose Query type varies based on populatePaths
896
999
  let populateQuery: any = model.findById(doc._id);
897
1000
  populateQuery = addPopulateToQuery(populateQuery, options.populatePaths);
898
1001
  doc = await populateQuery.exec();
@@ -901,12 +1004,12 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
901
1004
  if (options.postUpdate) {
902
1005
  try {
903
1006
  await options.postUpdate(doc, body, req, prevDoc);
904
- } catch (error: any) {
1007
+ } catch (error: unknown) {
905
1008
  throw new APIError({
906
1009
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
907
1010
  error,
908
1011
  status: 400,
909
- title: `postUpdate hook error on ${req.params.id}: ${error.message}`,
1012
+ title: `postUpdate hook error on ${req.params.id}: ${errorMessage(error)}`,
910
1013
  });
911
1014
  }
912
1015
  }
@@ -914,11 +1017,11 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
914
1017
  try {
915
1018
  const serialized = await responseHandler(doc, "update", req, options);
916
1019
  return res.json({data: serialized});
917
- } catch (error: any) {
1020
+ } catch (error: unknown) {
918
1021
  throw new APIError({
919
1022
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
920
1023
  error,
921
- title: `responseHandler error: ${error.message}`,
1024
+ title: `responseHandler error: ${errorMessage(error)}`,
922
1025
  });
923
1026
  }
924
1027
  })
@@ -932,13 +1035,15 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
932
1035
  permissionMiddleware(model, options),
933
1036
  ],
934
1037
  asyncHandler(async (req: Request, res: Response) => {
935
- const doc: mongoose.Document & T & {deleted?: boolean} = (req as any).obj;
1038
+ const doc: mongoose.Document & T & {deleted?: boolean} = (
1039
+ req as Request & {obj: mongoose.Document & T & {deleted?: boolean}}
1040
+ ).obj;
936
1041
 
937
1042
  if (options.preDelete) {
938
- let body;
1043
+ let body: T | null | undefined;
939
1044
  try {
940
1045
  body = await options.preDelete(doc, req);
941
- } catch (error: any) {
1046
+ } catch (error: unknown) {
942
1047
  if (isAPIError(error)) {
943
1048
  throw error;
944
1049
  }
@@ -946,7 +1051,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
946
1051
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
947
1052
  error,
948
1053
  status: 403,
949
- title: `preDelete hook error on ${req.params.id}: ${error.message}`,
1054
+ title: `preDelete hook error on ${req.params.id}: ${errorMessage(error)}`,
950
1055
  });
951
1056
  }
952
1057
  if (body === undefined) {
@@ -976,12 +1081,12 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
976
1081
  // For models without the isDeleted plugin
977
1082
  try {
978
1083
  await doc.deleteOne();
979
- } catch (error: any) {
1084
+ } catch (error: unknown) {
980
1085
  throw new APIError({
981
1086
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
982
1087
  error,
983
1088
  status: 400,
984
- title: error.message,
1089
+ title: errorMessage(error),
985
1090
  });
986
1091
  }
987
1092
  }
@@ -989,12 +1094,12 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
989
1094
  if (options.postDelete) {
990
1095
  try {
991
1096
  await options.postDelete(req, doc);
992
- } catch (error: any) {
1097
+ } catch (error: unknown) {
993
1098
  throw new APIError({
994
1099
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
995
1100
  error,
996
1101
  status: 400,
997
- title: `postDelete hook error: ${error.message}`,
1102
+ title: `postDelete hook error: ${errorMessage(error)}`,
998
1103
  });
999
1104
  }
1000
1105
  }
@@ -1048,16 +1153,16 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1048
1153
  });
1049
1154
  }
1050
1155
 
1051
- const array = [...doc[field]];
1156
+ const array = [...(doc as unknown as Record<string, unknown[]>)[field]];
1052
1157
  if (operation === "POST") {
1053
1158
  array.push(req.body[field]);
1054
1159
  } else if (operation === "PATCH" || operation === "DELETE") {
1055
1160
  // Check for subschema vs String array:
1056
- let index;
1161
+ let index: number;
1057
1162
  if (isValidObjectId(itemId)) {
1058
- index = array.findIndex((x: any) => x.id === itemId);
1163
+ index = array.findIndex((x) => (x as {id?: string})?.id === itemId);
1059
1164
  } else {
1060
- index = array.findIndex((x: string) => x === itemId);
1165
+ index = array.indexOf(itemId);
1061
1166
  }
1062
1167
  if (index === -1) {
1063
1168
  throw new APIError({
@@ -1068,7 +1173,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1068
1173
  // For PATCHing an item by ID, we need to merge the objects so we don't override the _id or
1069
1174
  // other parts of the subdocument.
1070
1175
  if (operation === "PATCH" && isValidObjectId(itemId)) {
1071
- Object.assign(array[index], req.body[field]);
1176
+ Object.assign(array[index] as object, req.body[field]);
1072
1177
  } else if (operation === "PATCH") {
1073
1178
  // For PATCHing a string array, we can replace the whole object.
1074
1179
  array[index] = req.body[field];
@@ -1085,7 +1190,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1085
1190
 
1086
1191
  try {
1087
1192
  body = transform<T>(options, body, "update", req.user) as Partial<T>;
1088
- } catch (error: any) {
1193
+ } catch (error: unknown) {
1089
1194
  if (isAPIError(error)) {
1090
1195
  throw error;
1091
1196
  }
@@ -1093,19 +1198,19 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1093
1198
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
1094
1199
  error,
1095
1200
  status: 403,
1096
- title: error.message,
1201
+ title: errorMessage(error),
1097
1202
  });
1098
1203
  }
1099
1204
 
1100
1205
  if (options.preUpdate) {
1101
1206
  try {
1102
1207
  body = await options.preUpdate(body, req);
1103
- } catch (error: any) {
1208
+ } catch (error: unknown) {
1104
1209
  throw new APIError({
1105
1210
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
1106
1211
  error,
1107
1212
  status: 400,
1108
- title: `preUpdate hook error on ${req.params.id}: ${error.message}`,
1213
+ title: `preUpdate hook error on ${req.params.id}: ${errorMessage(error)}`,
1109
1214
  });
1110
1215
  }
1111
1216
  if (body === undefined) {
@@ -1129,28 +1234,35 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1129
1234
  try {
1130
1235
  Object.assign(doc, body);
1131
1236
  await doc.save();
1132
- } catch (error: any) {
1237
+ } catch (error: unknown) {
1133
1238
  throw new APIError({
1134
1239
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
1135
1240
  error,
1136
1241
  status: 400,
1137
- title: `PATCH Pre Update error on ${req.params.id}: ${error.message}`,
1242
+ title: `PATCH Pre Update error on ${req.params.id}: ${errorMessage(error)}`,
1138
1243
  });
1139
1244
  }
1140
1245
 
1141
1246
  if (options.postUpdate) {
1142
1247
  try {
1143
- await options.postUpdate(doc as any, body, req, prevDoc as any);
1144
- } catch (error: any) {
1248
+ await options.postUpdate(
1249
+ doc as unknown as Document<unknown, unknown, unknown> & T,
1250
+ body,
1251
+ req,
1252
+ prevDoc as unknown as T
1253
+ );
1254
+ } catch (error: unknown) {
1145
1255
  throw new APIError({
1146
1256
  disableExternalErrorTracking: getDisableExternalErrorTracking(error),
1147
1257
  error,
1148
1258
  status: 400,
1149
- title: `PATCH Post Update error on ${req.params.id}: ${error.message}`,
1259
+ title: `PATCH Post Update error on ${req.params.id}: ${errorMessage(error)}`,
1150
1260
  });
1151
1261
  }
1152
1262
  }
1153
- return res.json({data: serialize<T>(req, options, doc as any)});
1263
+ return res.json({
1264
+ data: serialize<T>(req, options, doc as unknown as Document<unknown, unknown, unknown> & T),
1265
+ });
1154
1266
  }
1155
1267
 
1156
1268
  async function arrayPost(req: Request, res: Response) {
@@ -1165,7 +1277,7 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
1165
1277
  return arrayOperation(req, res, "DELETE");
1166
1278
  }
1167
1279
  // Set up routes for managing array fields. Check if there any array fields to add this for.
1168
- if (Object.values(model.schema.paths).find((config: any) => config.instance === "Array")) {
1280
+ if (Object.values(model.schema.paths).find((config) => config.instance === "Array")) {
1169
1281
  router.post(
1170
1282
  "/:id/:field",
1171
1283
  authenticateMiddleware(options.allowAnonymous),
@@ -1243,7 +1355,10 @@ export interface AsyncHandlerOptions {
1243
1355
  * }));
1244
1356
  * ```
1245
1357
  */
1246
- export const asyncHandler = (fn: any, options?: AsyncHandlerOptions) => {
1358
+ // biome-ignore lint/suspicious/noExplicitAny: handlers may have narrower Request<Params> generics — Express's overload signature uses any for the same reason
1359
+ type AsyncHandlerFn = (req: any, res: Response, next: NextFunction) => Promise<unknown> | unknown;
1360
+
1361
+ export const asyncHandler = (fn: AsyncHandlerFn, options?: AsyncHandlerOptions) => {
1247
1362
  // If no validation options, return simple handler
1248
1363
  if (!options?.bodySchema && !options?.querySchema) {
1249
1364
  return (req: Request, res: Response, next: NextFunction) => {
@@ -1283,13 +1398,13 @@ export const asyncHandler = (fn: any, options?: AsyncHandlerOptions) => {
1283
1398
  }
1284
1399
 
1285
1400
  try {
1286
- validators[index](req, res, (err?: any) => {
1401
+ validators[index](req, res, ((err?: unknown) => {
1287
1402
  if (err) {
1288
1403
  next(err);
1289
1404
  return;
1290
1405
  }
1291
1406
  runValidators(index + 1);
1292
- });
1407
+ }) as NextFunction);
1293
1408
  } catch (err) {
1294
1409
  next(err);
1295
1410
  }