@terreno/api 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +136 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.d.ts +15 -4
  4. package/dist/api.errors.test.js +1 -0
  5. package/dist/api.hooks.test.js +1 -0
  6. package/dist/api.js +153 -104
  7. package/dist/api.query.test.js +1 -0
  8. package/dist/api.test.js +174 -0
  9. package/dist/auth.d.ts +10 -5
  10. package/dist/auth.js +163 -90
  11. package/dist/auth.test.js +159 -0
  12. package/dist/betterAuthApp.test.js +1 -0
  13. package/dist/betterAuthSetup.d.ts +5 -6
  14. package/dist/betterAuthSetup.js +30 -17
  15. package/dist/betterAuthSetup.test.js +1 -0
  16. package/dist/config.d.ts +48 -0
  17. package/dist/config.js +257 -0
  18. package/dist/config.test.d.ts +1 -0
  19. package/dist/config.test.js +328 -0
  20. package/dist/configuration.test.js +1 -0
  21. package/dist/configurationApp.d.ts +1 -1
  22. package/dist/configurationApp.js +17 -13
  23. package/dist/configurationPlugin.test.js +1 -0
  24. package/dist/consentApp.test.js +1 -0
  25. package/dist/envConfigurationPlugin.d.ts +2 -0
  26. package/dist/envConfigurationPlugin.js +173 -0
  27. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  28. package/dist/envConfigurationPlugin.test.js +322 -0
  29. package/dist/errors.d.ts +18 -7
  30. package/dist/errors.js +111 -12
  31. package/dist/errors.test.js +16 -1
  32. package/dist/example.js +19 -7
  33. package/dist/expressServer.d.ts +10 -9
  34. package/dist/expressServer.js +62 -53
  35. package/dist/expressServer.test.js +165 -2
  36. package/dist/githubAuth.d.ts +2 -1
  37. package/dist/githubAuth.js +41 -26
  38. package/dist/githubAuth.test.js +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +4 -0
  41. package/dist/logger.d.ts +1 -1
  42. package/dist/logger.js +42 -20
  43. package/dist/models/versionConfig.d.ts +2 -0
  44. package/dist/models/versionConfig.js +8 -0
  45. package/dist/notifiers/googleChatNotifier.js +14 -16
  46. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  47. package/dist/notifiers/slackNotifier.js +16 -14
  48. package/dist/notifiers/slackNotifier.test.js +41 -3
  49. package/dist/notifiers/zoomNotifier.js +7 -10
  50. package/dist/notifiers/zoomNotifier.test.js +1 -0
  51. package/dist/openApi.d.ts +1 -1
  52. package/dist/openApi.test.js +1 -0
  53. package/dist/openApiBuilder.d.ts +39 -6
  54. package/dist/openApiBuilder.js +1 -31
  55. package/dist/openApiBuilder.test.js +1 -0
  56. package/dist/openApiValidator.js +1 -0
  57. package/dist/openApiValidator.test.js +1 -0
  58. package/dist/permissions.d.ts +4 -4
  59. package/dist/permissions.js +67 -65
  60. package/dist/permissions.middleware.test.js +1 -0
  61. package/dist/permissions.test.js +1 -0
  62. package/dist/plugins.d.ts +5 -5
  63. package/dist/plugins.js +18 -9
  64. package/dist/plugins.test.js +1 -1
  65. package/dist/populate.d.ts +15 -8
  66. package/dist/populate.js +23 -24
  67. package/dist/populate.test.js +1 -0
  68. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  69. package/dist/realtime/changeStreamWatcher.js +724 -0
  70. package/dist/realtime/index.d.ts +6 -0
  71. package/dist/realtime/index.js +27 -0
  72. package/dist/realtime/queryMatcher.d.ts +14 -0
  73. package/dist/realtime/queryMatcher.js +250 -0
  74. package/dist/realtime/queryStore.d.ts +37 -0
  75. package/dist/realtime/queryStore.js +195 -0
  76. package/dist/realtime/realtime.test.d.ts +10 -0
  77. package/dist/realtime/realtime.test.js +3066 -0
  78. package/dist/realtime/realtimeApp.d.ts +93 -0
  79. package/dist/realtime/realtimeApp.js +560 -0
  80. package/dist/realtime/registry.d.ts +40 -0
  81. package/dist/realtime/registry.js +38 -0
  82. package/dist/realtime/socketUser.d.ts +10 -0
  83. package/dist/realtime/socketUser.js +17 -0
  84. package/dist/realtime/types.d.ts +100 -0
  85. package/dist/realtime/types.js +2 -0
  86. package/dist/requestContext.d.ts +37 -0
  87. package/dist/requestContext.js +344 -0
  88. package/dist/requestContext.test.d.ts +1 -0
  89. package/dist/requestContext.test.js +384 -0
  90. package/dist/terrenoApp.d.ts +8 -0
  91. package/dist/terrenoApp.js +50 -13
  92. package/dist/terrenoApp.test.js +194 -21
  93. package/dist/terrenoPlugin.d.ts +11 -0
  94. package/dist/tests/bunSetup.js +1 -0
  95. package/dist/tests.js +1 -1
  96. package/dist/transformers.d.ts +2 -2
  97. package/dist/transformers.js +5 -3
  98. package/dist/transformers.test.js +90 -0
  99. package/dist/types/consentResponse.d.ts +6 -3
  100. package/dist/versionCheckPlugin.d.ts +2 -0
  101. package/dist/versionCheckPlugin.js +18 -12
  102. package/package.json +4 -2
  103. package/src/__tests__/versionCheckPlugin.test.ts +94 -3
  104. package/src/api.arrayOperations.test.ts +1 -0
  105. package/src/api.errors.test.ts +1 -0
  106. package/src/api.hooks.test.ts +1 -0
  107. package/src/api.query.test.ts +1 -0
  108. package/src/api.test.ts +132 -0
  109. package/src/api.ts +199 -84
  110. package/src/auth.test.ts +160 -0
  111. package/src/auth.ts +120 -50
  112. package/src/betterAuthApp.test.ts +1 -0
  113. package/src/betterAuthSetup.test.ts +1 -0
  114. package/src/betterAuthSetup.ts +59 -22
  115. package/src/config.test.ts +255 -0
  116. package/src/config.ts +216 -0
  117. package/src/configuration.test.ts +1 -0
  118. package/src/configurationApp.ts +59 -24
  119. package/src/configurationPlugin.test.ts +1 -0
  120. package/src/consentApp.test.ts +1 -0
  121. package/src/envConfigurationPlugin.test.ts +143 -0
  122. package/src/envConfigurationPlugin.ts +100 -0
  123. package/src/errors.test.ts +19 -1
  124. package/src/errors.ts +118 -38
  125. package/src/example.ts +49 -21
  126. package/src/express.d.ts +18 -1
  127. package/src/expressServer.test.ts +147 -2
  128. package/src/expressServer.ts +80 -50
  129. package/src/githubAuth.test.ts +1 -0
  130. package/src/githubAuth.ts +59 -38
  131. package/src/index.ts +4 -0
  132. package/src/logger.ts +47 -17
  133. package/src/models/versionConfig.ts +13 -2
  134. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  135. package/src/notifiers/googleChatNotifier.ts +7 -9
  136. package/src/notifiers/slackNotifier.test.ts +29 -3
  137. package/src/notifiers/slackNotifier.ts +9 -7
  138. package/src/notifiers/zoomNotifier.test.ts +1 -0
  139. package/src/notifiers/zoomNotifier.ts +8 -11
  140. package/src/openApi.test.ts +1 -0
  141. package/src/openApi.ts +4 -4
  142. package/src/openApiBuilder.test.ts +1 -0
  143. package/src/openApiBuilder.ts +14 -11
  144. package/src/openApiValidator.test.ts +1 -0
  145. package/src/openApiValidator.ts +3 -2
  146. package/src/permissions.middleware.test.ts +1 -0
  147. package/src/permissions.test.ts +1 -0
  148. package/src/permissions.ts +30 -25
  149. package/src/plugins.test.ts +1 -1
  150. package/src/plugins.ts +21 -14
  151. package/src/populate.test.ts +1 -0
  152. package/src/populate.ts +44 -36
  153. package/src/realtime/changeStreamWatcher.ts +572 -0
  154. package/src/realtime/index.ts +34 -0
  155. package/src/realtime/queryMatcher.ts +179 -0
  156. package/src/realtime/queryStore.ts +132 -0
  157. package/src/realtime/realtime.test.ts +2465 -0
  158. package/src/realtime/realtimeApp.ts +478 -0
  159. package/src/realtime/registry.ts +64 -0
  160. package/src/realtime/socketUser.ts +25 -0
  161. package/src/realtime/types.ts +112 -0
  162. package/src/requestContext.test.ts +321 -0
  163. package/src/requestContext.ts +368 -0
  164. package/src/terrenoApp.test.ts +137 -11
  165. package/src/terrenoApp.ts +64 -17
  166. package/src/terrenoPlugin.ts +12 -0
  167. package/src/tests/bunSetup.ts +1 -0
  168. package/src/tests.ts +7 -2
  169. package/src/transformers.test.ts +70 -2
  170. package/src/transformers.ts +15 -7
  171. package/src/types/consentResponse.ts +8 -10
  172. package/src/versionCheckPlugin.ts +15 -7
@@ -1,13 +1,19 @@
1
1
  import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
2
  import type express from "express";
3
+ import mongoose, {Schema} from "mongoose";
3
4
  import supertest from "supertest";
4
5
 
5
6
  import {modelRouter} from "./api";
7
+ import type {UserModel as UserModelType} from "./auth";
8
+ import {configurationPlugin} from "./configurationPlugin";
6
9
  import {Permissions} from "./permissions";
10
+ import {createdUpdatedPlugin} from "./plugins";
7
11
  import {TerrenoApp} from "./terrenoApp";
8
12
  import type {TerrenoPlugin} from "./terrenoPlugin";
9
13
  import {authAsUser, FoodModel, setupDb, UserModel} from "./tests";
10
14
 
15
+ const typedUserModel = UserModel as unknown as UserModelType;
16
+
11
17
  describe("TerrenoApp", () => {
12
18
  const originalEnv = process.env;
13
19
 
@@ -30,7 +36,7 @@ describe("TerrenoApp", () => {
30
36
  it("returns an express application without listening", () => {
31
37
  const app = new TerrenoApp({
32
38
  skipListen: true,
33
- userModel: UserModel as any,
39
+ userModel: typedUserModel,
34
40
  }).build();
35
41
 
36
42
  expect(app).toBeDefined();
@@ -40,7 +46,7 @@ describe("TerrenoApp", () => {
40
46
  const app = new TerrenoApp({
41
47
  corsOrigin: "https://example.com",
42
48
  skipListen: true,
43
- userModel: UserModel as any,
49
+ userModel: typedUserModel,
44
50
  }).build();
45
51
 
46
52
  expect(app).toBeDefined();
@@ -51,7 +57,7 @@ describe("TerrenoApp", () => {
51
57
  it("returns an express application with skipListen", () => {
52
58
  const app = new TerrenoApp({
53
59
  skipListen: true,
54
- userModel: UserModel as any,
60
+ userModel: typedUserModel,
55
61
  }).start();
56
62
 
57
63
  expect(app).toBeDefined();
@@ -59,7 +65,7 @@ describe("TerrenoApp", () => {
59
65
  });
60
66
 
61
67
  describe("register with modelRouter", () => {
62
- let admin: any;
68
+ let admin: Awaited<ReturnType<typeof setupDb>>[0];
63
69
 
64
70
  beforeEach(async () => {
65
71
  [admin] = await setupDb();
@@ -83,7 +89,7 @@ describe("TerrenoApp", () => {
83
89
 
84
90
  const app = new TerrenoApp({
85
91
  skipListen: true,
86
- userModel: UserModel as any,
92
+ userModel: typedUserModel,
87
93
  })
88
94
  .register(foodRegistration)
89
95
  .build();
@@ -115,7 +121,7 @@ describe("TerrenoApp", () => {
115
121
 
116
122
  const app = new TerrenoApp({
117
123
  skipListen: true,
118
- userModel: UserModel as any,
124
+ userModel: typedUserModel,
119
125
  })
120
126
  .register(foodRegistration)
121
127
  .build();
@@ -133,14 +139,13 @@ describe("TerrenoApp", () => {
133
139
 
134
140
  const app = new TerrenoApp({
135
141
  skipListen: true,
136
- userModel: UserModel as any,
142
+ userModel: typedUserModel,
137
143
  })
138
144
  .register(plugin)
139
145
  .build();
140
146
 
141
147
  expect(registerFn).toHaveBeenCalledTimes(1);
142
- // Verify the plugin received the express app
143
- const calledWith = (registerFn.mock.calls as any[][])[0][0];
148
+ const calledWith = (registerFn.mock.calls as unknown[][])[0][0];
144
149
  expect(calledWith).toBe(app);
145
150
  });
146
151
  });
@@ -155,7 +160,7 @@ describe("TerrenoApp", () => {
155
160
 
156
161
  const app = new TerrenoApp({
157
162
  skipListen: true,
158
- userModel: UserModel as any,
163
+ userModel: typedUserModel,
159
164
  })
160
165
  .addMiddleware(middleware)
161
166
  .build();
@@ -165,6 +170,127 @@ describe("TerrenoApp", () => {
165
170
  });
166
171
  });
167
172
 
173
+ describe("configure", () => {
174
+ beforeEach(async () => {
175
+ await setupDb();
176
+ });
177
+
178
+ it("mounts configuration routes when configure() is called", async () => {
179
+ const cfgSchema = new Schema(
180
+ {siteName: {default: "My Site", description: "Site name", type: String}},
181
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
182
+ );
183
+ cfgSchema.plugin(configurationPlugin);
184
+ cfgSchema.plugin(createdUpdatedPlugin);
185
+
186
+ const modelName = `CfgModel_${Date.now()}`;
187
+ const CfgModel = mongoose.model(modelName, cfgSchema);
188
+
189
+ const app = new TerrenoApp({
190
+ skipListen: true,
191
+ userModel: typedUserModel,
192
+ })
193
+ .configure(CfgModel)
194
+ .build();
195
+
196
+ const agent = await authAsUser(app, "admin");
197
+ const res = await agent.get("/configuration/meta");
198
+ expect(res.status).toBe(200);
199
+ });
200
+
201
+ it("supports custom basePath via configure options", async () => {
202
+ const cfgSchema2 = new Schema(
203
+ {siteName: {default: "Test", description: "Site name", type: String}},
204
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
205
+ );
206
+ cfgSchema2.plugin(configurationPlugin);
207
+ cfgSchema2.plugin(createdUpdatedPlugin);
208
+
209
+ const modelName = `CfgModel2_${Date.now()}`;
210
+ const CfgModel2 = mongoose.model(modelName, cfgSchema2);
211
+
212
+ const app = new TerrenoApp({
213
+ skipListen: true,
214
+ userModel: typedUserModel,
215
+ })
216
+ .configure(CfgModel2, {basePath: "/settings"})
217
+ .build();
218
+
219
+ const agent = await authAsUser(app, "admin");
220
+ const res = await agent.get("/settings/meta");
221
+ expect(res.status).toBe(200);
222
+ });
223
+ });
224
+
225
+ describe("fallthrough error handler", () => {
226
+ it("returns 500 for non-API errors", async () => {
227
+ const plugin: TerrenoPlugin = {
228
+ register: (pluginApp) => {
229
+ pluginApp.get("/trigger-fallthrough", (_req: express.Request, _res: express.Response) => {
230
+ throw new Error("unexpected failure");
231
+ });
232
+ },
233
+ };
234
+ const app = new TerrenoApp({
235
+ skipListen: true,
236
+ userModel: typedUserModel,
237
+ })
238
+ .register(plugin)
239
+ .build();
240
+
241
+ const res = await supertest(app).get("/trigger-fallthrough");
242
+ expect(res.status).toBe(500);
243
+ });
244
+ });
245
+
246
+ describe("start with listen", () => {
247
+ it("starts and listens on the configured port", async () => {
248
+ const port = "19876";
249
+ process.env.PORT = port;
250
+ const app = new TerrenoApp({
251
+ userModel: typedUserModel,
252
+ }).start();
253
+
254
+ expect(app).toBeDefined();
255
+ });
256
+ });
257
+
258
+ describe("addMiddleware with app-configuring function", () => {
259
+ it("invokes a function that receives the express app (fn.length > 3)", async () => {
260
+ let receivedApp: express.Application | undefined;
261
+ const configFn = (
262
+ _appInstance: express.Application,
263
+ _a: unknown,
264
+ _b: unknown,
265
+ _c: unknown
266
+ ): void => {
267
+ receivedApp = _appInstance;
268
+ };
269
+
270
+ const app = new TerrenoApp({
271
+ skipListen: true,
272
+ userModel: typedUserModel,
273
+ })
274
+ .addMiddleware(configFn as unknown as (app: express.Application) => void)
275
+ .build();
276
+
277
+ expect(app).toBeDefined();
278
+ expect(receivedApp).toBe(app);
279
+ });
280
+ });
281
+
282
+ describe("logRequests option", () => {
283
+ it("disables request logging when logRequests is false", () => {
284
+ const app = new TerrenoApp({
285
+ logRequests: false,
286
+ skipListen: true,
287
+ userModel: typedUserModel,
288
+ }).build();
289
+
290
+ expect(app).toBeDefined();
291
+ });
292
+ });
293
+
168
294
  describe("modelRouter overload", () => {
169
295
  it("returns ModelRouterRegistration when path is provided", () => {
170
296
  const result = modelRouter("/food", FoodModel, {
@@ -195,7 +321,7 @@ describe("TerrenoApp", () => {
195
321
 
196
322
  // Should be a regular router (function), not a ModelRouterRegistration
197
323
  expect(typeof result).toBe("function");
198
- expect((result as any).__type).toBeUndefined();
324
+ expect((result as unknown as {__type?: string}).__type).toBeUndefined();
199
325
  });
200
326
  });
201
327
  });
package/src/terrenoApp.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {createServer} from "node:http";
1
2
  import * as Sentry from "@sentry/bun";
2
3
  import cors from "cors";
3
4
  import express from "express";
@@ -5,12 +6,23 @@ import qs from "qs";
5
6
  import type {ModelRouterRegistration} from "./api";
6
7
  import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
7
8
  import {ConfigurationApp, type ConfigurationAppOptions} from "./configurationApp";
8
- import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
9
+ import {
10
+ apiErrorMiddleware,
11
+ apiFallthroughErrorMiddleware,
12
+ apiUnauthorizedMiddleware,
13
+ } from "./errors";
9
14
  import {type AuthOptions, logRequests} from "./expressServer";
10
15
  import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
11
16
  import {type LoggingOptions, logger, setupLogging} from "./logger";
12
17
  import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
13
18
  import {openApiEtagMiddleware} from "./openApiEtag";
19
+ import {RealtimeApp} from "./realtime/realtimeApp";
20
+ import type {RealtimeAppOptions} from "./realtime/types";
21
+ import {
22
+ getCurrentRequestContext,
23
+ requestContextMiddleware,
24
+ updateRequestContextFromRequest,
25
+ } from "./requestContext";
14
26
  import type {TerrenoPlugin} from "./terrenoPlugin";
15
27
  import openapi from "./vendor/wesleytodd-openapi/index";
16
28
 
@@ -49,6 +61,13 @@ export interface TerrenoAppOptions {
49
61
  arrayLimit?: number;
50
62
  /** Whether to log all incoming requests (default: true) */
51
63
  logRequests?: boolean;
64
+ /**
65
+ * Real-time sync configuration. When provided, Socket.io and MongoDB change streams
66
+ * are set up automatically — no need to register RealtimeApp as a separate plugin.
67
+ *
68
+ * Set to `true` for defaults, or pass a RealtimeAppOptions object for full control.
69
+ */
70
+ realtime?: boolean | RealtimeAppOptions;
52
71
  }
53
72
 
54
73
  /**
@@ -196,6 +215,7 @@ export class TerrenoApp {
196
215
  * ```
197
216
  */
198
217
  configure(
218
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> required for invariance — consumers pass arbitrary configuration models
199
219
  model: import("mongoose").Model<any>,
200
220
  options?: Omit<ConfigurationAppOptions, "model">
201
221
  ): this {
@@ -239,6 +259,8 @@ export class TerrenoApp {
239
259
  qs.parse(str, {arrayLimit: options.arrayLimit ?? 200})
240
260
  );
241
261
 
262
+ app.use(requestContextMiddleware);
263
+
242
264
  app.use(cors({credentials: true, origin: options.corsOrigin ?? "*"}));
243
265
 
244
266
  // Apply custom middleware before JSON parsing
@@ -255,8 +277,12 @@ export class TerrenoApp {
255
277
  app.use(express.json({limit: "50mb"}));
256
278
 
257
279
  // Auth routes (login/signup/refresh_token) before JWT middleware
258
- addAuthRoutes(app, options.userModel as any, options.authOptions);
259
- setupAuth(app as any, options.userModel as any);
280
+ addAuthRoutes(app, options.userModel, options.authOptions);
281
+ setupAuth(app, options.userModel);
282
+ app.use((req, res, next) => {
283
+ updateRequestContextFromRequest(req, res);
284
+ next();
285
+ });
260
286
 
261
287
  if (options.logRequests !== false) {
262
288
  app.use(logRequests);
@@ -269,9 +295,13 @@ export class TerrenoApp {
269
295
  });
270
296
 
271
297
  // Sentry scopes
272
- app.use((req: any, _res: any, next: any) => {
298
+ app.use((req: express.Request, _res: express.Response, next: express.NextFunction) => {
299
+ const context = getCurrentRequestContext();
273
300
  const transactionId = req.header("X-Transaction-ID");
274
- const sessionId = req.header("X-Session-ID");
301
+ const sessionId = context?.sessionId ?? req.header("X-Session-ID");
302
+ if (context?.requestId) {
303
+ Sentry.getCurrentScope().setTag("request_id", context.requestId);
304
+ }
275
305
  if (transactionId) {
276
306
  Sentry.getCurrentScope().setTag("transaction_id", transactionId);
277
307
  }
@@ -279,7 +309,7 @@ export class TerrenoApp {
279
309
  Sentry.getCurrentScope().setTag("session_id", sessionId);
280
310
  }
281
311
  if (req.user?._id) {
282
- Sentry.getCurrentScope().setTag("user", req.user._id);
312
+ Sentry.getCurrentScope().setTag("user", String(req.user._id));
283
313
  }
284
314
  next();
285
315
  });
@@ -303,8 +333,8 @@ export class TerrenoApp {
303
333
 
304
334
  // GitHub OAuth
305
335
  if (options.githubAuth) {
306
- setupGitHubAuth(app, options.userModel as any, options.githubAuth);
307
- addGitHubAuthRoutes(app, options.userModel as any, options.githubAuth, options.authOptions);
336
+ setupGitHubAuth(app, options.userModel, options.githubAuth);
337
+ addGitHubAuthRoutes(app, options.userModel, options.githubAuth, options.authOptions);
308
338
  }
309
339
 
310
340
  // Mount configuration app if configured
@@ -324,7 +354,7 @@ export class TerrenoApp {
324
354
 
325
355
  // /auth/me must be registered after plugins so that session middleware
326
356
  // (e.g. Better Auth) has a chance to populate req.user first.
327
- addMeRoutes(app, options.userModel as any, options.authOptions);
357
+ addMeRoutes(app, options.userModel, options.authOptions);
328
358
 
329
359
  Sentry.setupExpressErrorHandler(app);
330
360
 
@@ -332,12 +362,7 @@ export class TerrenoApp {
332
362
  app.use(apiUnauthorizedMiddleware);
333
363
  app.use(apiErrorMiddleware);
334
364
 
335
- app.use(function onError(err: any, _req: any, res: any, _next: any) {
336
- logger.error(`Fallthrough error: ${err}${err?.stack ? `\n${err.stack}` : ""}}`);
337
- Sentry.captureException(err);
338
- res.statusCode = 500;
339
- res.end(`${res.sentry}\n`);
340
- });
365
+ app.use(apiFallthroughErrorMiddleware);
341
366
 
342
367
  return app;
343
368
  }
@@ -363,16 +388,38 @@ export class TerrenoApp {
363
388
  * ```
364
389
  */
365
390
  start(): express.Application {
391
+ // If realtime option is set, auto-register the RealtimeApp plugin
392
+ if (this.options.realtime) {
393
+ const hasRealtimePlugin = this.registrations.some(
394
+ (r) => !this.isModelRouterRegistration(r) && r instanceof RealtimeApp
395
+ );
396
+ if (!hasRealtimePlugin) {
397
+ const realtimeConfig =
398
+ typeof this.options.realtime === "object" ? this.options.realtime : {};
399
+ this.register(new RealtimeApp(realtimeConfig));
400
+ }
401
+ }
402
+
366
403
  const app = this.build();
367
404
 
368
405
  if (!this.options.skipListen) {
369
406
  const port = process.env.PORT || "9000";
370
407
  try {
371
- app.listen(port, () => {
408
+ const server = createServer(app);
409
+
410
+ // Notify plugins that need access to the HTTP server (e.g. WebSocket plugins)
411
+ for (const reg of this.registrations) {
412
+ if (!this.isModelRouterRegistration(reg) && typeof reg.onServerCreated === "function") {
413
+ reg.onServerCreated(server);
414
+ }
415
+ }
416
+
417
+ server.listen(port, () => {
372
418
  logger.info(`Listening on port ${port}`);
373
419
  });
374
420
  } catch (error) {
375
- logger.error(`Error trying to start HTTP server: ${error}\n${(error as any).stack}`);
421
+ const stack = error instanceof Error ? error.stack : String(error);
422
+ logger.error(`Error trying to start HTTP server: ${error}\n${stack}`);
376
423
  process.exit(1);
377
424
  }
378
425
  }
@@ -1,3 +1,4 @@
1
+ import type http from "node:http";
1
2
  import type express from "express";
2
3
 
3
4
  /**
@@ -36,4 +37,15 @@ export interface TerrenoPlugin {
36
37
  * @param app - The Express application instance to register with
37
38
  */
38
39
  register(app: express.Application, openApi?: unknown): void;
40
+
41
+ /**
42
+ * Called after the HTTP server is created but before it starts listening.
43
+ * Use this to attach services that need the raw HTTP server, such as
44
+ * Socket.io or other WebSocket libraries.
45
+ *
46
+ * Only called when using `TerrenoApp.start()` (not `build()`).
47
+ *
48
+ * @param server - The Node.js HTTP server instance
49
+ */
50
+ onServerCreated?(server: http.Server): void;
39
51
  }
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterAll, afterEach, beforeAll, beforeEach, mock} from "bun:test";
2
3
  import {Writable} from "node:stream";
3
4
  import mongoose from "mongoose";
package/src/tests.ts CHANGED
@@ -106,7 +106,12 @@ const foodCategorySchema = new Schema<FoodCategory>(
106
106
  {timestamps: {createdAt: "created", updatedAt: "updated"}}
107
107
  );
108
108
 
109
- const likesSchema = new Schema<any>({
109
+ interface Likes {
110
+ likes: boolean;
111
+ userId: mongoose.Types.ObjectId;
112
+ }
113
+
114
+ const likesSchema = new Schema<Likes>({
110
115
  likes: {description: "Whether the user liked the item", type: Boolean},
111
116
  userId: {description: "The user who liked the item", ref: "User", type: "ObjectId"},
112
117
  });
@@ -124,7 +129,7 @@ const foodSchema = new Schema<Food>(
124
129
  type: Schema.Types.ObjectId,
125
130
  },
126
131
  ],
127
- // noExplicitAny: DateOnly is a custom SchemaType not recognized by Mongoose's built-in type definitions
132
+ // biome-ignore lint/suspicious/noExplicitAny: DateOnly is a custom SchemaType not recognized by Mongoose's built-in type definitions
128
133
  expiration: {description: "Expiration date of the food", type: DateOnly as any},
129
134
  hidden: {
130
135
  default: false,
@@ -1,14 +1,17 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {beforeEach, describe, expect, it} from "bun:test";
2
3
  import type express from "express";
3
- import type {ObjectId} from "mongoose";
4
+ import type {Document, ObjectId} from "mongoose";
4
5
  import supertest from "supertest";
5
6
  import type TestAgent from "supertest/lib/agent";
6
7
 
8
+ import type {ModelRouterOptions} from "./api";
7
9
  import {modelRouter} from "./api";
8
10
  import {addAuthRoutes, setupAuth} from "./auth";
11
+ import {APIError} from "./errors";
9
12
  import {Permissions} from "./permissions";
10
13
  import {authAsUser, type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
11
- import {AdminOwnerTransformer} from "./transformers";
14
+ import {AdminOwnerTransformer, defaultResponseHandler, transform} from "./transformers";
12
15
 
13
16
  describe("query and transform", () => {
14
17
  let notAdmin: any;
@@ -200,3 +203,68 @@ describe("query and transform", () => {
200
203
  await server.patch(`/food/${carrots.id}`).send({calories: 10}).expect(403);
201
204
  });
202
205
  });
206
+
207
+ describe("transform (deprecated helper)", () => {
208
+ const mockTransformFn = (obj: Partial<Food>, _method: "create" | "update") => ({
209
+ ...obj,
210
+ name: `${obj.name}_transformed`,
211
+ });
212
+
213
+ it("returns data unchanged when no transformer is configured", () => {
214
+ const options = {permissions: {}} as ModelRouterOptions<Food>;
215
+ const data = {name: "Apple"} as Partial<Food>;
216
+ expect(transform(options, data, "create")).toEqual(data);
217
+ });
218
+
219
+ it("transforms a single object", () => {
220
+ const options = {
221
+ transformer: {transform: mockTransformFn},
222
+ } as unknown as ModelRouterOptions<Food>;
223
+ const result = transform(options, {name: "Apple"} as Partial<Food>, "create");
224
+ expect((result as Partial<Food>).name).toBe("Apple_transformed");
225
+ });
226
+
227
+ it("transforms an array of objects", () => {
228
+ const options = {
229
+ transformer: {transform: mockTransformFn},
230
+ } as unknown as ModelRouterOptions<Food>;
231
+ const data = [{name: "Apple"}, {name: "Banana"}] as Partial<Food>[];
232
+ const result = transform(options, data, "update") as Partial<Food>[];
233
+ expect(result).toHaveLength(2);
234
+ expect(result[0].name).toBe("Apple_transformed");
235
+ expect(result[1].name).toBe("Banana_transformed");
236
+ });
237
+ });
238
+
239
+ describe("defaultResponseHandler", () => {
240
+ it("returns null when doc is null", async () => {
241
+ const options = {permissions: {}} as ModelRouterOptions<Food>;
242
+ const req = {} as express.Request;
243
+ const result = await defaultResponseHandler<Food>(null, "read", req, options);
244
+ expect(result).toBeNull();
245
+ });
246
+
247
+ it("throws APIError when serialize throws", async () => {
248
+ const options = {
249
+ transformer: {
250
+ serialize: () => {
251
+ throw new Error("serialize boom");
252
+ },
253
+ },
254
+ } as unknown as ModelRouterOptions<Food>;
255
+ const fakeDoc = {
256
+ _id: "abc",
257
+ toObject: () => ({name: "Apple"}),
258
+ } as unknown as Document<unknown, unknown, unknown> & Food;
259
+ const req = {} as express.Request;
260
+
261
+ try {
262
+ await defaultResponseHandler(fakeDoc, "read", req, options);
263
+ expect(true).toBe(false);
264
+ } catch (err: unknown) {
265
+ expect(err).toBeInstanceOf(APIError);
266
+ expect((err as APIError).status).toBe(400);
267
+ expect((err as APIError).title).toContain("Error serializing read response");
268
+ }
269
+ });
270
+ });
@@ -22,7 +22,8 @@ const getUserType = (
22
22
  if (user?.admin) {
23
23
  return "admin";
24
24
  }
25
- if (obj && user && String(obj?.ownerId) === String(user?.id)) {
25
+ const withOwner = obj as {ownerId?: unknown} | undefined;
26
+ if (withOwner && user && String(withOwner?.ownerId) === String(user?.id)) {
26
27
  return "owner";
27
28
  }
28
29
  if (user?.id) {
@@ -44,8 +45,9 @@ export const AdminOwnerTransformer = <T>(options: {
44
45
  const pickFields = (obj: Partial<T>, fields: string[]): Partial<T> => {
45
46
  const newData: Partial<T> = {};
46
47
  for (const field of fields) {
47
- if ((obj as Record<string, unknown>)[field] !== undefined) {
48
- (newData as Record<string, unknown>)[field] = (obj as Record<string, unknown>)[field];
48
+ const key = field as keyof T;
49
+ if (obj[key] !== undefined) {
50
+ newData[key] = obj[key];
49
51
  }
50
52
  }
51
53
  return newData;
@@ -115,11 +117,14 @@ export const transform = <T>(
115
117
  export const serialize = <T>(
116
118
  req: express.Request,
117
119
  options: ModelRouterOptions<T>,
118
- data: (Document & T) | (Document & T)[]
120
+ data: (Document<unknown, unknown, unknown> & T) | (Document<unknown, unknown, unknown> & T)[]
119
121
  ) => {
120
- const serializeFn = (serializeData: Document & T, serializeUser?: User) => {
122
+ const serializeFn = (
123
+ serializeData: Document<unknown, unknown, unknown> & T,
124
+ serializeUser?: User
125
+ ) => {
121
126
  const dataObject = serializeData.toObject() as T;
122
- (dataObject as Record<string, unknown>).id = serializeData._id;
127
+ (dataObject as unknown as {id: unknown}).id = serializeData._id;
123
128
 
124
129
  // Search for any value that is a Map and transform it to a plain object.
125
130
  // Otherwise Express drops the contents.
@@ -152,7 +157,10 @@ export const serialize = <T>(
152
157
  * using transformers.serializer if provided.
153
158
  */
154
159
  export const defaultResponseHandler = async <T>(
155
- doc: (Document & T) | (Document & T)[] | null,
160
+ doc:
161
+ | (Document<unknown, unknown, unknown> & T)
162
+ | (Document<unknown, unknown, unknown> & T)[]
163
+ | null,
156
164
  method: "list" | "create" | "read" | "update",
157
165
  request: express.Request,
158
166
  options: ModelRouterOptions<T>
@@ -1,18 +1,16 @@
1
1
  import type mongoose from "mongoose";
2
2
  import type {FindExactlyOnePlugin, FindOneOrNonePlugin} from "../plugins";
3
3
 
4
- // biome-ignore lint/complexity/noBannedTypes: No methods.
5
- export type ConsentResponseMethods = {};
4
+ // biome-ignore lint/suspicious/noEmptyInterface: Prefer interface over type per project rules
5
+ export interface ConsentResponseMethods {}
6
6
 
7
- export type ConsentResponseStatics = FindExactlyOnePlugin<ConsentResponseDocument> &
8
- FindOneOrNonePlugin<ConsentResponseDocument>;
7
+ export interface ConsentResponseStatics
8
+ extends FindExactlyOnePlugin<ConsentResponseDocument>,
9
+ FindOneOrNonePlugin<ConsentResponseDocument> {}
9
10
 
10
- export type ConsentResponseModel = mongoose.Model<
11
- ConsentResponseDocument,
12
- object,
13
- ConsentResponseMethods
14
- > &
15
- ConsentResponseStatics;
11
+ export interface ConsentResponseModel
12
+ extends mongoose.Model<ConsentResponseDocument, object, ConsentResponseMethods>,
13
+ ConsentResponseStatics {}
16
14
 
17
15
  export interface ConsentResponseDocument extends mongoose.Document {
18
16
  _id: mongoose.Types.ObjectId;
@@ -8,6 +8,8 @@ export type VersionCheckStatus = "ok" | "warning" | "required";
8
8
 
9
9
  export interface VersionCheckResponse {
10
10
  message?: string;
11
+ /** How often the client should poll for updates, in milliseconds. */
12
+ pollingIntervalMs?: number;
11
13
  requiredVersion?: number;
12
14
  status: VersionCheckStatus;
13
15
  updateUrl?: string;
@@ -17,6 +19,7 @@ export interface VersionCheckResponse {
17
19
  const DEFAULT_WARNING_MESSAGE =
18
20
  "A new version is available. Please update for the best experience.";
19
21
  const DEFAULT_REQUIRED_MESSAGE = "This version is no longer supported. Please update to continue.";
22
+ const DEFAULT_POLLING_INTERVAL_MINUTES = 1440;
20
23
 
21
24
  /**
22
25
  * TerrenoPlugin that adds a public GET /version-check endpoint for upgrade enforcement.
@@ -30,12 +33,12 @@ export class VersionCheckPlugin implements TerrenoPlugin {
30
33
  const versionParam = req.query.version;
31
34
  const platform = req.query.platform as string | undefined;
32
35
 
33
- const version =
34
- typeof versionParam === "string"
35
- ? parseInt(versionParam, 10)
36
- : typeof versionParam === "number"
37
- ? versionParam
38
- : undefined;
36
+ let version: number | undefined;
37
+ if (typeof versionParam === "string") {
38
+ version = parseInt(versionParam, 10);
39
+ } else if (typeof versionParam === "number") {
40
+ version = versionParam;
41
+ }
39
42
 
40
43
  if (version === undefined || Number.isNaN(version)) {
41
44
  return res.json({status: "ok" as VersionCheckStatus});
@@ -46,7 +49,10 @@ export class VersionCheckPlugin implements TerrenoPlugin {
46
49
  const config = await VersionConfig.findOneOrNone({_singleton: "config"});
47
50
 
48
51
  if (!config) {
49
- return res.json({status: "ok" as VersionCheckStatus});
52
+ return res.json({
53
+ pollingIntervalMs: DEFAULT_POLLING_INTERVAL_MINUTES * 60 * 1000,
54
+ status: "ok" as VersionCheckStatus,
55
+ });
50
56
  }
51
57
 
52
58
  const requiredVersion =
@@ -59,6 +65,8 @@ export class VersionCheckPlugin implements TerrenoPlugin {
59
65
  : (config.mobileWarningVersion ?? 0);
60
66
 
61
67
  const response: VersionCheckResponse = {
68
+ pollingIntervalMs:
69
+ (config.pollingIntervalMinutes ?? DEFAULT_POLLING_INTERVAL_MINUTES) * 60 * 1000,
62
70
  requiredVersion: requiredVersion > 0 ? requiredVersion : undefined,
63
71
  status: "ok",
64
72
  updateUrl: config.updateUrl || undefined,