@terreno/api 0.1.0 → 0.3.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 (40) hide show
  1. package/dist/api.d.ts +28 -2
  2. package/dist/api.js +20 -7
  3. package/dist/betterAuth.d.ts +91 -0
  4. package/dist/betterAuth.js +8 -0
  5. package/dist/betterAuth.test.d.ts +1 -0
  6. package/dist/betterAuth.test.js +181 -0
  7. package/dist/betterAuthApp.d.ts +22 -0
  8. package/dist/betterAuthApp.js +38 -0
  9. package/dist/betterAuthApp.test.d.ts +1 -0
  10. package/dist/betterAuthApp.test.js +242 -0
  11. package/dist/betterAuthSetup.d.ts +60 -0
  12. package/dist/betterAuthSetup.js +278 -0
  13. package/dist/betterAuthSetup.test.d.ts +1 -0
  14. package/dist/betterAuthSetup.test.js +684 -0
  15. package/dist/expressServer.js +3 -3
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +4 -0
  18. package/dist/terrenoApp.d.ts +189 -0
  19. package/dist/terrenoApp.js +352 -0
  20. package/dist/terrenoApp.test.d.ts +1 -0
  21. package/dist/terrenoApp.test.js +264 -0
  22. package/dist/terrenoPlugin.d.ts +34 -0
  23. package/package.json +6 -3
  24. package/src/api.ts +61 -3
  25. package/src/betterAuth.test.ts +160 -0
  26. package/src/betterAuth.ts +104 -0
  27. package/src/betterAuthApp.test.ts +114 -0
  28. package/src/betterAuthApp.ts +60 -0
  29. package/src/betterAuthSetup.test.ts +485 -0
  30. package/src/betterAuthSetup.ts +251 -0
  31. package/src/expressServer.ts +5 -6
  32. package/src/index.ts +4 -0
  33. package/src/openApiValidator.ts +1 -1
  34. package/src/terrenoApp.test.ts +201 -0
  35. package/src/terrenoApp.ts +347 -0
  36. package/src/terrenoPlugin.ts +34 -0
  37. package/.claude/CLAUDE.local.md +0 -204
  38. package/.cursor/rules/00-root.mdc +0 -338
  39. package/.github/copilot-instructions.md +0 -333
  40. package/AGENTS.md +0 -333
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Better Auth setup and initialization for @terreno/api.
3
+ *
4
+ * This module provides functions to initialize Better Auth with MongoDB,
5
+ * create session middleware, and sync users with the application User model.
6
+ */
7
+
8
+ import {betterAuth} from "better-auth";
9
+ import {mongodbAdapter} from "better-auth/adapters/mongodb";
10
+ import {toNodeHandler} from "better-auth/node";
11
+ import type {Application, NextFunction, Request, Response} from "express";
12
+ import mongoose from "mongoose";
13
+ import type {UserModel} from "./auth";
14
+ import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
+ import {logger} from "./logger";
16
+
17
+ /**
18
+ * The Better Auth instance type.
19
+ */
20
+ export type BetterAuthInstance = ReturnType<typeof betterAuth>;
21
+
22
+ /**
23
+ * Options for creating a Better Auth instance.
24
+ */
25
+ export interface CreateBetterAuthOptions {
26
+ config: BetterAuthConfig;
27
+ mongoClient: any;
28
+ userModel?: UserModel;
29
+ }
30
+
31
+ /**
32
+ * Creates a Better Auth instance with MongoDB adapter.
33
+ */
34
+ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthInstance => {
35
+ const {config, mongoClient} = options;
36
+
37
+ const secret = config.secret || process.env.BETTER_AUTH_SECRET;
38
+ if (!secret) {
39
+ throw new Error("BETTER_AUTH_SECRET must be set in env or config.secret must be provided.");
40
+ }
41
+
42
+ const baseURL = config.baseURL || process.env.BETTER_AUTH_URL;
43
+ if (!baseURL) {
44
+ throw new Error("BETTER_AUTH_URL must be set in env or config.baseURL must be provided.");
45
+ }
46
+
47
+ const basePath = config.basePath ?? "/api/auth";
48
+
49
+ const socialProviders: Record<string, {clientId: string; clientSecret: string}> = {};
50
+
51
+ if (config.googleOAuth) {
52
+ socialProviders.google = {
53
+ clientId: config.googleOAuth.clientId,
54
+ clientSecret: config.googleOAuth.clientSecret,
55
+ };
56
+ }
57
+
58
+ if (config.appleOAuth) {
59
+ socialProviders.apple = {
60
+ clientId: config.appleOAuth.clientId,
61
+ clientSecret: config.appleOAuth.clientSecret,
62
+ };
63
+ }
64
+
65
+ if (config.githubOAuth) {
66
+ socialProviders.github = {
67
+ clientId: config.githubOAuth.clientId,
68
+ clientSecret: config.githubOAuth.clientSecret,
69
+ };
70
+ }
71
+
72
+ const auth = betterAuth({
73
+ basePath,
74
+ baseURL,
75
+ database: mongodbAdapter(mongoClient.db()),
76
+ emailAndPassword: {
77
+ enabled: true,
78
+ },
79
+ secret,
80
+ session: {
81
+ cookieCache: {
82
+ enabled: true,
83
+ maxAge: 5 * 60, // 5 minutes
84
+ },
85
+ },
86
+ socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
87
+ trustedOrigins: config.trustedOrigins ?? [],
88
+ });
89
+
90
+ return auth;
91
+ };
92
+
93
+ /**
94
+ * Creates Express middleware that extracts the Better Auth session
95
+ * and populates req.user with the application User model.
96
+ */
97
+ export const createBetterAuthSessionMiddleware = (
98
+ auth: BetterAuthInstance,
99
+ userModel?: UserModel
100
+ ) => {
101
+ return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
102
+ try {
103
+ const session = await auth.api.getSession({
104
+ headers: req.headers as Record<string, string>,
105
+ });
106
+
107
+ if (session?.user && session?.session) {
108
+ const betterAuthUser = session.user as BetterAuthUser;
109
+
110
+ if (userModel) {
111
+ // Look up the application user by betterAuthId
112
+ const appUser = await userModel.findOne({betterAuthId: betterAuthUser.id});
113
+ if (appUser) {
114
+ (req as any).user = appUser;
115
+ (req as any).betterAuthSession = session;
116
+ } else {
117
+ // User exists in Better Auth but not synced yet - create them
118
+ const newUser = await syncBetterAuthUser(userModel, betterAuthUser);
119
+ (req as any).user = newUser;
120
+ (req as any).betterAuthSession = session;
121
+ }
122
+ } else {
123
+ // No user model - just attach the Better Auth user directly
124
+ (req as any).user = {
125
+ _id: betterAuthUser.id,
126
+ admin: false,
127
+ betterAuthId: betterAuthUser.id,
128
+ email: betterAuthUser.email,
129
+ id: betterAuthUser.id,
130
+ name: betterAuthUser.name,
131
+ };
132
+ (req as any).betterAuthSession = session;
133
+ }
134
+ }
135
+
136
+ next();
137
+ } catch (error) {
138
+ logger.debug(`Better Auth session extraction error: ${error}`);
139
+ next();
140
+ }
141
+ };
142
+ };
143
+
144
+ /**
145
+ * Syncs a Better Auth user to the application User model.
146
+ * Creates or updates the user as needed.
147
+ */
148
+ export const syncBetterAuthUser = async (
149
+ userModel: UserModel,
150
+ betterAuthUser: BetterAuthUser,
151
+ oauthProvider?: string
152
+ ): Promise<any> => {
153
+ try {
154
+ const existingUser: any = await userModel.findOne({betterAuthId: betterAuthUser.id});
155
+
156
+ if (existingUser) {
157
+ // Update existing user if needed
158
+ existingUser.email = betterAuthUser.email;
159
+ if (betterAuthUser.name) {
160
+ existingUser.name = betterAuthUser.name;
161
+ }
162
+ await existingUser.save();
163
+ return existingUser;
164
+ }
165
+
166
+ // Check if user exists by email (migration case)
167
+ const userByEmail: any = await userModel.findOne({email: betterAuthUser.email});
168
+ if (userByEmail) {
169
+ // Link existing user to Better Auth
170
+ userByEmail.betterAuthId = betterAuthUser.id;
171
+ if (oauthProvider) {
172
+ userByEmail.oauthProvider = oauthProvider;
173
+ }
174
+ await userByEmail.save();
175
+ return userByEmail;
176
+ }
177
+
178
+ // Create new user
179
+ const newUser: any = new (userModel as any)({
180
+ admin: false,
181
+ betterAuthId: betterAuthUser.id,
182
+ email: betterAuthUser.email,
183
+ name: betterAuthUser.name || betterAuthUser.email.split("@")[0],
184
+ oauthProvider: oauthProvider || null,
185
+ });
186
+ await newUser.save();
187
+ logger.info(`Created new user from Better Auth: ${newUser.id}`);
188
+ return newUser;
189
+ } catch (error) {
190
+ logger.error(`Error syncing Better Auth user: ${error}`);
191
+ throw error;
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Mounts Better Auth routes on the Express app.
197
+ */
198
+ export const mountBetterAuthRoutes = (
199
+ app: Application,
200
+ auth: BetterAuthInstance,
201
+ basePath = "/api/auth"
202
+ ): void => {
203
+ const handler = toNodeHandler(auth);
204
+
205
+ // Mount at the base path with wildcard
206
+ app.all(`${basePath}/*`, (req, res) => {
207
+ return handler(req, res);
208
+ });
209
+
210
+ logger.info(`Better Auth routes mounted at ${basePath}/*`);
211
+ };
212
+
213
+ /**
214
+ * Gets the MongoDB client from the mongoose connection.
215
+ */
216
+ export const getMongoClientFromMongoose = (): any => {
217
+ const connection = mongoose.connection;
218
+ const client = (connection as any).client;
219
+ if (!client) {
220
+ throw new Error("Mongoose is not connected. Ensure MongoDB connection is established first.");
221
+ }
222
+ return client;
223
+ };
224
+
225
+ /**
226
+ * Sets up Better Auth user sync hooks.
227
+ * This ensures users created/updated in Better Auth are synced to the application User model.
228
+ *
229
+ * Note: Better Auth doesn't have built-in event hooks, so we rely on the session middleware
230
+ * to create users on first session access.
231
+ */
232
+ export const setupBetterAuthUserSync = (_auth: BetterAuthInstance, _userModel: UserModel): void => {
233
+ // Better Auth v1.x doesn't expose event hooks for user creation.
234
+ // User sync is handled in createBetterAuthSessionMiddleware when a session is accessed.
235
+ // This function is a placeholder for future versions that may support hooks.
236
+ logger.debug("Better Auth user sync configured (via session middleware)");
237
+ };
238
+
239
+ /**
240
+ * Extracts Better Auth session data from the request.
241
+ */
242
+ export const getBetterAuthSession = (req: Request): BetterAuthSessionData | null => {
243
+ return (req as any).betterAuthSession ?? null;
244
+ };
245
+
246
+ /**
247
+ * Checks if the request has a valid Better Auth session.
248
+ */
249
+ export const hasBetterAuthSession = (req: Request): boolean => {
250
+ return Boolean((req as any).betterAuthSession);
251
+ };
@@ -177,7 +177,7 @@ function initializeRoutes(
177
177
  UserModel: UserMongooseModel,
178
178
  addRoutes: AddRoutes,
179
179
  options: InitializeRoutesOptions = {}
180
- ) {
180
+ ): express.Application {
181
181
  const app = express();
182
182
 
183
183
  // TODO: Log a warning when we hit the array limit.
@@ -193,11 +193,10 @@ function initializeRoutes(
193
193
  options.addMiddleware(app);
194
194
  }
195
195
 
196
- app.use(express.json());
196
+ app.use(express.json({limit: "50mb"}));
197
197
 
198
198
  // Add login/signup/refresh_token before the JWT/auth middlewares
199
199
  addAuthRoutes(app, UserModel as any, options?.authOptions);
200
-
201
200
  setupAuth(app as any, UserModel as any);
202
201
 
203
202
  if (options.logRequests !== false) {
@@ -245,7 +244,7 @@ function initializeRoutes(
245
244
 
246
245
  addMeRoutes(app, UserModel as any, options?.authOptions);
247
246
 
248
- // Set up GitHub OAuth if configured
247
+ // Set up GitHub OAuth if configured (works with JWT auth)
249
248
  if (options.githubAuth) {
250
249
  setupGitHubAuth(app, UserModel as any, options.githubAuth);
251
250
  addGitHubAuthRoutes(app, UserModel as any, options.githubAuth, options.authOptions);
@@ -297,8 +296,8 @@ export interface SetupServerOptions {
297
296
  sentryOptions?: Sentry.BunOptions;
298
297
  }
299
298
 
300
- // Sets up the routes and returns a function to launch the API.
301
- export function setupServer(options: SetupServerOptions) {
299
+ // Sets up the routes and returns the app.
300
+ export function setupServer(options: SetupServerOptions): express.Application {
302
301
  const UserModel = options.userModel;
303
302
  const addRoutes = options.addRoutes;
304
303
 
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./api";
2
2
  export * from "./auth";
3
+ export * from "./betterAuth";
4
+ export * from "./betterAuthApp";
5
+ export * from "./betterAuthSetup";
3
6
  export * from "./errors";
4
7
  export * from "./expressServer";
5
8
  export * from "./githubAuth";
@@ -12,6 +15,7 @@ export * from "./openApiValidator";
12
15
  export * from "./permissions";
13
16
  export * from "./plugins";
14
17
  export * from "./populate";
18
+ export * from "./terrenoApp";
15
19
  export * from "./terrenoPlugin";
16
20
  export * from "./transformers";
17
21
  export * from "./utils";
@@ -183,7 +183,7 @@ function getAjvInstance(): Ajv {
183
183
  useDefaults: true,
184
184
  validateSchema: false,
185
185
  });
186
- addFormats(instance);
186
+ addFormats(instance as any);
187
187
  ajvCache.set(key, instance);
188
188
  }
189
189
 
@@ -0,0 +1,201 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import type express from "express";
3
+ import supertest from "supertest";
4
+
5
+ import {modelRouter} from "./api";
6
+ import {Permissions} from "./permissions";
7
+ import {TerrenoApp} from "./terrenoApp";
8
+ import type {TerrenoPlugin} from "./terrenoPlugin";
9
+ import {authAsUser, FoodModel, setupDb, UserModel} from "./tests";
10
+
11
+ describe("TerrenoApp", () => {
12
+ const originalEnv = process.env;
13
+
14
+ beforeEach(() => {
15
+ process.env = {
16
+ ...originalEnv,
17
+ REFRESH_TOKEN_SECRET: "test-refresh-secret",
18
+ SESSION_SECRET: "test-session-secret",
19
+ TOKEN_EXPIRES_IN: "1h",
20
+ TOKEN_ISSUER: "test-issuer",
21
+ TOKEN_SECRET: "test-secret",
22
+ };
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.env = originalEnv;
27
+ });
28
+
29
+ describe("build", () => {
30
+ it("returns an express application without listening", () => {
31
+ const app = new TerrenoApp({
32
+ skipListen: true,
33
+ userModel: UserModel as any,
34
+ }).build();
35
+
36
+ expect(app).toBeDefined();
37
+ });
38
+
39
+ it("creates server with custom corsOrigin", () => {
40
+ const app = new TerrenoApp({
41
+ corsOrigin: "https://example.com",
42
+ skipListen: true,
43
+ userModel: UserModel as any,
44
+ }).build();
45
+
46
+ expect(app).toBeDefined();
47
+ });
48
+ });
49
+
50
+ describe("start", () => {
51
+ it("returns an express application with skipListen", () => {
52
+ const app = new TerrenoApp({
53
+ skipListen: true,
54
+ userModel: UserModel as any,
55
+ }).start();
56
+
57
+ expect(app).toBeDefined();
58
+ });
59
+ });
60
+
61
+ describe("register with modelRouter", () => {
62
+ let admin: any;
63
+
64
+ beforeEach(async () => {
65
+ [admin] = await setupDb();
66
+ });
67
+
68
+ it("mounts model router at the specified path", async () => {
69
+ const foodRegistration = modelRouter("/food", FoodModel, {
70
+ allowAnonymous: true,
71
+ permissions: {
72
+ create: [Permissions.IsAny],
73
+ delete: [Permissions.IsAny],
74
+ list: [Permissions.IsAny],
75
+ read: [Permissions.IsAny],
76
+ update: [Permissions.IsAny],
77
+ },
78
+ sort: "-created",
79
+ });
80
+
81
+ expect(foodRegistration.__type).toBe("modelRouter");
82
+ expect(foodRegistration.path).toBe("/food");
83
+
84
+ const app = new TerrenoApp({
85
+ skipListen: true,
86
+ userModel: UserModel as any,
87
+ })
88
+ .register(foodRegistration)
89
+ .build();
90
+
91
+ await FoodModel.create({
92
+ calories: 100,
93
+ name: "Apple",
94
+ ownerId: admin._id,
95
+ source: {name: "Nature"},
96
+ });
97
+
98
+ const agent = await authAsUser(app, "admin");
99
+ const res = await agent.get("/food").expect(200);
100
+ expect(res.body.data).toHaveLength(1);
101
+ expect(res.body.data[0].name).toBe("Apple");
102
+ });
103
+
104
+ it("supports chaining multiple registrations", async () => {
105
+ const foodRegistration = modelRouter("/food", FoodModel, {
106
+ allowAnonymous: true,
107
+ permissions: {
108
+ create: [Permissions.IsAny],
109
+ delete: [Permissions.IsAny],
110
+ list: [Permissions.IsAny],
111
+ read: [Permissions.IsAny],
112
+ update: [Permissions.IsAny],
113
+ },
114
+ });
115
+
116
+ const app = new TerrenoApp({
117
+ skipListen: true,
118
+ userModel: UserModel as any,
119
+ })
120
+ .register(foodRegistration)
121
+ .build();
122
+
123
+ expect(app).toBeDefined();
124
+ });
125
+ });
126
+
127
+ describe("register with plugin", () => {
128
+ it("calls plugin.register with the express app", () => {
129
+ const registerFn = mock(() => {});
130
+ const plugin: TerrenoPlugin = {
131
+ register: registerFn,
132
+ };
133
+
134
+ const app = new TerrenoApp({
135
+ skipListen: true,
136
+ userModel: UserModel as any,
137
+ })
138
+ .register(plugin)
139
+ .build();
140
+
141
+ expect(registerFn).toHaveBeenCalledTimes(1);
142
+ // Verify the plugin received the express app
143
+ const calledWith = (registerFn.mock.calls as any[][])[0][0];
144
+ expect(calledWith).toBe(app);
145
+ });
146
+ });
147
+
148
+ describe("addMiddleware", () => {
149
+ it("runs request handler middleware", async () => {
150
+ let middlewareCalled = false;
151
+ const middleware: express.RequestHandler = (_req, _res, next) => {
152
+ middlewareCalled = true;
153
+ next();
154
+ };
155
+
156
+ const app = new TerrenoApp({
157
+ skipListen: true,
158
+ userModel: UserModel as any,
159
+ })
160
+ .addMiddleware(middleware)
161
+ .build();
162
+
163
+ await supertest(app).get("/nonexistent").expect(404);
164
+ expect(middlewareCalled).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe("modelRouter overload", () => {
169
+ it("returns ModelRouterRegistration when path is provided", () => {
170
+ const result = modelRouter("/food", FoodModel, {
171
+ permissions: {
172
+ create: [Permissions.IsAny],
173
+ delete: [Permissions.IsAny],
174
+ list: [Permissions.IsAny],
175
+ read: [Permissions.IsAny],
176
+ update: [Permissions.IsAny],
177
+ },
178
+ });
179
+
180
+ expect(result.__type).toBe("modelRouter");
181
+ expect(result.path).toBe("/food");
182
+ expect(result.router).toBeDefined();
183
+ });
184
+
185
+ it("returns express.Router when no path is provided", () => {
186
+ const result = modelRouter(FoodModel, {
187
+ permissions: {
188
+ create: [Permissions.IsAny],
189
+ delete: [Permissions.IsAny],
190
+ list: [Permissions.IsAny],
191
+ read: [Permissions.IsAny],
192
+ update: [Permissions.IsAny],
193
+ },
194
+ });
195
+
196
+ // Should be a regular router (function), not a ModelRouterRegistration
197
+ expect(typeof result).toBe("function");
198
+ expect((result as any).__type).toBeUndefined();
199
+ });
200
+ });
201
+ });