@terreno/api 0.20.1 → 0.21.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 (65) hide show
  1. package/.ai/guidelines/core.md +71 -0
  2. package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
  3. package/README.md +54 -1
  4. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  5. package/dist/actions.openApi.test.js +13 -11
  6. package/dist/api.js +98 -11
  7. package/dist/api.query.test.js +31 -1
  8. package/dist/api.test.js +211 -0
  9. package/dist/auth.test.js +10 -10
  10. package/dist/betterAuth.d.ts +1 -1
  11. package/dist/consentApp.test.js +1 -0
  12. package/dist/example.js +4 -4
  13. package/dist/expressServer.d.ts +0 -22
  14. package/dist/expressServer.js +1 -125
  15. package/dist/expressServer.test.js +90 -91
  16. package/dist/githubAuth.test.js +22 -22
  17. package/dist/logger.d.ts +154 -0
  18. package/dist/logger.js +445 -26
  19. package/dist/logger.test.js +435 -0
  20. package/dist/middleware.d.ts +7 -0
  21. package/dist/middleware.js +58 -1
  22. package/dist/middleware.test.js +159 -0
  23. package/dist/openApi.test.js +10 -17
  24. package/dist/openApiBuilder.test.js +18 -10
  25. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  26. package/dist/realtime/changeStreamWatcher.js +2 -4
  27. package/dist/realtime/queryMatcher.d.ts +1 -1
  28. package/dist/realtime/queryMatcher.js +39 -14
  29. package/dist/realtime/types.d.ts +3 -3
  30. package/dist/requestContext.d.ts +61 -0
  31. package/dist/requestContext.js +74 -0
  32. package/dist/secretProviders.test.js +335 -0
  33. package/dist/terrenoApp.d.ts +27 -15
  34. package/dist/terrenoApp.js +24 -14
  35. package/dist/terrenoApp.test.js +52 -0
  36. package/dist/tests/bunSetup.js +61 -7
  37. package/dist/tests.js +27 -4
  38. package/package.json +1 -1
  39. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  40. package/src/actions.openApi.test.ts +12 -10
  41. package/src/api.query.test.ts +24 -1
  42. package/src/api.test.ts +169 -0
  43. package/src/api.ts +71 -0
  44. package/src/auth.test.ts +10 -10
  45. package/src/betterAuth.ts +1 -1
  46. package/src/consentApp.test.ts +1 -0
  47. package/src/example.ts +4 -4
  48. package/src/expressServer.test.ts +82 -85
  49. package/src/expressServer.ts +1 -213
  50. package/src/githubAuth.test.ts +22 -22
  51. package/src/logger.test.ts +466 -1
  52. package/src/logger.ts +477 -14
  53. package/src/middleware.test.ts +74 -2
  54. package/src/middleware.ts +57 -0
  55. package/src/openApi.test.ts +10 -17
  56. package/src/openApiBuilder.test.ts +18 -10
  57. package/src/realtime/changeStreamWatcher.ts +15 -10
  58. package/src/realtime/queryMatcher.ts +54 -27
  59. package/src/realtime/types.ts +4 -4
  60. package/src/requestContext.ts +86 -0
  61. package/src/secretProviders.test.ts +219 -1
  62. package/src/terrenoApp.test.ts +38 -0
  63. package/src/terrenoApp.ts +37 -15
  64. package/src/tests/bunSetup.ts +16 -3
  65. package/src/tests.ts +17 -4
package/src/api.test.ts CHANGED
@@ -2040,5 +2040,174 @@ describe("@terreno/api", () => {
2040
2040
 
2041
2041
  expect(res.body.data.name).toBe("Spinach");
2042
2042
  });
2043
+
2044
+ it("returns 400 when X-Unmodified-Since-ISO header is an invalid date", async () => {
2045
+ const res = await agent
2046
+ .patch(`/food/${spinach._id}`)
2047
+ .set("X-Unmodified-Since-ISO", "not-a-date")
2048
+ .send({name: "Bad Date"})
2049
+ .expect(400);
2050
+
2051
+ expect(res.body.title).toBe("Invalid conflict-detection timestamp");
2052
+ expect(res.body.detail).toContain("X-Unmodified-Since-ISO");
2053
+ });
2054
+
2055
+ it("returns 400 when If-Unmodified-Since header is an invalid HTTP date", async () => {
2056
+ const res = await agent
2057
+ .patch(`/food/${spinach._id}`)
2058
+ .set("If-Unmodified-Since", "not-a-valid-http-date")
2059
+ .send({name: "Bad HTTP Date"})
2060
+ .expect(400);
2061
+
2062
+ expect(res.body.title).toBe("Invalid conflict-detection timestamp");
2063
+ expect(res.body.detail).toContain("If-Unmodified-Since");
2064
+ });
2065
+
2066
+ it("returns 400 when _updatedAt body field is an invalid date", async () => {
2067
+ const res = await agent
2068
+ .patch(`/food/${spinach._id}`)
2069
+ .send({_updatedAt: "garbage-date", name: "Bad Body Date"})
2070
+ .expect(400);
2071
+
2072
+ expect(res.body.title).toBe("Invalid conflict-detection timestamp");
2073
+ expect(res.body.detail).toContain("_updatedAt");
2074
+ });
2075
+
2076
+ it("handles doc timestamp stored as ISO string", async () => {
2077
+ await FoodModel.collection.updateOne(
2078
+ {_id: spinach._id as unknown as mongoose.Types.ObjectId},
2079
+ {$set: {updated: "2025-06-15T12:00:00.000Z"}}
2080
+ );
2081
+
2082
+ const res = await agent
2083
+ .patch(`/food/${spinach._id}`)
2084
+ .set("If-Unmodified-Since", DateTime.fromISO("2025-06-15T11:00:00.000Z").toHTTP()!)
2085
+ .send({name: "String Timestamp"})
2086
+ .expect(409);
2087
+
2088
+ expect(res.body.error).toBe("Conflict");
2089
+ });
2090
+ });
2091
+
2092
+ describe("three-arg modelRouter registration", () => {
2093
+ it("returns a ModelRouterRegistration with path and realtime", () => {
2094
+ const registration = modelRouter("/food", FoodModel, {
2095
+ permissions: {
2096
+ create: [Permissions.IsAny],
2097
+ delete: [Permissions.IsAny],
2098
+ list: [Permissions.IsAny],
2099
+ read: [Permissions.IsAny],
2100
+ update: [Permissions.IsAny],
2101
+ },
2102
+ realtime: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2103
+ });
2104
+ expect(registration).toHaveProperty("__type", "modelRouter");
2105
+ expect(registration).toHaveProperty("path", "/food");
2106
+ expect(registration).toHaveProperty("router");
2107
+ expect(registration).toHaveProperty("_buildWithOpenApi");
2108
+ });
2109
+
2110
+ it("logs a warning when realtime config is used without the path form", () => {
2111
+ const router = modelRouter(FoodModel, {
2112
+ permissions: {
2113
+ create: [Permissions.IsAny],
2114
+ delete: [Permissions.IsAny],
2115
+ list: [Permissions.IsAny],
2116
+ read: [Permissions.IsAny],
2117
+ update: [Permissions.IsAny],
2118
+ },
2119
+ realtime: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2120
+ });
2121
+ // Returns a plain Router, not a registration, because no path was given
2122
+ expect(router).toBeDefined();
2123
+ expect(router).not.toHaveProperty("__type");
2124
+ });
2125
+ });
2126
+
2127
+ describe("create transform error wrapping", () => {
2128
+ let admin: mongoose.HydratedDocument<User>;
2129
+ let agent: TestAgent;
2130
+
2131
+ beforeEach(async () => {
2132
+ [admin] = await setupDb();
2133
+
2134
+ app = getBaseServer();
2135
+ setupAuth(app, UserModel as unknown as Parameters<typeof setupAuth>[1]);
2136
+ addAuthRoutes(app, UserModel as unknown as Parameters<typeof addAuthRoutes>[1]);
2137
+ app.use(
2138
+ "/food",
2139
+ modelRouter(FoodModel, {
2140
+ permissions: {
2141
+ create: [Permissions.IsAny],
2142
+ delete: [Permissions.IsAny],
2143
+ list: [Permissions.IsAny],
2144
+ read: [Permissions.IsAny],
2145
+ update: [Permissions.IsAny],
2146
+ },
2147
+ transformer: {
2148
+ transform: () => {
2149
+ throw new Error("generic transform error");
2150
+ },
2151
+ },
2152
+ })
2153
+ );
2154
+ server = supertest(app);
2155
+ agent = await authAsUser(app, "admin");
2156
+ });
2157
+
2158
+ it("wraps non-APIError transform errors in a 400 APIError on create", async () => {
2159
+ const res = await agent
2160
+ .post("/food")
2161
+ .send({calories: 10, name: "New Food", ownerId: admin._id})
2162
+ .expect(400);
2163
+
2164
+ expect(res.body.title).toContain("generic transform error");
2165
+ });
2166
+ });
2167
+
2168
+ describe("update transform error wrapping", () => {
2169
+ let admin: mongoose.HydratedDocument<User>;
2170
+ let agent: TestAgent;
2171
+ let spinach: Food;
2172
+
2173
+ beforeEach(async () => {
2174
+ [admin] = await setupDb();
2175
+ spinach = await FoodModel.create({
2176
+ calories: 10,
2177
+ created: new Date(),
2178
+ hidden: false,
2179
+ name: "Spinach",
2180
+ ownerId: admin._id,
2181
+ });
2182
+
2183
+ app = getBaseServer();
2184
+ setupAuth(app, UserModel as unknown as Parameters<typeof setupAuth>[1]);
2185
+ addAuthRoutes(app, UserModel as unknown as Parameters<typeof addAuthRoutes>[1]);
2186
+ app.use(
2187
+ "/food",
2188
+ modelRouter(FoodModel, {
2189
+ permissions: {
2190
+ create: [Permissions.IsAny],
2191
+ delete: [Permissions.IsAny],
2192
+ list: [Permissions.IsAny],
2193
+ read: [Permissions.IsAny],
2194
+ update: [Permissions.IsAny],
2195
+ },
2196
+ transformer: {
2197
+ transform: () => {
2198
+ throw new Error("update transform error");
2199
+ },
2200
+ },
2201
+ })
2202
+ );
2203
+ server = supertest(app);
2204
+ agent = await authAsUser(app, "admin");
2205
+ });
2206
+
2207
+ it("wraps non-APIError transform errors in a 403 APIError on update", async () => {
2208
+ const res = await agent.patch(`/food/${spinach._id}`).send({name: "Updated"}).expect(403);
2209
+
2210
+ expect(res.body.title).toContain("update transform error");
2211
+ });
2043
2212
  });
2044
2213
  });
package/src/api.ts CHANGED
@@ -342,6 +342,75 @@ export interface ModelRouterOptions<T> {
342
342
  realtime?: RealtimeConfig;
343
343
  }
344
344
 
345
+ /**
346
+ * Parses a date-range query bound from an ISO-8601 string using Luxon, throwing a 400
347
+ * APIError when the value cannot be parsed. Centralizes date-string parsing so the repo's
348
+ * Luxon convention is honored for admin changelist date-range filters.
349
+ */
350
+ const parseDateRangeBound = (rawValue: unknown, queryKey: string): Date => {
351
+ const parsed = DateTime.fromISO(String(rawValue), {zone: "utc"});
352
+ if (!parsed.isValid) {
353
+ throw new APIError({
354
+ status: 400,
355
+ title: `Invalid date for query parameter ${queryKey}`,
356
+ });
357
+ }
358
+ return parsed.toJSDate();
359
+ };
360
+
361
+ /**
362
+ * Collapses `field_gte` / `field_lte` query pairs into `{ field: { $gte, $lte } }` for Date paths,
363
+ * so admin changelist date-range filters map to valid Mongoose range queries.
364
+ */
365
+ const mergeDateRangeQueryParams = <T>(
366
+ model: Model<T>,
367
+ query: Record<string, unknown>
368
+ ): Record<string, unknown> => {
369
+ const schema = model.schema;
370
+ const result: Record<string, unknown> = {...query};
371
+ const dateRangeBases = new Set<string>();
372
+ for (const key of Object.keys(result)) {
373
+ const match = /^(.+)_(gte|lte)$/.exec(key);
374
+ if (!match) {
375
+ continue;
376
+ }
377
+ const baseField = match[1];
378
+ const path = schema.path(baseField);
379
+ if (!path || path.instance !== "Date") {
380
+ continue;
381
+ }
382
+ dateRangeBases.add(baseField);
383
+ }
384
+ for (const baseField of dateRangeBases) {
385
+ const gteKey = `${baseField}_gte`;
386
+ const lteKey = `${baseField}_lte`;
387
+ const gteRaw = result[gteKey];
388
+ const lteRaw = result[lteKey];
389
+ const bounds: {$gte?: Date; $lte?: Date} = {};
390
+ if (gteRaw !== undefined && gteRaw !== null && String(gteRaw).trim() !== "") {
391
+ bounds.$gte = parseDateRangeBound(gteRaw, gteKey);
392
+ }
393
+ if (lteRaw !== undefined && lteRaw !== null && String(lteRaw).trim() !== "") {
394
+ bounds.$lte = parseDateRangeBound(lteRaw, lteKey);
395
+ }
396
+ if (Object.keys(bounds).length === 0) {
397
+ continue;
398
+ }
399
+ delete result[gteKey];
400
+ delete result[lteKey];
401
+ const direct = result[baseField];
402
+ if (direct !== undefined && direct !== null && typeof direct !== "object") {
403
+ delete result[baseField];
404
+ }
405
+ if (typeof direct === "object" && direct !== null && !Array.isArray(direct)) {
406
+ result[baseField] = {...(direct as Record<string, unknown>), ...bounds};
407
+ } else {
408
+ result[baseField] = bounds;
409
+ }
410
+ }
411
+ return result;
412
+ };
413
+
345
414
  // Ensures query params are allowed. Also checks nested query params when using $and/$or.
346
415
  const checkQueryParamAllowed = (
347
416
  queryParam: string,
@@ -711,6 +780,8 @@ function _buildModelRouter<T>(model: Model<T>, options: ModelRouterOptions<T>):
711
780
  }
712
781
  }
713
782
 
783
+ query = mergeDateRangeQueryParams(model, query);
784
+
714
785
  // Special operators. NOTE: these request Mongo Atlas.
715
786
  if (req.query.$search) {
716
787
  mongoose.connection.db?.collection(model.collection.collectionName);
package/src/auth.test.ts CHANGED
@@ -7,9 +7,9 @@ import type TestAgent from "supertest/lib/agent";
7
7
 
8
8
  import {modelRouter} from "./api";
9
9
  import {addAuthRoutes, addMeRoutes, generateTokens, setupAuth} from "./auth";
10
- import {setupServer} from "./expressServer";
11
10
  import {Permissions} from "./permissions";
12
11
  import {getCurrentRequestContext} from "./requestContext";
12
+ import {TerrenoApp} from "./terrenoApp";
13
13
  import {type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
14
14
  import {AdminOwnerTransformer} from "./transformers";
15
15
  import {timeout} from "./utils";
@@ -151,11 +151,11 @@ describe("auth tests", () => {
151
151
  })
152
152
  );
153
153
  }
154
- app = setupServer({
155
- addRoutes,
154
+ app = new TerrenoApp({
155
+ configureApp: addRoutes,
156
156
  skipListen: true,
157
157
  userModel: UserModel as any,
158
- });
158
+ }).build();
159
159
  agent = supertest.agent(app);
160
160
  });
161
161
 
@@ -827,11 +827,11 @@ describe("addAuthRoutes /refresh_token error paths", () => {
827
827
  beforeEach(async () => {
828
828
  setSystemTime();
829
829
  await setupDb();
830
- app = setupServer({
831
- addRoutes: () => {},
830
+ app = new TerrenoApp({
831
+ configureApp: () => {},
832
832
  skipListen: true,
833
833
  userModel: UserModel as any,
834
- });
834
+ }).build();
835
835
  agent = supertest.agent(app);
836
836
  });
837
837
 
@@ -892,11 +892,11 @@ describe("addMeRoutes edge cases", () => {
892
892
  beforeEach(async () => {
893
893
  setSystemTime();
894
894
  await setupDb();
895
- app = setupServer({
896
- addRoutes: () => {},
895
+ app = new TerrenoApp({
896
+ configureApp: () => {},
897
897
  skipListen: true,
898
898
  userModel: UserModel as any,
899
- });
899
+ }).build();
900
900
  agent = supertest.agent(app);
901
901
  });
902
902
 
package/src/betterAuth.ts CHANGED
@@ -63,7 +63,7 @@ export interface BetterAuthConfig {
63
63
  }
64
64
 
65
65
  /**
66
- * Auth provider selection for setupServer.
66
+ * Auth provider selection for TerrenoApp.
67
67
  * - "jwt": Traditional JWT/Passport authentication (default)
68
68
  * - "better-auth": Better Auth with OAuth support
69
69
  */
@@ -39,6 +39,7 @@ describe("ConsentApp", () => {
39
39
  it("returns empty list when no forms exist", async () => {
40
40
  const res = await adminAgent.get("/consent-forms").expect(200);
41
41
  expect(res.body.data).toHaveLength(0);
42
+ expect(res.body.requestId).toBe(res.headers["x-request-id"]);
42
43
  });
43
44
 
44
45
  it("lists consent forms for admins", async () => {
package/src/example.ts CHANGED
@@ -4,7 +4,6 @@ import passportLocalMongoose from "passport-local-mongoose";
4
4
 
5
5
  import {type ModelRouterOptions, modelRouter} from "./api";
6
6
  import {addAuthRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
7
- import {setupServer} from "./expressServer";
8
7
  import {logger} from "./logger";
9
8
  import {Permissions} from "./permissions";
10
9
  import {
@@ -14,6 +13,7 @@ import {
14
13
  findOneOrNone,
15
14
  isDeletedPlugin,
16
15
  } from "./plugins";
16
+ import {TerrenoApp} from "./terrenoApp";
17
17
 
18
18
  mongoose
19
19
  .connect("mongodb://localhost:27017/example")
@@ -116,12 +116,12 @@ const getBaseServer = () => {
116
116
  );
117
117
  };
118
118
 
119
- return setupServer({
120
- addRoutes,
119
+ return new TerrenoApp({
120
+ configureApp: addRoutes,
121
121
  loggingOptions: {
122
122
  level: "debug",
123
123
  },
124
124
  userModel: UserModel as unknown as UserMongooseModel,
125
- });
125
+ }).build();
126
126
  };
127
127
  getBaseServer();