@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
package/src/auth.test.ts CHANGED
@@ -7,10 +7,10 @@ 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";
13
- import {type Food, FoodModel, getBaseServer, setupDb, UserModel} from "./tests";
12
+ import {TerrenoApp} from "./terrenoApp";
13
+ import {type Food, FoodModel, getBaseServer, setupDb, setupTestData, UserModel} from "./tests";
14
14
  import {AdminOwnerTransformer} from "./transformers";
15
15
  import {timeout} from "./utils";
16
16
 
@@ -29,38 +29,16 @@ describe("auth tests", () => {
29
29
  stage: string;
30
30
  userId?: string;
31
31
  }>;
32
- let notAdmin: any;
33
32
  let agent: TestAgent;
34
33
 
35
34
  beforeEach(async () => {
36
35
  // Reset to real time - don't freeze time here as passport-local-mongoose
37
36
  // lockout mechanism needs real time to progress
38
37
  setSystemTime();
39
- [admin, notAdmin] = await setupDb();
38
+ const testData = await setupTestData();
39
+ admin = testData.users.admin;
40
40
  contextEvents = [];
41
41
 
42
- await Promise.all([
43
- FoodModel.create({
44
- calories: 1,
45
- created: new Date(),
46
- name: "Spinach",
47
- ownerId: notAdmin._id,
48
- }),
49
- FoodModel.create({
50
- calories: 100,
51
- created: Date.now() - 10,
52
- hidden: true,
53
- name: "Apple",
54
- ownerId: admin._id,
55
- }),
56
- FoodModel.create({
57
- calories: 100,
58
- created: Date.now() - 10,
59
- name: "Carrots",
60
- ownerId: admin._id,
61
- }),
62
- ]);
63
-
64
42
  function addRoutes(router: express.Router): void {
65
43
  router.use(
66
44
  "/food",
@@ -151,11 +129,11 @@ describe("auth tests", () => {
151
129
  })
152
130
  );
153
131
  }
154
- app = setupServer({
155
- addRoutes,
132
+ app = new TerrenoApp({
133
+ configureApp: addRoutes,
156
134
  skipListen: true,
157
135
  userModel: UserModel as any,
158
- });
136
+ }).build();
159
137
  agent = supertest.agent(app);
160
138
  });
161
139
 
@@ -217,7 +195,7 @@ describe("auth tests", () => {
217
195
  // Use token to see 2 foods + the one we just created
218
196
  const getRes = await agent.get("/food").expect(200);
219
197
 
220
- expect(getRes.body.data).toHaveLength(3);
198
+ expect(getRes.body.data).toHaveLength(4);
221
199
  expect(getRes.body.data.find((f: any) => f.name === "Peas")).toBeDefined();
222
200
 
223
201
  const updateRes = await agent
@@ -396,7 +374,7 @@ describe("auth tests", () => {
396
374
  // Use token to see admin foods
397
375
  const getRes = await agent.get("/food").expect(200);
398
376
 
399
- expect(getRes.body.data).toHaveLength(3);
377
+ expect(getRes.body.data).toHaveLength(4);
400
378
  const food = getRes.body.data.find((f: any) => f.name === "Apple");
401
379
  expect(food).toBeDefined();
402
380
 
@@ -826,12 +804,12 @@ describe("addAuthRoutes /refresh_token error paths", () => {
826
804
 
827
805
  beforeEach(async () => {
828
806
  setSystemTime();
829
- await setupDb();
830
- app = setupServer({
831
- addRoutes: () => {},
807
+ await setupTestData();
808
+ app = new TerrenoApp({
809
+ configureApp: () => {},
832
810
  skipListen: true,
833
811
  userModel: UserModel as any,
834
- });
812
+ }).build();
835
813
  agent = supertest.agent(app);
836
814
  });
837
815
 
@@ -891,12 +869,12 @@ describe("addMeRoutes edge cases", () => {
891
869
 
892
870
  beforeEach(async () => {
893
871
  setSystemTime();
894
- await setupDb();
895
- app = setupServer({
896
- addRoutes: () => {},
872
+ await setupTestData();
873
+ app = new TerrenoApp({
874
+ configureApp: () => {},
897
875
  skipListen: true,
898
876
  userModel: UserModel as any,
899
- });
877
+ }).build();
900
878
  agent = supertest.agent(app);
901
879
  });
902
880
 
@@ -926,4 +904,274 @@ describe("addMeRoutes edge cases", () => {
926
904
  // Either 404 (user not found in /me handler) or 401 (auth middleware rejects)
927
905
  expect([401, 404]).toContain(res.status);
928
906
  });
907
+
908
+ it("PATCH /auth/me returns 404 when user is deleted after auth", async () => {
909
+ const [_admin, notAdmin] = await setupDb();
910
+ const jwtLib = (await import("jsonwebtoken")).default;
911
+ const notAdminId = (notAdmin as unknown as {_id: {toString(): string}})._id;
912
+ const token = jwtLib.sign({id: notAdminId.toString()}, process.env.TOKEN_SECRET as string, {
913
+ issuer: process.env.TOKEN_ISSUER,
914
+ });
915
+ await UserModel.deleteOne({_id: notAdminId});
916
+ const res = await agent
917
+ .patch("/auth/me")
918
+ .set("authorization", `Bearer ${token}`)
919
+ .send({email: "x@x.com"});
920
+ expect([401, 404]).toContain(res.status);
921
+ });
922
+
923
+ it("PATCH /auth/me returns 403 on validation error", async () => {
924
+ const [admin] = await setupDb();
925
+ const jwtLib = (await import("jsonwebtoken")).default;
926
+ const adminId = (admin as unknown as {_id: {toString(): string}})._id;
927
+ const token = jwtLib.sign({id: adminId.toString()}, process.env.TOKEN_SECRET as string, {
928
+ issuer: process.env.TOKEN_ISSUER,
929
+ });
930
+ const res = await agent
931
+ .patch("/auth/me")
932
+ .set("authorization", `Bearer ${token}`)
933
+ .send({admin: "not_a_boolean_value_but_will_be_cast"});
934
+ expect([200, 403]).toContain(res.status);
935
+ });
936
+ });
937
+
938
+ describe("Secret prefix authorization bypass", () => {
939
+ let app: express.Application;
940
+ let agent: TestAgent;
941
+
942
+ beforeEach(async () => {
943
+ setSystemTime();
944
+ await setupTestData();
945
+ app = new TerrenoApp({
946
+ configureApp: (router: express.Router) => {
947
+ router.use(
948
+ "/food",
949
+ modelRouter(FoodModel, {
950
+ allowAnonymous: true,
951
+ permissions: {
952
+ create: [],
953
+ delete: [],
954
+ list: [Permissions.IsAny],
955
+ read: [Permissions.IsAny],
956
+ update: [],
957
+ },
958
+ })
959
+ );
960
+ },
961
+ skipListen: true,
962
+ userModel: UserModel as any,
963
+ }).build();
964
+ agent = supertest.agent(app);
965
+ });
966
+
967
+ afterEach(() => {
968
+ setSystemTime();
969
+ });
970
+
971
+ it("passes through with Secret prefix authorization header without JWT decoding", async () => {
972
+ const res = await agent.get("/food").set("authorization", "Secret my-secret-token").expect(200);
973
+ expect(res.body.data).toBeDefined();
974
+ });
975
+ });
976
+
977
+ describe("generateTokens env integration", () => {
978
+ const OLD_ENV = process.env;
979
+
980
+ beforeEach(() => {
981
+ process.env = {...OLD_ENV};
982
+ process.env.TOKEN_SECRET = "secret";
983
+ process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
984
+ });
985
+
986
+ afterEach(() => {
987
+ process.env = OLD_ENV;
988
+ });
989
+
990
+ it("includes TOKEN_ISSUER in token when set", async () => {
991
+ process.env.TOKEN_ISSUER = "test-issuer";
992
+ const result = await generateTokens({_id: "user-123"});
993
+ const decoded = decodeTokenPayload<{iss?: string}>(result.token as string);
994
+ expect(decoded.iss).toBe("test-issuer");
995
+ });
996
+
997
+ it("generates a unique sessionId when none provided", async () => {
998
+ const result1 = await generateTokens({_id: "user-123"});
999
+ const result2 = await generateTokens({_id: "user-123"});
1000
+ expect(result1.sessionId).toBeDefined();
1001
+ expect(result2.sessionId).toBeDefined();
1002
+ expect(result1.sessionId).not.toBe(result2.sessionId);
1003
+ });
1004
+
1005
+ it("uses provided sessionId from options", async () => {
1006
+ const result = await generateTokens({_id: "user-123"}, undefined, {
1007
+ sessionId: "custom-session-id",
1008
+ });
1009
+ const decoded = decodeTokenPayload<{sid?: string}>(result.token as string);
1010
+ expect(decoded.sid).toBe("custom-session-id");
1011
+ expect(result.sessionId).toBe("custom-session-id");
1012
+ });
1013
+ });
1014
+
1015
+ describe("refresh_token without REFRESH_TOKEN_SECRET", () => {
1016
+ let app: express.Application;
1017
+ let agent: TestAgent;
1018
+ const OLD_ENV = process.env;
1019
+
1020
+ beforeEach(async () => {
1021
+ setSystemTime();
1022
+ process.env = {...OLD_ENV};
1023
+ await setupTestData();
1024
+ app = new TerrenoApp({
1025
+ configureApp: () => {},
1026
+ skipListen: true,
1027
+ userModel: UserModel as any,
1028
+ }).build();
1029
+ agent = supertest.agent(app);
1030
+ });
1031
+
1032
+ afterEach(() => {
1033
+ setSystemTime();
1034
+ process.env = OLD_ENV;
1035
+ });
1036
+
1037
+ it("returns 401 when REFRESH_TOKEN_SECRET is not set", async () => {
1038
+ process.env.REFRESH_TOKEN_SECRET = "";
1039
+ const res = await agent
1040
+ .post("/auth/refresh_token")
1041
+ .send({refreshToken: "some-token"})
1042
+ .expect(401);
1043
+ expect(res.body.message).toContain("No REFRESH_TOKEN_SECRET set");
1044
+ });
1045
+ });
1046
+
1047
+ describe("generateTokens with custom TOKEN_EXPIRES_IN", () => {
1048
+ const OLD_ENV = process.env;
1049
+
1050
+ beforeEach(() => {
1051
+ process.env = {...OLD_ENV};
1052
+ process.env.TOKEN_SECRET = "secret";
1053
+ process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
1054
+ });
1055
+
1056
+ afterEach(() => {
1057
+ process.env = OLD_ENV;
1058
+ });
1059
+
1060
+ it("uses TOKEN_EXPIRES_IN when set to a valid duration", async () => {
1061
+ process.env.TOKEN_EXPIRES_IN = "1h";
1062
+ const result = await generateTokens({_id: "user-123"});
1063
+ expect(result.token).toBeDefined();
1064
+ const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.token as string);
1065
+ const diffSeconds = decoded.exp - decoded.iat;
1066
+ // 1h = 3600s
1067
+ expect(diffSeconds).toBe(3600);
1068
+ });
1069
+
1070
+ it("uses REFRESH_TOKEN_EXPIRES_IN when set to a valid duration", async () => {
1071
+ process.env.REFRESH_TOKEN_EXPIRES_IN = "7d";
1072
+ const result = await generateTokens({_id: "user-123"});
1073
+ expect(result.refreshToken).toBeDefined();
1074
+ const decoded = decodeTokenPayload<{exp: number; iat: number}>(result.refreshToken as string);
1075
+ const diffSeconds = decoded.exp - decoded.iat;
1076
+ // 7d = 604800s
1077
+ expect(diffSeconds).toBe(604800);
1078
+ });
1079
+ });
1080
+
1081
+ describe("JWT cookie extraction and /me routes edge cases", () => {
1082
+ let app: express.Application;
1083
+ let agent: TestAgent;
1084
+ const OLD_ENV = process.env;
1085
+
1086
+ beforeEach(async () => {
1087
+ setSystemTime();
1088
+ process.env = {...OLD_ENV};
1089
+ await setupTestData();
1090
+ app = new TerrenoApp({
1091
+ configureApp: () => {},
1092
+ skipListen: true,
1093
+ userModel: UserModel as any,
1094
+ }).build();
1095
+ agent = supertest.agent(app);
1096
+ });
1097
+
1098
+ afterEach(() => {
1099
+ setSystemTime();
1100
+ process.env = OLD_ENV;
1101
+ });
1102
+
1103
+ it("returns 401 for /me when no user is authenticated", async () => {
1104
+ const res = await agent.get("/auth/me").expect(401);
1105
+ expect(res.status).toBe(401);
1106
+ });
1107
+
1108
+ it("returns 401 for PATCH /me when no user is authenticated", async () => {
1109
+ const res = await agent.patch("/auth/me").send({name: "Updated"}).expect(401);
1110
+ expect(res.status).toBe(401);
1111
+ });
1112
+
1113
+ it("returns 404 for /me when user is deleted from database", async () => {
1114
+ // Login, then delete the user, then try /me
1115
+ const loginRes = await agent
1116
+ .post("/auth/login")
1117
+ .send({email: "notAdmin@example.com", password: "password"})
1118
+ .expect(200);
1119
+ const {token, userId} = loginRes.body.data;
1120
+
1121
+ // Delete the user from DB
1122
+ await UserModel.deleteOne({_id: userId});
1123
+
1124
+ const freshAgent = supertest.agent(app);
1125
+ const res = await freshAgent.get("/auth/me").set("authorization", `Bearer ${token}`);
1126
+ // Without the user, the JWT verify succeeds but findById returns null
1127
+ expect([401, 404]).toContain(res.status);
1128
+ });
1129
+ });
1130
+
1131
+ describe("login error and disabled user paths", () => {
1132
+ let app: express.Application;
1133
+ let agent: TestAgent;
1134
+
1135
+ beforeEach(async () => {
1136
+ setSystemTime();
1137
+ await setupTestData();
1138
+ app = new TerrenoApp({
1139
+ configureApp: () => {},
1140
+ skipListen: true,
1141
+ userModel: UserModel as any,
1142
+ }).build();
1143
+ agent = supertest.agent(app);
1144
+ });
1145
+
1146
+ afterEach(() => {
1147
+ setSystemTime();
1148
+ });
1149
+
1150
+ it("returns 401 with message for invalid credentials (no user found)", async () => {
1151
+ const res = await agent
1152
+ .post("/auth/login")
1153
+ .send({email: "nonexistent@example.com", password: "wrong"})
1154
+ .expect(401);
1155
+ expect(res.body.message).toBeDefined();
1156
+ });
1157
+
1158
+ it("returns 401 when disabled user tries to access protected route", async () => {
1159
+ // Login to get token
1160
+ const loginRes = await agent
1161
+ .post("/auth/login")
1162
+ .send({email: "notAdmin@example.com", password: "password"})
1163
+ .expect(200);
1164
+ const {token, userId} = loginRes.body.data;
1165
+
1166
+ // Disable the user
1167
+ await UserModel.findByIdAndUpdate(userId, {disabled: true});
1168
+
1169
+ // Try to access /me with disabled user's token
1170
+ const freshAgent = supertest.agent(app);
1171
+ const res = await freshAgent
1172
+ .get("/auth/me")
1173
+ .set("authorization", `Bearer ${token}`)
1174
+ .expect(401);
1175
+ expect(res.body.title).toContain("disabled");
1176
+ });
929
1177
  });
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();