@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.
- package/dist/__tests__/versionCheckPlugin.test.js +136 -3
- package/dist/api.arrayOperations.test.js +1 -0
- package/dist/api.d.ts +15 -4
- package/dist/api.errors.test.js +1 -0
- package/dist/api.hooks.test.js +1 -0
- package/dist/api.js +153 -104
- package/dist/api.query.test.js +1 -0
- package/dist/api.test.js +174 -0
- package/dist/auth.d.ts +10 -5
- package/dist/auth.js +163 -90
- package/dist/auth.test.js +159 -0
- package/dist/betterAuthApp.test.js +1 -0
- package/dist/betterAuthSetup.d.ts +5 -6
- package/dist/betterAuthSetup.js +30 -17
- package/dist/betterAuthSetup.test.js +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +257 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +328 -0
- package/dist/configuration.test.js +1 -0
- package/dist/configurationApp.d.ts +1 -1
- package/dist/configurationApp.js +17 -13
- package/dist/configurationPlugin.test.js +1 -0
- package/dist/consentApp.test.js +1 -0
- package/dist/envConfigurationPlugin.d.ts +2 -0
- package/dist/envConfigurationPlugin.js +173 -0
- package/dist/envConfigurationPlugin.test.d.ts +1 -0
- package/dist/envConfigurationPlugin.test.js +322 -0
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +111 -12
- package/dist/errors.test.js +16 -1
- package/dist/example.js +19 -7
- package/dist/expressServer.d.ts +10 -9
- package/dist/expressServer.js +62 -53
- package/dist/expressServer.test.js +165 -2
- package/dist/githubAuth.d.ts +2 -1
- package/dist/githubAuth.js +41 -26
- package/dist/githubAuth.test.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +42 -20
- package/dist/models/versionConfig.d.ts +2 -0
- package/dist/models/versionConfig.js +8 -0
- package/dist/notifiers/googleChatNotifier.js +14 -16
- package/dist/notifiers/googleChatNotifier.test.js +1 -0
- package/dist/notifiers/slackNotifier.js +16 -14
- package/dist/notifiers/slackNotifier.test.js +41 -3
- package/dist/notifiers/zoomNotifier.js +7 -10
- package/dist/notifiers/zoomNotifier.test.js +1 -0
- package/dist/openApi.d.ts +1 -1
- package/dist/openApi.test.js +1 -0
- package/dist/openApiBuilder.d.ts +39 -6
- package/dist/openApiBuilder.js +1 -31
- package/dist/openApiBuilder.test.js +1 -0
- package/dist/openApiValidator.js +1 -0
- package/dist/openApiValidator.test.js +1 -0
- package/dist/permissions.d.ts +4 -4
- package/dist/permissions.js +67 -65
- package/dist/permissions.middleware.test.js +1 -0
- package/dist/permissions.test.js +1 -0
- package/dist/plugins.d.ts +5 -5
- package/dist/plugins.js +18 -9
- package/dist/plugins.test.js +1 -1
- package/dist/populate.d.ts +15 -8
- package/dist/populate.js +23 -24
- package/dist/populate.test.js +1 -0
- package/dist/realtime/changeStreamWatcher.d.ts +73 -0
- package/dist/realtime/changeStreamWatcher.js +724 -0
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime/index.js +27 -0
- package/dist/realtime/queryMatcher.d.ts +14 -0
- package/dist/realtime/queryMatcher.js +250 -0
- package/dist/realtime/queryStore.d.ts +37 -0
- package/dist/realtime/queryStore.js +195 -0
- package/dist/realtime/realtime.test.d.ts +10 -0
- package/dist/realtime/realtime.test.js +3066 -0
- package/dist/realtime/realtimeApp.d.ts +93 -0
- package/dist/realtime/realtimeApp.js +560 -0
- package/dist/realtime/registry.d.ts +40 -0
- package/dist/realtime/registry.js +38 -0
- package/dist/realtime/socketUser.d.ts +10 -0
- package/dist/realtime/socketUser.js +17 -0
- package/dist/realtime/types.d.ts +100 -0
- package/dist/realtime/types.js +2 -0
- package/dist/requestContext.d.ts +37 -0
- package/dist/requestContext.js +344 -0
- package/dist/requestContext.test.d.ts +1 -0
- package/dist/requestContext.test.js +384 -0
- package/dist/terrenoApp.d.ts +8 -0
- package/dist/terrenoApp.js +50 -13
- package/dist/terrenoApp.test.js +194 -21
- package/dist/terrenoPlugin.d.ts +11 -0
- package/dist/tests/bunSetup.js +1 -0
- package/dist/tests.js +1 -1
- package/dist/transformers.d.ts +2 -2
- package/dist/transformers.js +5 -3
- package/dist/transformers.test.js +90 -0
- package/dist/types/consentResponse.d.ts +6 -3
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +18 -12
- package/package.json +4 -2
- package/src/__tests__/versionCheckPlugin.test.ts +94 -3
- package/src/api.arrayOperations.test.ts +1 -0
- package/src/api.errors.test.ts +1 -0
- package/src/api.hooks.test.ts +1 -0
- package/src/api.query.test.ts +1 -0
- package/src/api.test.ts +132 -0
- package/src/api.ts +199 -84
- package/src/auth.test.ts +160 -0
- package/src/auth.ts +120 -50
- package/src/betterAuthApp.test.ts +1 -0
- package/src/betterAuthSetup.test.ts +1 -0
- package/src/betterAuthSetup.ts +59 -22
- package/src/config.test.ts +255 -0
- package/src/config.ts +216 -0
- package/src/configuration.test.ts +1 -0
- package/src/configurationApp.ts +59 -24
- package/src/configurationPlugin.test.ts +1 -0
- package/src/consentApp.test.ts +1 -0
- package/src/envConfigurationPlugin.test.ts +143 -0
- package/src/envConfigurationPlugin.ts +100 -0
- package/src/errors.test.ts +19 -1
- package/src/errors.ts +118 -38
- package/src/example.ts +49 -21
- package/src/express.d.ts +18 -1
- package/src/expressServer.test.ts +147 -2
- package/src/expressServer.ts +80 -50
- package/src/githubAuth.test.ts +1 -0
- package/src/githubAuth.ts +59 -38
- package/src/index.ts +4 -0
- package/src/logger.ts +47 -17
- package/src/models/versionConfig.ts +13 -2
- package/src/notifiers/googleChatNotifier.test.ts +1 -0
- package/src/notifiers/googleChatNotifier.ts +7 -9
- package/src/notifiers/slackNotifier.test.ts +29 -3
- package/src/notifiers/slackNotifier.ts +9 -7
- package/src/notifiers/zoomNotifier.test.ts +1 -0
- package/src/notifiers/zoomNotifier.ts +8 -11
- package/src/openApi.test.ts +1 -0
- package/src/openApi.ts +4 -4
- package/src/openApiBuilder.test.ts +1 -0
- package/src/openApiBuilder.ts +14 -11
- package/src/openApiValidator.test.ts +1 -0
- package/src/openApiValidator.ts +3 -2
- package/src/permissions.middleware.test.ts +1 -0
- package/src/permissions.test.ts +1 -0
- package/src/permissions.ts +30 -25
- package/src/plugins.test.ts +1 -1
- package/src/plugins.ts +21 -14
- package/src/populate.test.ts +1 -0
- package/src/populate.ts +44 -36
- package/src/realtime/changeStreamWatcher.ts +572 -0
- package/src/realtime/index.ts +34 -0
- package/src/realtime/queryMatcher.ts +179 -0
- package/src/realtime/queryStore.ts +132 -0
- package/src/realtime/realtime.test.ts +2465 -0
- package/src/realtime/realtimeApp.ts +478 -0
- package/src/realtime/registry.ts +64 -0
- package/src/realtime/socketUser.ts +25 -0
- package/src/realtime/types.ts +112 -0
- package/src/requestContext.test.ts +321 -0
- package/src/requestContext.ts +368 -0
- package/src/terrenoApp.test.ts +137 -11
- package/src/terrenoApp.ts +64 -17
- package/src/terrenoPlugin.ts +12 -0
- package/src/tests/bunSetup.ts +1 -0
- package/src/tests.ts +7 -2
- package/src/transformers.test.ts +70 -2
- package/src/transformers.ts +15 -7
- package/src/types/consentResponse.ts +8 -10
- package/src/versionCheckPlugin.ts +15 -7
package/src/terrenoApp.test.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
142
|
+
userModel: typedUserModel,
|
|
137
143
|
})
|
|
138
144
|
.register(plugin)
|
|
139
145
|
.build();
|
|
140
146
|
|
|
141
147
|
expect(registerFn).toHaveBeenCalledTimes(1);
|
|
142
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
|
259
|
-
setupAuth(app
|
|
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:
|
|
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
|
|
307
|
-
addGitHubAuthRoutes(app, options.userModel
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/terrenoPlugin.ts
CHANGED
|
@@ -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
|
}
|
package/src/tests/bunSetup.ts
CHANGED
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
|
-
|
|
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,
|
package/src/transformers.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/transformers.ts
CHANGED
|
@@ -22,7 +22,8 @@ const getUserType = (
|
|
|
22
22
|
if (user?.admin) {
|
|
23
23
|
return "admin";
|
|
24
24
|
}
|
|
25
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 = (
|
|
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
|
|
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:
|
|
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/
|
|
5
|
-
export
|
|
4
|
+
// biome-ignore lint/suspicious/noEmptyInterface: Prefer interface over type per project rules
|
|
5
|
+
export interface ConsentResponseMethods {}
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
|
|
7
|
+
export interface ConsentResponseStatics
|
|
8
|
+
extends FindExactlyOnePlugin<ConsentResponseDocument>,
|
|
9
|
+
FindOneOrNonePlugin<ConsentResponseDocument> {}
|
|
9
10
|
|
|
10
|
-
export
|
|
11
|
-
ConsentResponseDocument,
|
|
12
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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({
|
|
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,
|