@terreno/api 0.0.17 → 0.0.18

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 (46) hide show
  1. package/.windsurfrules +107 -0
  2. package/AGENTS.md +313 -0
  3. package/README.md +3 -4
  4. package/biome.jsonc +1 -1
  5. package/dist/api.js +1 -1
  6. package/dist/api.query.test.js +1 -1
  7. package/dist/api.test.js +36 -1202
  8. package/dist/errors.js +1 -1
  9. package/dist/expressServer.d.ts +8 -2
  10. package/dist/expressServer.js +8 -1
  11. package/dist/githubAuth.d.ts +64 -0
  12. package/dist/githubAuth.js +293 -0
  13. package/dist/githubAuth.test.d.ts +1 -0
  14. package/dist/githubAuth.test.js +351 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1 -0
  17. package/dist/logger.js +1 -1
  18. package/dist/middleware.js +1 -1
  19. package/dist/notifiers/googleChatNotifier.js +1 -1
  20. package/dist/notifiers/googleChatNotifier.test.js +1 -1
  21. package/dist/notifiers/slackNotifier.js +1 -1
  22. package/dist/notifiers/slackNotifier.test.js +1 -1
  23. package/dist/notifiers/zoomNotifier.js +1 -1
  24. package/dist/notifiers/zoomNotifier.test.js +1 -1
  25. package/dist/permissions.js +1 -1
  26. package/dist/tests/bunSetup.js +2 -2
  27. package/package.json +4 -2
  28. package/src/api.query.test.ts +1 -1
  29. package/src/api.test.ts +30 -984
  30. package/src/api.ts +1 -1
  31. package/src/errors.ts +1 -1
  32. package/src/expressServer.ts +18 -2
  33. package/src/githubAuth.test.ts +223 -0
  34. package/src/githubAuth.ts +335 -0
  35. package/src/index.ts +1 -0
  36. package/src/logger.ts +1 -1
  37. package/src/middleware.ts +1 -1
  38. package/src/notifiers/googleChatNotifier.test.ts +1 -1
  39. package/src/notifiers/googleChatNotifier.ts +1 -1
  40. package/src/notifiers/slackNotifier.test.ts +1 -1
  41. package/src/notifiers/slackNotifier.ts +1 -1
  42. package/src/notifiers/zoomNotifier.test.ts +1 -1
  43. package/src/notifiers/zoomNotifier.ts +1 -1
  44. package/src/permissions.ts +1 -1
  45. package/src/tests/bunSetup.ts +2 -2
  46. /package/{CLAUDE.md → .cursorrules} +0 -0
package/src/api.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- import * as Sentry from "@sentry/node";
6
+ import * as Sentry from "@sentry/bun";
7
7
  import express, {type NextFunction, type Request, type Response} from "express";
8
8
  import cloneDeep from "lodash/cloneDeep";
9
9
  import mongoose, {type Document, type Model} from "mongoose";
package/src/errors.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // https://jsonapi.org/format/#errors
2
- import * as Sentry from "@sentry/node";
2
+ import * as Sentry from "@sentry/bun";
3
3
  import type {NextFunction, Request, Response} from "express";
4
4
  import {Schema} from "mongoose";
5
5
 
@@ -1,4 +1,4 @@
1
- import * as Sentry from "@sentry/node";
1
+ import * as Sentry from "@sentry/bun";
2
2
  import openapi from "@wesleytodd/openapi";
3
3
  import cors from "cors";
4
4
  import cron from "cron";
@@ -12,6 +12,7 @@ import qs from "qs";
12
12
  import type {ModelRouterOptions} from "./api";
13
13
  import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
14
14
  import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
15
+ import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
15
16
  import {type LoggingOptions, logger, setupLogging} from "./logger";
16
17
  import {sendToSlack} from "./notifiers";
17
18
  import {openApiEtagMiddleware} from "./openApiEtag";
@@ -168,6 +169,8 @@ interface InitializeRoutesOptions {
168
169
  logRequests?: boolean;
169
170
  loggingOptions?: LoggingOptions;
170
171
  authOptions?: AuthOptions;
172
+ /** GitHub OAuth configuration. When provided, enables GitHub authentication. */
173
+ githubAuth?: GitHubAuthOptions;
171
174
  }
172
175
 
173
176
  function initializeRoutes(
@@ -241,6 +244,13 @@ function initializeRoutes(
241
244
  }
242
245
 
243
246
  addMeRoutes(app, UserModel as any, options?.authOptions);
247
+
248
+ // Set up GitHub OAuth if configured
249
+ if (options.githubAuth) {
250
+ setupGitHubAuth(app, UserModel as any, options.githubAuth);
251
+ addGitHubAuthRoutes(app, UserModel as any, options.githubAuth, options.authOptions);
252
+ }
253
+
244
254
  addRoutes(app, {openApi: oapi});
245
255
 
246
256
  Sentry.setupExpressErrorHandler(app);
@@ -264,6 +274,11 @@ export interface SetupServerOptions {
264
274
  addRoutes: AddRoutes;
265
275
  loggingOptions?: LoggingOptions;
266
276
  authOptions?: AuthOptions;
277
+ /**
278
+ * GitHub OAuth configuration. When provided, enables GitHub authentication.
279
+ * Requires the user schema to have GitHub fields (use githubUserPlugin).
280
+ */
281
+ githubAuth?: GitHubAuthOptions;
267
282
  skipListen?: boolean;
268
283
  corsOrigin?:
269
284
  | string
@@ -279,7 +294,7 @@ export interface SetupServerOptions {
279
294
  ) => void);
280
295
  addMiddleware?: AddRoutes;
281
296
  ignoreTraces?: string[];
282
- sentryOptions?: Sentry.NodeOptions;
297
+ sentryOptions?: Sentry.BunOptions;
283
298
  }
284
299
 
285
300
  // Sets up the routes and returns a function to launch the API.
@@ -295,6 +310,7 @@ export function setupServer(options: SetupServerOptions) {
295
310
  addMiddleware: options.addMiddleware,
296
311
  authOptions: options.authOptions,
297
312
  corsOrigin: options.corsOrigin,
313
+ githubAuth: options.githubAuth,
298
314
  });
299
315
  } catch (error: any) {
300
316
  logger.error(`Error initializing routes: ${error.stack}`);
@@ -0,0 +1,223 @@
1
+ import {afterEach, beforeEach, describe, expect, it, setSystemTime} from "bun:test";
2
+ import type express from "express";
3
+ import mongoose, {model, Schema} from "mongoose";
4
+ import passportLocalMongoose from "passport-local-mongoose";
5
+ import supertest from "supertest";
6
+ import type TestAgent from "supertest/lib/agent";
7
+
8
+ import {setupServer} from "./expressServer";
9
+ import {type GitHubUserFields, githubUserPlugin} from "./githubAuth";
10
+ import {logger} from "./logger";
11
+ import {createdUpdatedPlugin, isDisabledPlugin} from "./plugins";
12
+
13
+ interface TestUser extends GitHubUserFields {
14
+ admin: boolean;
15
+ name?: string;
16
+ username: string;
17
+ email: string;
18
+ disabled?: boolean;
19
+ }
20
+
21
+ // Create schema for GitHub-enabled user
22
+ const testUserSchema = new Schema<TestUser>({
23
+ admin: {default: false, type: Boolean},
24
+ name: String,
25
+ username: String,
26
+ });
27
+
28
+ testUserSchema.plugin(passportLocalMongoose as any, {
29
+ attemptsField: "attempts",
30
+ interval: 1,
31
+ limitAttempts: true,
32
+ maxAttempts: 3,
33
+ maxInterval: 1,
34
+ usernameCaseInsensitive: true,
35
+ usernameField: "email",
36
+ });
37
+ testUserSchema.plugin(createdUpdatedPlugin);
38
+ testUserSchema.plugin(isDisabledPlugin);
39
+ testUserSchema.plugin(githubUserPlugin);
40
+
41
+ // Get or create model to avoid model redefinition errors
42
+ const GitHubTestUserModel =
43
+ mongoose.models.GitHubTestUser || model<TestUser>("GitHubTestUser", testUserSchema);
44
+
45
+ // Connect to database before tests
46
+ const connectDb = async () => {
47
+ if (mongoose.connection.readyState === 0) {
48
+ await mongoose
49
+ .connect("mongodb://127.0.0.1/terreno?&connectTimeoutMS=360000")
50
+ .catch(logger.catch);
51
+ }
52
+ process.env.REFRESH_TOKEN_SECRET = "refresh_secret";
53
+ process.env.TOKEN_SECRET = "secret";
54
+ process.env.TOKEN_EXPIRES_IN = "30m";
55
+ process.env.TOKEN_ISSUER = "example.com";
56
+ process.env.SESSION_SECRET = "session";
57
+ };
58
+
59
+ describe("githubUserPlugin", () => {
60
+ it("adds GitHub fields to schema", () => {
61
+ const paths = testUserSchema.paths;
62
+ expect(paths.githubId).toBeDefined();
63
+ expect(paths.githubUsername).toBeDefined();
64
+ expect(paths.githubProfileUrl).toBeDefined();
65
+ expect(paths.githubAvatarUrl).toBeDefined();
66
+ });
67
+
68
+ it("githubId is indexed and sparse", () => {
69
+ const githubIdPath = testUserSchema.path("githubId");
70
+ expect((githubIdPath as any).options.index).toBe(true);
71
+ expect((githubIdPath as any).options.sparse).toBe(true);
72
+ expect((githubIdPath as any).options.unique).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe("GitHub auth routes", () => {
77
+ let app: express.Application;
78
+ let agent: TestAgent;
79
+
80
+ beforeEach(async () => {
81
+ setSystemTime();
82
+ await connectDb();
83
+
84
+ await GitHubTestUserModel.deleteMany({});
85
+
86
+ // Create test user with password
87
+ const testUser = await GitHubTestUserModel.create({
88
+ admin: false,
89
+ email: "test@example.com",
90
+ name: "Test User",
91
+ });
92
+ await (testUser as any).setPassword("password123");
93
+ await testUser.save();
94
+
95
+ function addRoutes(router: express.Router): void {
96
+ router.get("/test", (_req, res) => res.json({ok: true}));
97
+ }
98
+
99
+ app = setupServer({
100
+ addRoutes,
101
+ githubAuth: {
102
+ allowAccountLinking: true,
103
+ callbackURL: "http://localhost:9000/auth/github/callback",
104
+ clientId: "test-client-id",
105
+ clientSecret: "test-client-secret",
106
+ },
107
+ skipListen: true,
108
+ userModel: GitHubTestUserModel as any,
109
+ });
110
+ agent = supertest.agent(app);
111
+ });
112
+
113
+ afterEach(async () => {
114
+ setSystemTime();
115
+ });
116
+
117
+ it("GET /auth/github redirects to GitHub OAuth", async () => {
118
+ const res = await agent.get("/auth/github").expect(302);
119
+ expect(res.headers.location).toContain("github.com");
120
+ expect(res.headers.location).toContain("client_id=test-client-id");
121
+ });
122
+
123
+ it("GET /auth/github/failure returns 401", async () => {
124
+ const res = await agent.get("/auth/github/failure").expect(401);
125
+ expect(res.body.message).toBe("GitHub authentication failed");
126
+ });
127
+
128
+ it("DELETE /auth/github/unlink requires authentication", async () => {
129
+ const res = await agent.delete("/auth/github/unlink").expect(401);
130
+ expect(res.body).toBeDefined();
131
+ });
132
+
133
+ it("DELETE /auth/github/unlink works when authenticated with password", async () => {
134
+ // Login as test user
135
+ const loginRes = await agent
136
+ .post("/auth/login")
137
+ .send({email: "test@example.com", password: "password123"})
138
+ .expect(200);
139
+
140
+ // Link github to this user
141
+ const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
142
+ if (user) {
143
+ (user as any).githubId = "99999";
144
+ (user as any).githubUsername = "testghuser";
145
+ await user.save();
146
+ }
147
+
148
+ // Unlink
149
+ const res = await agent
150
+ .delete("/auth/github/unlink")
151
+ .set("authorization", `Bearer ${loginRes.body.data.token}`)
152
+ .expect(200);
153
+
154
+ expect(res.body.data.message).toBe("GitHub account unlinked successfully");
155
+
156
+ // Verify github fields are cleared
157
+ const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
158
+ expect((updatedUser as any).githubId).toBeUndefined();
159
+ expect((updatedUser as any).githubUsername).toBeUndefined();
160
+ });
161
+
162
+ it("user can have both password and GitHub auth", async () => {
163
+ const user = await GitHubTestUserModel.findOne({email: "test@example.com"});
164
+ expect(user).toBeDefined();
165
+ if (!user) {
166
+ return;
167
+ }
168
+
169
+ // Link GitHub
170
+ (user as any).githubId = "88888";
171
+ (user as any).githubUsername = "linkeduser";
172
+ await user.save();
173
+
174
+ // Can still login with password
175
+ const res = await agent
176
+ .post("/auth/login")
177
+ .send({email: "test@example.com", password: "password123"})
178
+ .expect(200);
179
+
180
+ expect(res.body.data.token).toBeDefined();
181
+
182
+ // User has both auth methods - successful login proves password works
183
+ // and we verify GitHub fields are set
184
+ const updatedUser = await GitHubTestUserModel.findOne({email: "test@example.com"});
185
+ expect(updatedUser).toBeDefined();
186
+ expect((updatedUser as any).githubId).toBe("88888");
187
+ expect((updatedUser as any).githubUsername).toBe("linkeduser");
188
+ });
189
+ });
190
+
191
+ describe("GitHub auth disabled", () => {
192
+ let app: express.Application;
193
+ let agent: TestAgent;
194
+
195
+ beforeEach(async () => {
196
+ setSystemTime();
197
+ await connectDb();
198
+
199
+ await GitHubTestUserModel.deleteMany({});
200
+
201
+ function addRoutes(router: express.Router): void {
202
+ router.get("/test", (_req, res) => res.json({ok: true}));
203
+ }
204
+
205
+ // Setup server WITHOUT GitHub auth
206
+ app = setupServer({
207
+ addRoutes,
208
+ skipListen: true,
209
+ userModel: GitHubTestUserModel as any,
210
+ });
211
+ agent = supertest.agent(app);
212
+ });
213
+
214
+ afterEach(async () => {
215
+ setSystemTime();
216
+ });
217
+
218
+ it("GitHub routes are not available when githubAuth is not configured", async () => {
219
+ await agent.get("/auth/github").expect(404);
220
+ await agent.get("/auth/github/callback").expect(404);
221
+ await agent.delete("/auth/github/unlink").expect(404);
222
+ });
223
+ });
@@ -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,6 +2,7 @@ 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";
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";