@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.
- package/.claude/CLAUDE.local.md +204 -0
- package/.cursor/rules/00-root.mdc +338 -0
- package/.github/copilot-instructions.md +333 -0
- package/AGENTS.md +333 -0
- package/README.md +76 -7
- package/biome.jsonc +1 -1
- package/dist/api.d.ts +68 -1
- package/dist/api.js +140 -5
- package/dist/api.query.test.js +1 -1
- package/dist/api.test.js +222 -484
- package/dist/auth.js +3 -1
- package/dist/errors.js +15 -12
- package/dist/example.js +7 -7
- package/dist/expressServer.d.ts +8 -2
- package/dist/expressServer.js +8 -1
- package/dist/githubAuth.d.ts +64 -0
- package/dist/githubAuth.js +293 -0
- package/dist/githubAuth.test.d.ts +1 -0
- package/dist/githubAuth.test.js +351 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/logger.js +1 -1
- package/dist/middleware.js +1 -1
- package/dist/notifiers/googleChatNotifier.js +1 -1
- package/dist/notifiers/googleChatNotifier.test.js +1 -1
- package/dist/notifiers/slackNotifier.js +1 -1
- package/dist/notifiers/slackNotifier.test.js +1 -1
- package/dist/notifiers/zoomNotifier.js +1 -1
- package/dist/notifiers/zoomNotifier.test.js +1 -1
- package/dist/openApi.test.js +8 -5
- package/dist/openApiBuilder.d.ts +69 -1
- package/dist/openApiBuilder.js +109 -5
- package/dist/openApiValidator.d.ts +296 -0
- package/dist/openApiValidator.js +698 -0
- package/dist/openApiValidator.test.d.ts +1 -0
- package/dist/openApiValidator.test.js +346 -0
- package/dist/permissions.js +1 -1
- package/dist/plugins.test.js +3 -3
- package/dist/terrenoPlugin.d.ts +4 -0
- package/dist/terrenoPlugin.js +2 -0
- package/dist/tests/bunSetup.js +2 -2
- package/dist/tests.js +34 -24
- package/package.json +7 -2
- package/src/__snapshots__/openApi.test.ts.snap +399 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +108 -0
- package/src/api.query.test.ts +1 -1
- package/src/api.test.ts +161 -374
- package/src/api.ts +210 -4
- package/src/auth.ts +3 -1
- package/src/errors.ts +15 -12
- package/src/example.ts +7 -7
- package/src/expressServer.ts +18 -2
- package/src/githubAuth.test.ts +223 -0
- package/src/githubAuth.ts +335 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +1 -1
- package/src/middleware.ts +1 -1
- package/src/notifiers/googleChatNotifier.test.ts +1 -1
- package/src/notifiers/googleChatNotifier.ts +1 -1
- package/src/notifiers/slackNotifier.test.ts +1 -1
- package/src/notifiers/slackNotifier.ts +1 -1
- package/src/notifiers/zoomNotifier.test.ts +1 -1
- package/src/notifiers/zoomNotifier.ts +1 -1
- package/src/openApi.test.ts +8 -5
- package/src/openApiBuilder.ts +188 -15
- package/src/openApiValidator.test.ts +241 -0
- package/src/openApiValidator.ts +860 -0
- package/src/permissions.ts +1 -1
- package/src/plugins.test.ts +3 -3
- package/src/terrenoPlugin.ts +5 -0
- package/src/tests/bunSetup.ts +2 -2
- package/src/tests.ts +34 -24
- package/CLAUDE.md +0 -107
- package/dist/response.d.ts +0 -0
- package/dist/response.js +0 -1
- package/index.ts +0 -1
- 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
package/src/middleware.ts
CHANGED
package/src/openApi.test.ts
CHANGED
|
@@ -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: {
|