@terreno/api 0.20.2 → 0.22.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 (107) 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/bunfig.toml +1 -1
  5. package/dist/__tests__/versionCheckPlugin.test.js +29 -7
  6. package/dist/actions.openApi.test.js +13 -11
  7. package/dist/api.js +98 -11
  8. package/dist/api.query.test.js +31 -1
  9. package/dist/api.test.js +211 -0
  10. package/dist/auth.test.js +418 -43
  11. package/dist/betterAuth.d.ts +1 -1
  12. package/dist/consentApp.test.js +1 -0
  13. package/dist/example.js +4 -4
  14. package/dist/expressServer.d.ts +0 -22
  15. package/dist/expressServer.js +1 -125
  16. package/dist/expressServer.test.js +90 -91
  17. package/dist/githubAuth.test.js +22 -22
  18. package/dist/logger.d.ts +154 -0
  19. package/dist/logger.js +445 -26
  20. package/dist/logger.test.js +435 -0
  21. package/dist/middleware.d.ts +7 -0
  22. package/dist/middleware.js +58 -1
  23. package/dist/middleware.test.js +159 -0
  24. package/dist/models/consentForm.js +2 -1
  25. package/dist/models/consentResponse.js +2 -1
  26. package/dist/models/versionConfig.js +2 -1
  27. package/dist/openApi.test.js +10 -17
  28. package/dist/openApiBuilder.d.ts +18 -0
  29. package/dist/openApiBuilder.js +21 -0
  30. package/dist/openApiBuilder.test.js +34 -10
  31. package/dist/permissions.test.js +10 -43
  32. package/dist/populate.test.js +10 -42
  33. package/dist/realtime/changeStreamWatcher.d.ts +4 -4
  34. package/dist/realtime/changeStreamWatcher.js +2 -4
  35. package/dist/realtime/queryMatcher.d.ts +1 -1
  36. package/dist/realtime/queryMatcher.js +39 -14
  37. package/dist/realtime/types.d.ts +3 -3
  38. package/dist/requestContext.d.ts +61 -0
  39. package/dist/requestContext.js +74 -0
  40. package/dist/secretProviders.test.js +335 -0
  41. package/dist/syncConsents.test.js +2 -2
  42. package/dist/terrenoApp.d.ts +27 -15
  43. package/dist/terrenoApp.js +24 -14
  44. package/dist/terrenoApp.test.js +52 -0
  45. package/dist/tests/bunSetup.js +66 -262
  46. package/dist/tests/createTestData.d.ts +9 -0
  47. package/dist/tests/createTestData.js +272 -0
  48. package/dist/tests/models.d.ts +71 -0
  49. package/dist/tests/models.js +134 -0
  50. package/dist/tests/mongoTestSetup.d.ts +7 -0
  51. package/dist/tests/mongoTestSetup.js +150 -0
  52. package/dist/tests/testEnv.d.ts +0 -0
  53. package/dist/tests/testEnv.js +6 -0
  54. package/dist/tests/testHelper.d.ts +22 -0
  55. package/dist/tests/testHelper.js +115 -0
  56. package/dist/tests/types.d.ts +29 -0
  57. package/dist/tests/types.js +2 -0
  58. package/dist/tests.d.ts +10 -78
  59. package/dist/tests.js +24 -241
  60. package/dist/transformers.test.js +14 -50
  61. package/package.json +18 -4
  62. package/src/__snapshots__/openApiBuilder.test.ts.snap +1 -0
  63. package/src/__tests__/versionCheckPlugin.test.ts +43 -15
  64. package/src/actions.openApi.test.ts +12 -10
  65. package/src/api.query.test.ts +24 -1
  66. package/src/api.test.ts +169 -0
  67. package/src/api.ts +71 -0
  68. package/src/auth.test.ts +287 -39
  69. package/src/betterAuth.ts +1 -1
  70. package/src/consentApp.test.ts +1 -0
  71. package/src/example.ts +4 -4
  72. package/src/expressServer.test.ts +82 -85
  73. package/src/expressServer.ts +1 -213
  74. package/src/githubAuth.test.ts +22 -22
  75. package/src/logger.test.ts +466 -1
  76. package/src/logger.ts +477 -14
  77. package/src/middleware.test.ts +74 -2
  78. package/src/middleware.ts +57 -0
  79. package/src/models/consentForm.ts +3 -4
  80. package/src/models/consentResponse.ts +6 -4
  81. package/src/models/versionConfig.ts +3 -4
  82. package/src/openApi.test.ts +10 -17
  83. package/src/openApiBuilder.test.ts +27 -10
  84. package/src/openApiBuilder.ts +24 -0
  85. package/src/permissions.test.ts +8 -23
  86. package/src/populate.test.ts +7 -22
  87. package/src/realtime/changeStreamWatcher.ts +15 -10
  88. package/src/realtime/queryMatcher.ts +54 -27
  89. package/src/realtime/types.ts +4 -4
  90. package/src/requestContext.ts +86 -0
  91. package/src/secretProviders.test.ts +219 -1
  92. package/src/syncConsents.test.ts +1 -1
  93. package/src/terrenoApp.test.ts +38 -0
  94. package/src/terrenoApp.ts +37 -15
  95. package/src/tests/bunSetup.ts +22 -236
  96. package/src/tests/createTestData.ts +176 -0
  97. package/src/tests/models.ts +164 -0
  98. package/src/tests/mongoTestSetup.ts +69 -0
  99. package/src/tests/testEnv.ts +4 -0
  100. package/src/tests/testHelper.ts +57 -0
  101. package/src/tests/types.ts +35 -0
  102. package/src/tests.ts +40 -231
  103. package/src/transformers.test.ts +11 -30
  104. package/tsconfig.typedoc.json +4 -0
  105. package/dist/tests/index.d.ts +0 -1
  106. package/dist/tests/index.js +0 -17
  107. package/src/tests/index.ts +0 -1
@@ -3,8 +3,6 @@ import {beforeEach, describe, expect, it} from "bun:test";
3
3
  import type express from "express";
4
4
  import supertest from "supertest";
5
5
  import {type ModelRouterOptions, modelRouter} from "./api";
6
- import {addAuthRoutes, setupAuth} from "./auth";
7
- import {setupServer} from "./expressServer";
8
6
  import {Permissions} from "./permissions";
9
7
  import {TerrenoApp} from "./terrenoApp";
10
8
  import {FoodModel, setupDb, UserModel} from "./tests";
@@ -57,6 +55,8 @@ const primeActionOpenApiRoutes = async (
57
55
  };
58
56
 
59
57
  const assertActionOpenApiSpec = (spec: Record<string, unknown>): void => {
58
+ expect(spec.requestId).toBeUndefined();
59
+
60
60
  const paths = spec.paths as Record<string, Record<string, unknown>>;
61
61
  const collectionPath = paths["/food/summarize"];
62
62
  const instancePath = paths["/food/{id}/ping"];
@@ -139,14 +139,18 @@ describe("action OpenAPI emission", () => {
139
139
 
140
140
  const specRes = await server.get("/openapi.json").expect(200);
141
141
  assertActionOpenApiSpec(specRes.body);
142
+
143
+ const pingRes = await server.get(`/food/${foodId}/ping`).expect(200);
144
+ expect(pingRes.body.data).toEqual({id: foodId});
145
+ expect(pingRes.body.requestId).toBe(pingRes.headers["x-request-id"]);
142
146
  });
143
147
  });
144
148
 
145
- describe("setupServer", () => {
149
+ describe("configureApp", () => {
146
150
  let app: express.Application;
147
151
 
148
152
  beforeEach(() => {
149
- const addRoutes = (
153
+ const configureApp = (
150
154
  router: express.Router,
151
155
  routerOptions?: Partial<ModelRouterOptions<unknown>>
152
156
  ): void => {
@@ -156,16 +160,14 @@ describe("action OpenAPI emission", () => {
156
160
  );
157
161
  };
158
162
 
159
- app = setupServer({
160
- addRoutes,
163
+ app = new TerrenoApp({
164
+ configureApp,
161
165
  skipListen: true,
162
166
  userModel: UserModel as any,
163
- });
164
- setupAuth(app, UserModel as any);
165
- addAuthRoutes(app, UserModel as any);
167
+ }).build();
166
168
  });
167
169
 
168
- it("emits the same action operations on first hit via legacy setupServer", async () => {
170
+ it("emits the same action operations on first hit via configureApp", async () => {
169
171
  const server = supertest(app);
170
172
  await primeActionOpenApiRoutes(server, foodId);
171
173
 
@@ -95,7 +95,17 @@ describe("query and list methods", () => {
95
95
  update: [Permissions.IsOwner],
96
96
  },
97
97
  populatePaths: [{path: "ownerId"}],
98
- queryFields: ["hidden", "name", "calories", "created", "source.name", "tags", "eatenBy"],
98
+ queryFields: [
99
+ "hidden",
100
+ "name",
101
+ "calories",
102
+ "created",
103
+ "created_gte",
104
+ "created_lte",
105
+ "source.name",
106
+ "tags",
107
+ "eatenBy",
108
+ ],
99
109
  sort: {created: "descending"},
100
110
  })
101
111
  );
@@ -190,6 +200,19 @@ describe("query and list methods", () => {
190
200
  expect(res.body.data[0].id).toBe((apple as any).id);
191
201
  });
192
202
 
203
+ it("list applies created_gte and created_lte as a Date range", async () => {
204
+ const res = await agent
205
+ .get("/food")
206
+ .query({
207
+ created_gte: "2021-12-03T00:00:05.000Z",
208
+ created_lte: "2021-12-03T00:00:25.000Z",
209
+ limit: 10,
210
+ })
211
+ .expect(200);
212
+ const names = (res.body.data as {name: string}[]).map((d) => d.name).sort();
213
+ expect(names).toEqual(["Pizza", "Spinach"]);
214
+ });
215
+
193
216
  it("list query params not in list", async () => {
194
217
  const res = await agent.get(`/food?ownerId=${admin._id}`).expect(400);
195
218
  expect(res.body.title).toBe("ownerId is not allowed as a query param.");
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);