@terreno/api 0.0.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.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
@@ -0,0 +1,219 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import type express from "express";
3
+ import supertest from "supertest";
4
+ import type TestAgent from "supertest/lib/agent";
5
+
6
+ import {modelRouter} from "./api";
7
+ import {addAuthRoutes, setupAuth} from "./auth";
8
+ import {Permissions} from "./permissions";
9
+ import {
10
+ authAsUser,
11
+ type Food,
12
+ FoodModel,
13
+ getBaseServer,
14
+ RequiredModel,
15
+ setupDb,
16
+ UserModel,
17
+ } from "./tests";
18
+
19
+ describe("permissions", () => {
20
+ let server: TestAgent;
21
+ let app: express.Application;
22
+
23
+ beforeEach(async () => {
24
+ process.env.REFRESH_TOKEN_SECRET = "testsecret1234";
25
+
26
+ const [admin, notAdmin] = await setupDb();
27
+
28
+ await Promise.all([
29
+ FoodModel.create({
30
+ calories: 1,
31
+ created: new Date(),
32
+ name: "Spinach",
33
+ ownerId: notAdmin._id,
34
+ }),
35
+ FoodModel.create({
36
+ calories: 100,
37
+ created: Date.now() - 10,
38
+ name: "Apple",
39
+ ownerId: admin._id,
40
+ }),
41
+ ]);
42
+ app = getBaseServer();
43
+ setupAuth(app, UserModel as any);
44
+ addAuthRoutes(app, UserModel as any);
45
+ app.use(
46
+ "/food",
47
+ modelRouter(FoodModel, {
48
+ allowAnonymous: true,
49
+ permissions: {
50
+ create: [Permissions.IsAuthenticated],
51
+ delete: [Permissions.IsAdmin],
52
+ list: [Permissions.IsAny],
53
+ read: [Permissions.IsAny],
54
+ update: [Permissions.IsOwner],
55
+ },
56
+ })
57
+ );
58
+ app.use(
59
+ "/required",
60
+ modelRouter(RequiredModel, {
61
+ permissions: {
62
+ create: [Permissions.IsAuthenticated],
63
+ delete: [Permissions.IsAdmin],
64
+ list: [Permissions.IsAny],
65
+ read: [Permissions.IsAny],
66
+ update: [Permissions.IsOwner],
67
+ },
68
+ })
69
+ );
70
+ server = supertest(app);
71
+ });
72
+
73
+ describe("anonymous food", () => {
74
+ it("list", async () => {
75
+ const res = await server.get("/food").expect(200);
76
+ expect(res.body.data).toHaveLength(2);
77
+ });
78
+
79
+ it("get", async () => {
80
+ const res = await server.get("/food").expect(200);
81
+ expect(res.body.data).toHaveLength(2);
82
+ const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
83
+ expect(res.body.data[0]._id).toBe(res2.body.data._id);
84
+ });
85
+
86
+ it("post", async () => {
87
+ const res = await server.post("/food").send({
88
+ calories: 15,
89
+ name: "Broccoli",
90
+ });
91
+ expect(res.status).toBe(405);
92
+ });
93
+
94
+ it("patch", async () => {
95
+ const res = await server.get("/food");
96
+ const res2 = await server.patch(`/food/${res.body.data[0]._id}`).send({
97
+ name: "Broccoli",
98
+ });
99
+ expect(res2.status).toBe(403);
100
+ });
101
+
102
+ it("delete", async () => {
103
+ const res = await server.get("/food");
104
+ const res2 = await server.delete(`/food/${res.body.data[0]._id}`);
105
+ expect(res2.status).toBe(405);
106
+ });
107
+ });
108
+
109
+ describe("non admin food", () => {
110
+ let agent: TestAgent;
111
+
112
+ beforeEach(async () => {
113
+ agent = await authAsUser(app, "notAdmin");
114
+ });
115
+
116
+ it("list", async () => {
117
+ const res = await agent.get("/food").expect(200);
118
+ expect(res.body.data).toHaveLength(2);
119
+ });
120
+
121
+ it("get", async () => {
122
+ const res = await agent.get("/food").expect(200);
123
+ expect(res.body.data).toHaveLength(2);
124
+ const res2 = await server.get(`/food/${res.body.data[0]._id}`).expect(200);
125
+ expect(res.body.data[0]._id).toBe(res2.body.data._id);
126
+ });
127
+
128
+ it("post", async () => {
129
+ await agent
130
+ .post("/food")
131
+ .send({
132
+ calories: 15,
133
+ name: "Broccoli",
134
+ })
135
+ .expect(201);
136
+ });
137
+
138
+ it("patch own item", async () => {
139
+ const res = await agent.get("/food");
140
+ const spinach = res.body.data.find((food: Food) => food.name === "Spinach");
141
+ const res2 = await agent
142
+ .patch(`/food/${spinach._id}`)
143
+ .send({
144
+ name: "Broccoli",
145
+ })
146
+ .expect(200);
147
+ expect(res2.body.data.name).toBe("Broccoli");
148
+ });
149
+
150
+ it("patch other item", async () => {
151
+ const res = await agent.get("/food");
152
+ const spinach = res.body.data.find((food: Food) => food.name === "Apple");
153
+ await agent
154
+ .patch(`/food/${spinach._id}`)
155
+ .send({
156
+ name: "Broccoli",
157
+ })
158
+ .expect(403);
159
+ });
160
+
161
+ it("delete", async () => {
162
+ const res = await agent.get("/food");
163
+ const res2 = await agent.delete(`/food/${res.body.data[0]._id}`);
164
+ expect(res2.status).toBe(405);
165
+ });
166
+ });
167
+
168
+ describe("admin food", () => {
169
+ let agent: TestAgent;
170
+
171
+ beforeEach(async () => {
172
+ agent = await authAsUser(app, "admin");
173
+ });
174
+
175
+ it("list", async () => {
176
+ const res = await agent.get("/food");
177
+ expect(res.body.data).toHaveLength(2);
178
+ });
179
+
180
+ it("get", async () => {
181
+ const res = await agent.get("/food");
182
+ expect(res.body.data).toHaveLength(2);
183
+ const res2 = await agent.get(`/food/${res.body.data[0]._id}`);
184
+ expect(res.body.data[0]._id).toBe(res2.body.data._id);
185
+ });
186
+
187
+ it("post", async () => {
188
+ const res = await agent.post("/food").send({
189
+ calories: 15,
190
+ name: "Broccoli",
191
+ });
192
+ expect(res.status).toBe(201);
193
+ });
194
+
195
+ it("patch", async () => {
196
+ const res = await agent.get("/food");
197
+ await agent
198
+ .patch(`/food/${res.body.data[0]._id}`)
199
+ .send({
200
+ name: "Broccoli",
201
+ })
202
+ .expect(200);
203
+ });
204
+
205
+ it("delete", async () => {
206
+ const res = await agent.get("/food");
207
+ await agent.delete(`/food/${res.body.data[0]._id}`).expect(204);
208
+ });
209
+
210
+ it("handles validation errors", async () => {
211
+ await agent
212
+ .post("/required")
213
+ .send({
214
+ about: "Whoops forgot required",
215
+ })
216
+ .expect(400);
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,228 @@
1
+ // Defaults closed
2
+ import * as Sentry from "@sentry/node";
3
+ import type express from "express";
4
+ import type {NextFunction} from "express";
5
+ import mongoose, {type Model} from "mongoose";
6
+
7
+ import {addPopulateToQuery, getModel, type modelRouterOptions, type RESTMethod} from "./api";
8
+ import type {User} from "./auth";
9
+ import {APIError} from "./errors";
10
+ import {logger} from "./logger";
11
+
12
+ export type PermissionMethod<T> = (
13
+ method: RESTMethod,
14
+ user?: User,
15
+ obj?: T
16
+ ) => boolean | Promise<boolean>;
17
+
18
+ export interface RESTPermissions<T> {
19
+ create: PermissionMethod<T>[];
20
+ list: PermissionMethod<T>[];
21
+ read: PermissionMethod<T>[];
22
+ update: PermissionMethod<T>[];
23
+ delete: PermissionMethod<T>[];
24
+ }
25
+
26
+ export const OwnerQueryFilter = (user?: User) => {
27
+ if (user) {
28
+ return {ownerId: user?.id};
29
+ }
30
+ // Return a null, so we know to return no results.
31
+ return null;
32
+ };
33
+
34
+ export const Permissions = {
35
+ IsAdmin: (_method: RESTMethod, user?: User) => {
36
+ return Boolean(user?.admin);
37
+ },
38
+ IsAny: () => {
39
+ return true;
40
+ },
41
+ IsAuthenticated: (_method: RESTMethod, user?: User) => {
42
+ if (!user) {
43
+ return false;
44
+ }
45
+ return Boolean(user.id);
46
+ },
47
+ IsAuthenticatedOrReadOnly: (method: RESTMethod, user?: User) => {
48
+ if (user?.id && !user?.isAnonymous) {
49
+ return true;
50
+ }
51
+ return method === "list" || method === "read";
52
+ },
53
+ IsOwner: (_method: RESTMethod, user?: User, obj?: any) => {
54
+ // When checking if we can possibly perform the action, return true.
55
+ if (!obj) {
56
+ return true;
57
+ }
58
+ if (!user) {
59
+ return false;
60
+ }
61
+ if (user?.admin) {
62
+ return true;
63
+ }
64
+ const ownerId = obj?.ownerId?._id || obj?.ownerId;
65
+ return user?.id && ownerId && String(ownerId) === String(user?.id);
66
+ },
67
+ IsOwnerOrReadOnly: (method: RESTMethod, user?: User, obj?: any) => {
68
+ // When checking if we can possibly perform the action, return true.
69
+ if (!obj) {
70
+ return true;
71
+ }
72
+ if (user?.admin) {
73
+ return true;
74
+ }
75
+
76
+ if (user?.id && obj?.ownerId && String(obj?.ownerId) === String(user?.id)) {
77
+ return true;
78
+ }
79
+ return method === "list" || method === "read";
80
+ },
81
+ };
82
+
83
+ export async function checkPermissions<T>(
84
+ method: RESTMethod,
85
+ permissions: PermissionMethod<T>[],
86
+ user?: User,
87
+ obj?: T
88
+ ): Promise<boolean> {
89
+ let anyTrue = false;
90
+ for (const perm of permissions) {
91
+ // May or may not be a promise.
92
+ if (!(await perm(method, user, obj))) {
93
+ return false;
94
+ }
95
+ anyTrue = true;
96
+ }
97
+ return anyTrue;
98
+ }
99
+
100
+ // Check the permissions for a given model and method. If the method is a read, update, or delete,
101
+ // finds the relevant object, checks the permissions, and attaches the object to the request as
102
+ // req.obj.
103
+ export function permissionMiddleware<T>(
104
+ baseModel: Model<T>,
105
+ options: Pick<modelRouterOptions<T>, "permissions" | "populatePaths" | "discriminatorKey">
106
+ ) {
107
+ return async (req: express.Request, _res: express.Response, next: NextFunction) => {
108
+ if (req.method === "OPTIONS") {
109
+ return next();
110
+ }
111
+ try {
112
+ let method: "list" | "create" | "read" | "update" | "delete";
113
+
114
+ const reqMethod = req.method.toLowerCase();
115
+ if (reqMethod === "post") {
116
+ method = "create";
117
+ } else if (reqMethod === "get") {
118
+ if (req.params.id) {
119
+ method = "read";
120
+ } else {
121
+ method = "list";
122
+ }
123
+ } else if (reqMethod === "patch") {
124
+ method = "update";
125
+ } else if (reqMethod === "delete") {
126
+ method = "delete";
127
+ } else {
128
+ throw new APIError({
129
+ status: 405,
130
+ title: `Method ${req.method} not allowed`,
131
+ });
132
+ }
133
+
134
+ const model = getModel(baseModel, req.body, options);
135
+
136
+ // All methods check for permissions.
137
+ if (!(await checkPermissions(method, options.permissions[method], req.user))) {
138
+ throw new APIError({
139
+ status: 405,
140
+ title:
141
+ `Access to ${method.toUpperCase()} on ${model.modelName} ` +
142
+ `denied for ${req.user?.id}`,
143
+ });
144
+ }
145
+
146
+ if (method === "create" || method === "list") {
147
+ return next();
148
+ }
149
+
150
+ const builtQuery = model.findById(req.params.id);
151
+ const populatedQuery = addPopulateToQuery(builtQuery as any, options.populatePaths);
152
+ let data;
153
+ try {
154
+ data = await populatedQuery.exec();
155
+ } catch (error: any) {
156
+ throw new APIError({
157
+ error,
158
+ status: 500,
159
+ title: `GET failed on ${req.params.id}`,
160
+ });
161
+ }
162
+ if (!data || (["update", "delete"].includes(method) && data?.__t && !req.body?.__t)) {
163
+ // For discriminated models, return 404 without checking hidden state
164
+ if (["update", "delete"].includes(method) && data?.__t && !req.body?.__t) {
165
+ throw new APIError({
166
+ status: 404,
167
+ title: `Document ${req.params.id} not found for model ${model.modelName}`,
168
+ });
169
+ }
170
+
171
+ // Check if document exists but is hidden. Completely skip plugins.
172
+ const hiddenDoc = await model.collection.findOne({
173
+ _id: new mongoose.Types.ObjectId(req.params.id),
174
+ });
175
+
176
+ if (!hiddenDoc) {
177
+ Sentry.captureMessage(`Document ${req.params.id} not found for model ${model.modelName}`);
178
+ const error = new APIError({
179
+ status: 404,
180
+ title: `Document ${req.params.id} not found for model ${model.modelName}`,
181
+ });
182
+ error.meta = undefined;
183
+ throw error;
184
+ }
185
+
186
+ // Document exists but is hidden
187
+ const reason: {[key: string]: string} | null = hiddenDoc.deleted
188
+ ? {deleted: "true"}
189
+ : hiddenDoc.disabled
190
+ ? {disabled: "true"}
191
+ : hiddenDoc.archived
192
+ ? {archived: "true"}
193
+ : null;
194
+
195
+ // If no reason found, treat as not found
196
+ if (!reason) {
197
+ const error = new APIError({
198
+ status: 404,
199
+ title: `Document ${req.params.id} not found for model ${model.modelName}`,
200
+ });
201
+ error.meta = undefined;
202
+ throw error;
203
+ }
204
+ throw new APIError({
205
+ // We don't want to send this to Sentry because it's expected behavior.
206
+ disableExternalErrorTracking: true,
207
+ meta: reason,
208
+ status: 404,
209
+ title: `Document ${req.params.id} not found for model ${model.modelName}`,
210
+ });
211
+ }
212
+
213
+ if (!(await checkPermissions(method, options.permissions[method], req.user, data))) {
214
+ throw new APIError({
215
+ status: 403,
216
+ title: `Access to GET on ${model.modelName}:${req.params.id} denied for ${req.user?.id}`,
217
+ });
218
+ }
219
+
220
+ (req as any).obj = data;
221
+
222
+ return next();
223
+ } catch (error) {
224
+ logger.error(`Permissions error: ${error instanceof Error ? error.message : error}`);
225
+ return next(error);
226
+ }
227
+ };
228
+ }