@terreno/api 0.0.17 → 0.1.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 (77) hide show
  1. package/.claude/CLAUDE.local.md +204 -0
  2. package/.cursor/rules/00-root.mdc +338 -0
  3. package/.github/copilot-instructions.md +333 -0
  4. package/AGENTS.md +333 -0
  5. package/README.md +76 -7
  6. package/biome.jsonc +1 -1
  7. package/dist/api.d.ts +68 -1
  8. package/dist/api.js +140 -5
  9. package/dist/api.query.test.js +1 -1
  10. package/dist/api.test.js +222 -484
  11. package/dist/auth.js +3 -1
  12. package/dist/errors.js +15 -12
  13. package/dist/example.js +7 -7
  14. package/dist/expressServer.d.ts +8 -2
  15. package/dist/expressServer.js +8 -1
  16. package/dist/githubAuth.d.ts +64 -0
  17. package/dist/githubAuth.js +293 -0
  18. package/dist/githubAuth.test.d.ts +1 -0
  19. package/dist/githubAuth.test.js +351 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +3 -0
  22. package/dist/logger.js +1 -1
  23. package/dist/middleware.js +1 -1
  24. package/dist/notifiers/googleChatNotifier.js +1 -1
  25. package/dist/notifiers/googleChatNotifier.test.js +1 -1
  26. package/dist/notifiers/slackNotifier.js +1 -1
  27. package/dist/notifiers/slackNotifier.test.js +1 -1
  28. package/dist/notifiers/zoomNotifier.js +1 -1
  29. package/dist/notifiers/zoomNotifier.test.js +1 -1
  30. package/dist/openApi.test.js +8 -5
  31. package/dist/openApiBuilder.d.ts +69 -1
  32. package/dist/openApiBuilder.js +109 -5
  33. package/dist/openApiValidator.d.ts +296 -0
  34. package/dist/openApiValidator.js +698 -0
  35. package/dist/openApiValidator.test.d.ts +1 -0
  36. package/dist/openApiValidator.test.js +346 -0
  37. package/dist/permissions.js +1 -1
  38. package/dist/plugins.test.js +3 -3
  39. package/dist/terrenoPlugin.d.ts +4 -0
  40. package/dist/terrenoPlugin.js +2 -0
  41. package/dist/tests/bunSetup.js +2 -2
  42. package/dist/tests.js +34 -24
  43. package/package.json +7 -2
  44. package/src/__snapshots__/openApi.test.ts.snap +399 -0
  45. package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
  46. package/src/api.query.test.ts +1 -1
  47. package/src/api.test.ts +161 -374
  48. package/src/api.ts +210 -4
  49. package/src/auth.ts +3 -1
  50. package/src/errors.ts +15 -12
  51. package/src/example.ts +7 -7
  52. package/src/expressServer.ts +18 -2
  53. package/src/githubAuth.test.ts +223 -0
  54. package/src/githubAuth.ts +335 -0
  55. package/src/index.ts +3 -0
  56. package/src/logger.ts +1 -1
  57. package/src/middleware.ts +1 -1
  58. package/src/notifiers/googleChatNotifier.test.ts +1 -1
  59. package/src/notifiers/googleChatNotifier.ts +1 -1
  60. package/src/notifiers/slackNotifier.test.ts +1 -1
  61. package/src/notifiers/slackNotifier.ts +1 -1
  62. package/src/notifiers/zoomNotifier.test.ts +1 -1
  63. package/src/notifiers/zoomNotifier.ts +1 -1
  64. package/src/openApi.test.ts +8 -5
  65. package/src/openApiBuilder.ts +188 -15
  66. package/src/openApiValidator.test.ts +241 -0
  67. package/src/openApiValidator.ts +860 -0
  68. package/src/permissions.ts +1 -1
  69. package/src/plugins.test.ts +3 -3
  70. package/src/terrenoPlugin.ts +5 -0
  71. package/src/tests/bunSetup.ts +2 -2
  72. package/src/tests.ts +34 -24
  73. package/CLAUDE.md +0 -107
  74. package/dist/response.d.ts +0 -0
  75. package/dist/response.js +0 -1
  76. package/index.ts +0 -1
  77. package/src/response.ts +0 -0
@@ -0,0 +1,335 @@
1
+ import type express from "express";
2
+ import passport from "passport";
3
+ import {Strategy as GitHubStrategy, type Profile} from "passport-github2";
4
+ import {generateTokens, type UserModel} from "./auth";
5
+ import {APIError} from "./errors";
6
+ import type {AuthOptions} from "./expressServer";
7
+ import {logger} from "./logger";
8
+
9
+ /** Options for configuring GitHub OAuth authentication */
10
+ export interface GitHubAuthOptions {
11
+ /** GitHub OAuth Client ID */
12
+ clientId: string;
13
+ /** GitHub OAuth Client Secret */
14
+ clientSecret: string;
15
+ /** Callback URL for GitHub OAuth (e.g., https://yourapp.com/auth/github/callback) */
16
+ callbackURL: string;
17
+ /** OAuth scopes to request from GitHub. Defaults to ["user:email"] */
18
+ scope?: string[];
19
+ /**
20
+ * Whether to allow linking GitHub to existing accounts.
21
+ * If true, authenticated users can link their GitHub account.
22
+ * Defaults to true.
23
+ */
24
+ allowAccountLinking?: boolean;
25
+ /**
26
+ * Custom function to handle user creation or lookup from GitHub profile.
27
+ * If not provided, a default implementation will be used.
28
+ */
29
+ findOrCreateUser?: (
30
+ profile: Profile,
31
+ accessToken: string,
32
+ refreshToken: string,
33
+ existingUser?: any
34
+ ) => Promise<any>;
35
+ }
36
+
37
+ /** Fields added to user documents for GitHub authentication */
38
+ export interface GitHubUserFields {
39
+ /** GitHub user ID */
40
+ githubId?: string;
41
+ /** GitHub username */
42
+ githubUsername?: string;
43
+ /** GitHub profile URL */
44
+ githubProfileUrl?: string;
45
+ /** GitHub avatar URL */
46
+ githubAvatarUrl?: string;
47
+ }
48
+
49
+ /**
50
+ * Plugin to add GitHub authentication fields to a user schema.
51
+ * Apply this plugin to your User schema if you want to enable GitHub auth.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import {githubUserPlugin} from "@terreno/api";
56
+ *
57
+ * userSchema.plugin(githubUserPlugin);
58
+ * ```
59
+ */
60
+ export function githubUserPlugin(schema: any) {
61
+ schema.add({
62
+ githubAvatarUrl: {type: String},
63
+ githubId: {index: true, sparse: true, type: String, unique: true},
64
+ githubProfileUrl: {type: String},
65
+ githubUsername: {type: String},
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Sets up GitHub OAuth authentication strategy.
71
+ * Call this after setupAuth() in your server initialization.
72
+ */
73
+ export function setupGitHubAuth(
74
+ _app: express.Application,
75
+ userModel: UserModel,
76
+ githubOptions: GitHubAuthOptions
77
+ ) {
78
+ const scope = githubOptions.scope ?? ["user:email"];
79
+
80
+ passport.use(
81
+ "github",
82
+ new GitHubStrategy(
83
+ {
84
+ callbackURL: githubOptions.callbackURL,
85
+ clientID: githubOptions.clientId,
86
+ clientSecret: githubOptions.clientSecret,
87
+ passReqToCallback: true,
88
+ scope,
89
+ },
90
+ async (
91
+ req: express.Request,
92
+ accessToken: string,
93
+ refreshToken: string,
94
+ profile: Profile,
95
+ done: (error: any, user?: any) => void
96
+ ) => {
97
+ try {
98
+ const existingUser = req.user;
99
+
100
+ // If custom handler provided, use it
101
+ if (githubOptions.findOrCreateUser) {
102
+ const user = await githubOptions.findOrCreateUser(
103
+ profile,
104
+ accessToken,
105
+ refreshToken,
106
+ existingUser
107
+ );
108
+ return done(null, user);
109
+ }
110
+
111
+ // Default implementation
112
+ const githubId = profile.id;
113
+
114
+ // Check if user with this GitHub ID already exists
115
+ const existingGitHubUser = await userModel.findOne({githubId} as any);
116
+
117
+ // Case 1: User is authenticated and wants to link GitHub account
118
+ if (existingUser) {
119
+ if (!githubOptions.allowAccountLinking) {
120
+ return done(new APIError({status: 400, title: "Account linking is disabled"}));
121
+ }
122
+
123
+ if (
124
+ existingGitHubUser &&
125
+ existingGitHubUser._id.toString() !== existingUser._id.toString()
126
+ ) {
127
+ return done(
128
+ new APIError({
129
+ status: 400,
130
+ title: "This GitHub account is already linked to another user",
131
+ })
132
+ );
133
+ }
134
+
135
+ // Link GitHub to existing user
136
+ const user = await userModel.findById(existingUser._id);
137
+ if (user) {
138
+ (user as any).githubId = githubId;
139
+ (user as any).githubUsername = profile.username;
140
+ (user as any).githubProfileUrl = profile.profileUrl;
141
+ (user as any).githubAvatarUrl = profile.photos?.[0]?.value;
142
+ await user.save();
143
+ return done(null, user);
144
+ }
145
+ return done(new APIError({status: 404, title: "User not found"}));
146
+ }
147
+
148
+ // Case 2: User with this GitHub ID exists - log them in
149
+ if (existingGitHubUser) {
150
+ return done(null, existingGitHubUser);
151
+ }
152
+
153
+ // Case 3: Create new user with GitHub credentials
154
+ const email = profile.emails?.[0]?.value;
155
+
156
+ // Check if user with this email already exists
157
+ if (email) {
158
+ const existingEmailUser = await userModel.findOne({email} as any);
159
+ if (existingEmailUser) {
160
+ // If account linking is allowed, link GitHub to existing email account
161
+ if (githubOptions.allowAccountLinking !== false) {
162
+ (existingEmailUser as any).githubId = githubId;
163
+ (existingEmailUser as any).githubUsername = profile.username;
164
+ (existingEmailUser as any).githubProfileUrl = profile.profileUrl;
165
+ (existingEmailUser as any).githubAvatarUrl = profile.photos?.[0]?.value;
166
+ await existingEmailUser.save();
167
+ return done(null, existingEmailUser);
168
+ }
169
+ return done(
170
+ new APIError({
171
+ status: 400,
172
+ title:
173
+ "An account with this email already exists. Please log in and link your GitHub account.",
174
+ })
175
+ );
176
+ }
177
+ }
178
+
179
+ // Create new user
180
+ const newUser = new userModel({
181
+ admin: false,
182
+ email,
183
+ githubAvatarUrl: profile.photos?.[0]?.value,
184
+ githubId,
185
+ githubProfileUrl: profile.profileUrl,
186
+ githubUsername: profile.username,
187
+ } as any);
188
+
189
+ await newUser.save();
190
+ return done(null, newUser);
191
+ } catch (error) {
192
+ logger.error(`GitHub auth error: ${error}`);
193
+ return done(error);
194
+ }
195
+ }
196
+ ) as passport.Strategy
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Adds GitHub OAuth routes to the Express application.
202
+ *
203
+ * Routes added:
204
+ * - GET /auth/github - Initiates GitHub OAuth flow
205
+ * - GET /auth/github/callback - Handles GitHub OAuth callback
206
+ * - POST /auth/github/link - Links GitHub account to authenticated user (requires JWT auth)
207
+ * - DELETE /auth/github/unlink - Unlinks GitHub account from authenticated user (requires JWT auth)
208
+ */
209
+ export function addGitHubAuthRoutes(
210
+ app: express.Application,
211
+ userModel: UserModel,
212
+ githubOptions: GitHubAuthOptions,
213
+ authOptions?: AuthOptions
214
+ ): void {
215
+ const router = require("express").Router();
216
+
217
+ // Initiate GitHub OAuth flow
218
+ router.get(
219
+ "/github",
220
+ (req: express.Request, _res: express.Response, next: express.NextFunction) => {
221
+ // Store the return URL in session or query for redirect after auth
222
+ const returnTo = req.query.returnTo as string | undefined;
223
+ if (returnTo) {
224
+ (req as any).session = (req as any).session || {};
225
+ (req as any).session.returnTo = returnTo;
226
+ }
227
+ next();
228
+ },
229
+ passport.authenticate("github", {session: false})
230
+ );
231
+
232
+ // GitHub OAuth callback
233
+ router.get(
234
+ "/github/callback",
235
+ passport.authenticate("github", {
236
+ failureRedirect: "/auth/github/failure",
237
+ session: false,
238
+ }),
239
+ async (req: express.Request, res: express.Response) => {
240
+ try {
241
+ const tokens = await generateTokens(req.user, authOptions);
242
+ const returnTo = (req as any).session?.returnTo;
243
+
244
+ // If there's a return URL, redirect with tokens as query params
245
+ if (returnTo) {
246
+ const url = new URL(returnTo);
247
+ url.searchParams.set("token", tokens.token || "");
248
+ if (tokens.refreshToken) {
249
+ url.searchParams.set("refreshToken", tokens.refreshToken);
250
+ }
251
+ url.searchParams.set("userId", (req.user as any)?._id?.toString() || "");
252
+ return res.redirect(url.toString());
253
+ }
254
+
255
+ // Otherwise return JSON response
256
+ return res.json({
257
+ data: {
258
+ refreshToken: tokens.refreshToken,
259
+ token: tokens.token,
260
+ userId: (req.user as any)?._id,
261
+ },
262
+ });
263
+ } catch (error) {
264
+ logger.error(`GitHub callback error: ${error}`);
265
+ return res.status(500).json({message: "Authentication failed"});
266
+ }
267
+ }
268
+ );
269
+
270
+ // GitHub auth failure handler
271
+ router.get("/github/failure", (_req: express.Request, res: express.Response) => {
272
+ return res.status(401).json({message: "GitHub authentication failed"});
273
+ });
274
+
275
+ // Link GitHub to existing authenticated user
276
+ if (githubOptions.allowAccountLinking !== false) {
277
+ router.get(
278
+ "/github/link",
279
+ (req: express.Request, res: express.Response, next: express.NextFunction): void => {
280
+ // Require JWT authentication for linking
281
+ passport.authenticate("jwt", {session: false}, (err: any, user: any) => {
282
+ if (err || !user) {
283
+ res.status(401).json({message: "Authentication required to link GitHub account"});
284
+ return;
285
+ }
286
+ req.user = user;
287
+ next();
288
+ })(req, res, next);
289
+ },
290
+ passport.authenticate("github", {session: false})
291
+ );
292
+
293
+ // Unlink GitHub from user account
294
+ router.delete(
295
+ "/github/unlink",
296
+ passport.authenticate("jwt", {session: false}),
297
+ async (req: express.Request, res: express.Response) => {
298
+ if (!req.user) {
299
+ return res.status(401).json({message: "Authentication required"});
300
+ }
301
+
302
+ try {
303
+ // Explicitly select hash and salt fields which may be hidden by default
304
+ const user = await userModel.findById((req.user as any)._id).select("+hash +salt");
305
+ if (!user) {
306
+ return res.status(404).json({message: "User not found"});
307
+ }
308
+
309
+ // Check if user has other authentication methods before unlinking
310
+ // passport-local-mongoose stores password in hash and salt fields
311
+ const hasPassword = !!(user as any).hash || !!(user as any).salt;
312
+ if (!hasPassword) {
313
+ return res.status(400).json({
314
+ message:
315
+ "Cannot unlink GitHub account without another authentication method. Set a password first.",
316
+ });
317
+ }
318
+
319
+ (user as any).githubId = undefined;
320
+ (user as any).githubUsername = undefined;
321
+ (user as any).githubProfileUrl = undefined;
322
+ (user as any).githubAvatarUrl = undefined;
323
+ await user.save();
324
+
325
+ return res.json({data: {message: "GitHub account unlinked successfully"}});
326
+ } catch (error) {
327
+ logger.error(`GitHub unlink error: ${error}`);
328
+ return res.status(500).json({message: "Failed to unlink GitHub account"});
329
+ }
330
+ }
331
+ );
332
+ }
333
+
334
+ app.use("/auth", router);
335
+ }
package/src/index.ts CHANGED
@@ -2,13 +2,16 @@ export * from "./api";
2
2
  export * from "./auth";
3
3
  export * from "./errors";
4
4
  export * from "./expressServer";
5
+ export * from "./githubAuth";
5
6
  export * from "./logger";
6
7
  export * from "./middleware";
7
8
  export * from "./notifiers";
8
9
  export * from "./openApiBuilder";
9
10
  export * from "./openApiEtag";
11
+ export * from "./openApiValidator";
10
12
  export * from "./permissions";
11
13
  export * from "./plugins";
12
14
  export * from "./populate";
15
+ export * from "./terrenoPlugin";
13
16
  export * from "./transformers";
14
17
  export * from "./utils";
package/src/logger.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import {inspect} from "node:util";
3
- import * as Sentry from "@sentry/node";
3
+ import * as Sentry from "@sentry/bun";
4
4
  import winston from "winston";
5
5
 
6
6
  function isPrimitive(val: any) {
package/src/middleware.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
1
+ import * as Sentry from "@sentry/bun";
2
2
  import type {NextFunction, Request, Response} from "express";
3
3
 
4
4
  /**
@@ -1,5 +1,5 @@
1
1
  import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
- import * as Sentry from "@sentry/node";
2
+ import * as Sentry from "@sentry/bun";
3
3
  import axios from "axios";
4
4
 
5
5
  import {sendToGoogleChat} from "./googleChatNotifier";
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
1
+ import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
4
  import {APIError} from "../errors";
@@ -1,5 +1,5 @@
1
1
  import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
- import * as Sentry from "@sentry/node";
2
+ import * as Sentry from "@sentry/bun";
3
3
  import axios from "axios";
4
4
 
5
5
  import {sendToSlack} from "./slackNotifier";
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
1
+ import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
4
  import {APIError} from "../errors";
@@ -1,5 +1,5 @@
1
1
  import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
- import * as Sentry from "@sentry/node";
2
+ import * as Sentry from "@sentry/bun";
3
3
  import axios from "axios";
4
4
 
5
5
  import {sendToZoom} from "./zoomNotifier";
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
1
+ import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
4
  import {APIError} from "../errors";
@@ -160,13 +160,13 @@ describe("openApi", () => {
160
160
  // Ensure that a Number query field supports gt/gte/lt/lte and just a Number
161
161
  expect(foodQuery.schema).toEqual({
162
162
  oneOf: [
163
- {type: "number"},
163
+ {description: "Number of calories in the food", type: "number"},
164
164
  {
165
165
  properties: {
166
- $gt: {type: "number"},
167
- $gte: {type: "number"},
168
- $lt: {type: "number"},
169
- $lte: {type: "number"},
166
+ $gt: {description: "Number of calories in the food", type: "number"},
167
+ $gte: {description: "Number of calories in the food", type: "number"},
168
+ $lt: {description: "Number of calories in the food", type: "number"},
169
+ $lte: {description: "Number of calories in the food", type: "number"},
170
170
  },
171
171
  type: "object",
172
172
  },
@@ -278,6 +278,7 @@ describe("openApi populate", () => {
278
278
  type: "string",
279
279
  },
280
280
  name: {
281
+ description: "The user's display name",
281
282
  type: "string",
282
283
  },
283
284
  },
@@ -293,12 +294,14 @@ describe("openApi populate", () => {
293
294
  });
294
295
 
295
296
  expect(properties.likesIds).toEqual({
297
+ description: "User likes for this food",
296
298
  items: {
297
299
  properties: {
298
300
  _id: {
299
301
  type: "string",
300
302
  },
301
303
  likes: {
304
+ description: "Whether the user liked the item",
302
305
  type: "boolean",
303
306
  },
304
307
  userId: {