@terreno/api 0.20.2 → 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.
- package/.ai/guidelines/core.md +71 -0
- package/.ai/skills/mongoose-schema-safety/SKILL.md +143 -0
- package/README.md +54 -1
- package/dist/__tests__/versionCheckPlugin.test.js +29 -7
- package/dist/actions.openApi.test.js +13 -11
- package/dist/api.js +98 -11
- package/dist/api.query.test.js +31 -1
- package/dist/api.test.js +211 -0
- package/dist/auth.test.js +10 -10
- package/dist/betterAuth.d.ts +1 -1
- package/dist/consentApp.test.js +1 -0
- package/dist/example.js +4 -4
- package/dist/expressServer.d.ts +0 -22
- package/dist/expressServer.js +1 -125
- package/dist/expressServer.test.js +90 -91
- package/dist/githubAuth.test.js +22 -22
- package/dist/logger.d.ts +154 -0
- package/dist/logger.js +445 -26
- package/dist/logger.test.js +435 -0
- package/dist/middleware.d.ts +7 -0
- package/dist/middleware.js +58 -1
- package/dist/middleware.test.js +159 -0
- package/dist/openApi.test.js +10 -17
- package/dist/openApiBuilder.test.js +18 -10
- package/dist/realtime/changeStreamWatcher.d.ts +4 -4
- package/dist/realtime/changeStreamWatcher.js +2 -4
- package/dist/realtime/queryMatcher.d.ts +1 -1
- package/dist/realtime/queryMatcher.js +39 -14
- package/dist/realtime/types.d.ts +3 -3
- package/dist/requestContext.d.ts +61 -0
- package/dist/requestContext.js +74 -0
- package/dist/secretProviders.test.js +335 -0
- package/dist/terrenoApp.d.ts +27 -15
- package/dist/terrenoApp.js +24 -14
- package/dist/terrenoApp.test.js +52 -0
- package/dist/tests/bunSetup.js +61 -7
- package/dist/tests.js +27 -4
- package/package.json +1 -1
- package/src/__tests__/versionCheckPlugin.test.ts +43 -15
- package/src/actions.openApi.test.ts +12 -10
- package/src/api.query.test.ts +24 -1
- package/src/api.test.ts +169 -0
- package/src/api.ts +71 -0
- package/src/auth.test.ts +10 -10
- package/src/betterAuth.ts +1 -1
- package/src/consentApp.test.ts +1 -0
- package/src/example.ts +4 -4
- package/src/expressServer.test.ts +82 -85
- package/src/expressServer.ts +1 -213
- package/src/githubAuth.test.ts +22 -22
- package/src/logger.test.ts +466 -1
- package/src/logger.ts +477 -14
- package/src/middleware.test.ts +74 -2
- package/src/middleware.ts +57 -0
- package/src/openApi.test.ts +10 -17
- package/src/openApiBuilder.test.ts +18 -10
- package/src/realtime/changeStreamWatcher.ts +15 -10
- package/src/realtime/queryMatcher.ts +54 -27
- package/src/realtime/types.ts +4 -4
- package/src/requestContext.ts +86 -0
- package/src/secretProviders.test.ts +219 -1
- package/src/terrenoApp.test.ts +38 -0
- package/src/terrenoApp.ts +37 -15
- package/src/tests/bunSetup.ts +16 -3
- 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 =
|
|
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 =
|
|
831
|
-
|
|
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 =
|
|
896
|
-
|
|
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
|
|
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
|
*/
|
package/src/consentApp.test.ts
CHANGED
|
@@ -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
|
|
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();
|