@rebasepro/server-core 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e

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 (176) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index-DXVBFp5V.js +37 -0
  6. package/dist/index-DXVBFp5V.js.map +1 -0
  7. package/dist/index.es.js +49931 -55
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +28475 -34569
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
  12. package/dist/server-core/src/api/types.d.ts +1 -1
  13. package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
  14. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  15. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  16. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  17. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  18. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  19. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  20. package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
  21. package/dist/server-core/src/auth/index.d.ts +13 -2
  22. package/dist/server-core/src/auth/interfaces.d.ts +45 -6
  23. package/dist/server-core/src/auth/jwt.d.ts +1 -0
  24. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  25. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  26. package/dist/server-core/src/auth/middleware.d.ts +36 -11
  27. package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
  28. package/dist/server-core/src/auth/routes.d.ts +13 -3
  29. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  30. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  31. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  32. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  33. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  34. package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
  35. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  36. package/dist/server-core/src/cron/index.d.ts +6 -0
  37. package/dist/server-core/src/email/index.d.ts +2 -2
  38. package/dist/server-core/src/email/templates.d.ts +9 -0
  39. package/dist/server-core/src/email/types.d.ts +20 -23
  40. package/dist/server-core/src/history/history-routes.d.ts +2 -2
  41. package/dist/server-core/src/index.d.ts +5 -0
  42. package/dist/server-core/src/init.d.ts +112 -2
  43. package/dist/server-core/src/singleton.d.ts +35 -0
  44. package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
  45. package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
  46. package/dist/server-core/src/storage/index.d.ts +18 -11
  47. package/dist/server-core/src/storage/routes.d.ts +3 -3
  48. package/dist/server-core/src/storage/types.d.ts +28 -16
  49. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  50. package/dist/server-core/src/utils/logger.d.ts +31 -0
  51. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  52. package/dist/types/src/controllers/auth.d.ts +2 -0
  53. package/dist/types/src/controllers/client.d.ts +119 -7
  54. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  55. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  56. package/dist/types/src/controllers/data.d.ts +34 -7
  57. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  58. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  59. package/dist/types/src/controllers/email.d.ts +34 -0
  60. package/dist/types/src/controllers/index.d.ts +1 -0
  61. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  62. package/dist/types/src/controllers/navigation.d.ts +5 -5
  63. package/dist/types/src/controllers/registry.d.ts +6 -3
  64. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  65. package/dist/types/src/controllers/storage.d.ts +24 -26
  66. package/dist/types/src/rebase_context.d.ts +8 -4
  67. package/dist/types/src/types/backend.d.ts +4 -1
  68. package/dist/types/src/types/builders.d.ts +5 -4
  69. package/dist/types/src/types/chips.d.ts +1 -1
  70. package/dist/types/src/types/collections.d.ts +169 -125
  71. package/dist/types/src/types/cron.d.ts +102 -0
  72. package/dist/types/src/types/data_source.d.ts +1 -1
  73. package/dist/types/src/types/entity_actions.d.ts +8 -8
  74. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  75. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  76. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  77. package/dist/types/src/types/entity_views.d.ts +8 -8
  78. package/dist/types/src/types/export_import.d.ts +3 -3
  79. package/dist/types/src/types/index.d.ts +1 -0
  80. package/dist/types/src/types/plugins.d.ts +72 -18
  81. package/dist/types/src/types/properties.d.ts +118 -33
  82. package/dist/types/src/types/relations.d.ts +1 -1
  83. package/dist/types/src/types/slots.d.ts +30 -6
  84. package/dist/types/src/types/translations.d.ts +44 -0
  85. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  86. package/package.json +84 -84
  87. package/scratch.ts +6 -5
  88. package/src/api/ast-schema-editor.ts +11 -11
  89. package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
  90. package/src/api/errors.ts +38 -14
  91. package/src/api/graphql/graphql-schema-generator.ts +5 -3
  92. package/src/api/openapi-generator.ts +613 -58
  93. package/src/api/rest/api-generator.ts +17 -11
  94. package/src/api/rest/query-parser.ts +1 -1
  95. package/src/api/schema-editor-routes.ts +2 -0
  96. package/src/api/server.ts +8 -5
  97. package/src/api/types.ts +1 -1
  98. package/src/auth/admin-routes.ts +58 -17
  99. package/src/auth/apple-oauth.ts +130 -0
  100. package/src/auth/bitbucket-oauth.ts +82 -0
  101. package/src/auth/discord-oauth.ts +83 -0
  102. package/src/auth/facebook-oauth.ts +72 -0
  103. package/src/auth/github-oauth.ts +110 -0
  104. package/src/auth/gitlab-oauth.ts +70 -0
  105. package/src/auth/google-oauth.ts +34 -46
  106. package/src/auth/index.ts +15 -2
  107. package/src/auth/interfaces.ts +54 -7
  108. package/src/auth/jwt.ts +22 -5
  109. package/src/auth/linkedin-oauth.ts +81 -0
  110. package/src/auth/microsoft-oauth.ts +88 -0
  111. package/src/auth/middleware.ts +194 -45
  112. package/src/auth/password.ts +3 -1
  113. package/src/auth/rate-limiter.ts +4 -4
  114. package/src/auth/routes.ts +197 -139
  115. package/src/auth/slack-oauth.ts +71 -0
  116. package/src/auth/spotify-oauth.ts +67 -0
  117. package/src/auth/twitter-oauth.ts +120 -0
  118. package/src/collections/loader.ts +6 -6
  119. package/src/cron/cron-loader.ts +89 -0
  120. package/src/cron/cron-routes.test.ts +265 -0
  121. package/src/cron/cron-routes.ts +85 -0
  122. package/src/cron/cron-scheduler.test.ts +421 -0
  123. package/src/cron/cron-scheduler.ts +413 -0
  124. package/src/cron/cron-store.ts +163 -0
  125. package/src/cron/index.ts +6 -0
  126. package/src/db/interfaces.ts +2 -2
  127. package/src/email/index.ts +3 -2
  128. package/src/email/smtp-email-service.ts +5 -2
  129. package/src/email/templates.ts +93 -6
  130. package/src/email/types.ts +17 -24
  131. package/src/functions/function-loader.ts +34 -6
  132. package/src/functions/function-routes.ts +2 -2
  133. package/src/history/history-routes.ts +4 -3
  134. package/src/index.ts +10 -0
  135. package/src/init.ts +475 -57
  136. package/src/services/driver-registry.ts +1 -1
  137. package/src/singleton.test.ts +28 -0
  138. package/src/singleton.ts +70 -0
  139. package/src/storage/LocalStorageController.ts +50 -53
  140. package/src/storage/S3StorageController.ts +49 -46
  141. package/src/storage/index.ts +27 -16
  142. package/src/storage/routes.ts +76 -59
  143. package/src/storage/storage-registry.ts +5 -5
  144. package/src/storage/types.ts +44 -32
  145. package/src/types/index.ts +4 -4
  146. package/src/utils/dev-port.ts +176 -0
  147. package/src/utils/logger.ts +143 -0
  148. package/src/utils/logging.ts +5 -2
  149. package/src/utils/request-logger.ts +66 -0
  150. package/test/admin-routes.test.ts +114 -65
  151. package/test/api-generator.test.ts +73 -30
  152. package/test/ast-schema-editor.test.ts +5 -3
  153. package/test/auth-middleware-hono.test.ts +268 -33
  154. package/test/auth-routes.test.ts +325 -146
  155. package/test/driver-registry.test.ts +4 -2
  156. package/test/error-propagation.test.ts +226 -0
  157. package/test/errors.test.ts +12 -7
  158. package/test/jwt-security.test.ts +18 -9
  159. package/test/jwt.test.ts +26 -13
  160. package/test/middleware.test.ts +13 -8
  161. package/test/query-parser.test.ts +13 -8
  162. package/test/rate-limiter.test.ts +7 -7
  163. package/test/safe-compare.test.ts +66 -0
  164. package/test/singleton.test.ts +59 -0
  165. package/test/storage-local.test.ts +36 -43
  166. package/test/storage-registry.test.ts +38 -36
  167. package/test/storage-routes.test.ts +9 -5
  168. package/test/storage-s3.test.ts +51 -48
  169. package/test.ts +6 -0
  170. package/tsconfig.json +1 -1
  171. package/vite.config.ts +7 -5
  172. package/dist/index-BeMqpmfQ.js +0 -239
  173. package/dist/index-BeMqpmfQ.js.map +0 -1
  174. package/dist/index-bl4J3lNb.js +0 -55823
  175. package/dist/index-bl4J3lNb.js.map +0 -1
  176. package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
@@ -1,13 +1,12 @@
1
1
  import { Hono } from "hono";
2
- import { ApiError } from "../api/errors";
2
+ import { ApiError, errorHandler } from "../api/errors";
3
3
  import { randomBytes, createHash } from "crypto";
4
- import type { AuthRepository } from "./interfaces";
4
+ import type { AuthRepository, OAuthProvider } from "./interfaces";
5
5
  import { generateAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry } from "./jwt";
6
6
  import { hashPassword, verifyPassword, validatePasswordStrength } from "./password";
7
- import { verifyGoogleIdToken, isGoogleOAuthConfigured } from "./google-oauth";
8
7
  import { requireAuth } from "./middleware";
9
8
  import { EmailService, EmailConfig } from "../email";
10
- import { getPasswordResetTemplate, getEmailVerificationTemplate } from "../email/templates";
9
+ import { getPasswordResetTemplate, getEmailVerificationTemplate, getWelcomeEmailTemplate } from "../email/templates";
11
10
  import { HonoEnv } from "../api/types";
12
11
  import { defaultAuthLimiter, strictAuthLimiter } from "./rate-limiter";
13
12
  import { z } from "zod";
@@ -19,10 +18,20 @@ export interface AuthModuleConfig {
19
18
  authRepo: AuthRepository;
20
19
  emailService?: EmailService;
21
20
  emailConfig?: EmailConfig;
22
- /** Allow new user registration (default: false). First user can always register for bootstrap. */
21
+ /** Allow new user registration (default: false). */
23
22
  allowRegistration?: boolean;
24
- /** Default role ID to assign to new users (default: none). The first user always gets "admin". */
23
+ /** Default role ID to assign to new users (default: none). Must NOT be "admin". */
25
24
  defaultRole?: string;
25
+ /** Optional array of OAuth providers */
26
+ oauthProviders?: OAuthProvider[];
27
+ /** When true, blocks all self-registration regardless of `allowRegistration`. */
28
+ disableSelfRegistration?: boolean;
29
+ /**
30
+ * Callback that checks if bootstrap has already been completed.
31
+ * Used by GET /auth/config to report `needsSetup` status.
32
+ * When not provided, falls back to checking if any users exist.
33
+ */
34
+ isBootstrapCompleted?: () => Promise<boolean>;
26
35
  }
27
36
 
28
37
  /**
@@ -72,7 +81,17 @@ function getPasswordResetExpiry(): Date {
72
81
  }
73
82
 
74
83
  export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
84
+ if (config.defaultRole === "admin") {
85
+ throw new Error("CRITICAL SECURITY ERROR: defaultRole cannot be 'admin'. Administrative privilege escalation via registration is strictly forbidden. Use the POST /admin/bootstrap endpoint to promote the initial administrator.");
86
+ }
87
+
75
88
  const router = new Hono<HonoEnv>();
89
+
90
+ // Attach Rebase error handler to ensure ApiError exceptions are correctly
91
+ // formatted instead of caught by Hono's default error handler.
92
+ // Hono's onError does NOT propagate from parent to child routers.
93
+ router.onError(errorHandler);
94
+
76
95
  const authRepo = config.authRepo;
77
96
  const { emailService, emailConfig, allowRegistration = false } = config;
78
97
 
@@ -86,9 +105,6 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
86
105
  email: z.string().email("Invalid email address").max(255),
87
106
  password: z.string().min(1, "Password is required").max(128)
88
107
  });
89
- const googleSchema = z.object({
90
- idToken: z.string().min(1, "ID token is required")
91
- });
92
108
  const forgotPasswordSchema = z.object({
93
109
  email: z.string().email("Invalid email address").max(255)
94
110
  });
@@ -129,13 +145,55 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
129
145
  }
130
146
 
131
147
  /**
132
- * Check if registration is allowed (always allow first user for bootstrap)
148
+ * Check if registration is allowed.
149
+ * Registration is only allowed when explicitly enabled via `allowRegistration`.
150
+ * First-user bootstrap must use POST /admin/bootstrap instead.
151
+ */
152
+ function isRegistrationAllowed(): boolean {
153
+ if (config.disableSelfRegistration) return false;
154
+ return !!allowRegistration;
155
+ }
156
+
157
+ /**
158
+ * Send welcome email to a newly registered user (fire-and-forget).
159
+ */
160
+ function sendWelcomeEmail(user: { email: string; displayName?: string | null }) {
161
+ if (!isEmailConfigured()) return;
162
+ const appName = emailConfig?.appName || "Rebase";
163
+ const loginUrl = emailConfig?.resetPasswordUrl || ""; // reuse base URL → the login / app page
164
+ const templateFn = emailConfig?.templates?.welcomeEmail;
165
+ const emailContent = templateFn
166
+ ? templateFn(user, appName)
167
+ : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : undefined);
168
+
169
+ emailService!.send({
170
+ to: user.email,
171
+ subject: emailContent.subject,
172
+ html: emailContent.html,
173
+ text: emailContent.text
174
+ }).catch(err => {
175
+ console.error("Failed to send welcome email:", err instanceof Error ? err.message : err);
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Helper to generate and store session tokens
133
181
  */
134
- async function isRegistrationAllowed(): Promise<boolean> {
135
- if (allowRegistration) return true;
136
- // Always allow first user registration for bootstrap
137
- const allUsers = await authRepo.listUsers();
138
- return allUsers.length === 0;
182
+ async function createSessionAndTokens(userId: string, userAgent: string, ipAddress: string) {
183
+ const roles = await authRepo.getUserRoles(userId);
184
+ const roleIds = roles.map(r => r.id);
185
+ const accessToken = generateAccessToken(userId, roleIds);
186
+ const refreshToken = generateRefreshToken();
187
+
188
+ await authRepo.createRefreshToken(
189
+ userId,
190
+ hashRefreshToken(refreshToken),
191
+ getRefreshTokenExpiry(),
192
+ userAgent,
193
+ ipAddress
194
+ );
195
+
196
+ return { roleIds, accessToken, refreshToken };
139
197
  }
140
198
 
141
199
  /**
@@ -145,9 +203,13 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
145
203
  router.post("/register", defaultAuthLimiter, async (c) => {
146
204
  const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
147
205
 
148
- // Check if registration is allowed
149
- const registrationAllowed = await isRegistrationAllowed();
150
- if (!registrationAllowed) {
206
+ // Hard kill switch — blocks registration regardless of allowRegistration
207
+ if (config.disableSelfRegistration) {
208
+ throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
209
+ }
210
+
211
+ // Check if registration is allowed (no bypass for empty databases)
212
+ if (!isRegistrationAllowed()) {
151
213
  throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
152
214
  }
153
215
 
@@ -168,37 +230,24 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
168
230
  const user = await authRepo.createUser({
169
231
  email: email.toLowerCase(),
170
232
  passwordHash,
171
- displayName: displayName || undefined,
172
- provider: "email"
233
+ displayName: displayName || undefined
173
234
  });
174
235
 
175
- // Check if this is the first user - make them admin
176
- const allUsers = await authRepo.listUsers();
177
- const isFirstUser = allUsers.length === 1;
178
- if (isFirstUser) {
179
- await authRepo.assignDefaultRole(user.id, "admin");
180
- } else if (config.defaultRole) {
236
+ // Assign configured default role (never auto-assign admin via registration)
237
+ if (config.defaultRole) {
181
238
  await authRepo.assignDefaultRole(user.id, config.defaultRole);
182
239
  }
183
240
 
184
- // Generate tokens
185
- const roles = await authRepo.getUserRoles(user.id);
186
- const roleIds = roles.map(r => r.id);
187
- const accessToken = generateAccessToken(user.id, roleIds);
188
- const refreshToken = generateRefreshToken();
189
-
190
- // Store refresh token
191
- const userAgent = c.req.header("user-agent") || "unknown";
192
- const ipAddress = c.req.header("x-forwarded-for") || "unknown";
193
-
194
- await authRepo.createRefreshToken(
241
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
195
242
  user.id,
196
- hashRefreshToken(refreshToken),
197
- getRefreshTokenExpiry(),
198
- userAgent,
199
- ipAddress
243
+ c.req.header("user-agent") || "unknown",
244
+ c.req.header("x-forwarded-for") || "unknown"
200
245
  );
201
246
 
247
+ // Send welcome email (fire-and-forget, don't block registration)
248
+ sendWelcomeEmail({ email: user.email,
249
+ displayName: user.displayName });
250
+
202
251
  return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
203
252
  });
204
253
 
@@ -223,100 +272,81 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
223
272
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
224
273
  }
225
274
 
226
- // Generate tokens
227
- const roles = await authRepo.getUserRoles(user.id);
228
- const roleIds = roles.map(r => r.id);
229
-
230
- const accessToken = generateAccessToken(user.id, roleIds);
231
- const refreshToken = generateRefreshToken();
232
-
233
- // Store refresh token
234
- const userAgent = c.req.header("user-agent") || "unknown";
235
- const ipAddress = c.req.header("x-forwarded-for") || "unknown";
236
-
237
- await authRepo.createRefreshToken(
275
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
238
276
  user.id,
239
- hashRefreshToken(refreshToken),
240
- getRefreshTokenExpiry(),
241
- userAgent,
242
- ipAddress
277
+ c.req.header("user-agent") || "unknown",
278
+ c.req.header("x-forwarded-for") || "unknown"
243
279
  );
244
280
 
245
281
  return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
246
282
  });
247
283
 
248
284
  /**
249
- * POST /auth/google
250
- * Login/register with Google ID token
285
+ * Dynamically mount OAuth provider routes
251
286
  */
252
- router.post("/google", defaultAuthLimiter, async (c) => {
253
- const { idToken } = parseBody(googleSchema, await c.req.json());
254
-
255
- if (!isGoogleOAuthConfigured()) {
256
- throw ApiError.serviceUnavailable("Google login not configured", "NOT_CONFIGURED");
257
- }
287
+ if (config.oauthProviders && config.oauthProviders.length > 0) {
288
+ for (const provider of config.oauthProviders) {
289
+ router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
290
+ const payload = parseBody(provider.schema, await c.req.json());
291
+
292
+ const externalUser = await provider.verify(payload);
293
+ if (!externalUser) {
294
+ throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
295
+ }
258
296
 
259
- const googleUser = await verifyGoogleIdToken(idToken);
260
- if (!googleUser) {
261
- throw ApiError.unauthorized("Invalid Google token", "INVALID_TOKEN");
262
- }
297
+ // Find or create user
298
+ let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
299
+
300
+ if (!user) {
301
+ // Check if email exists (link accounts)
302
+ user = await authRepo.getUserByEmail(externalUser.email);
303
+
304
+ if (user) {
305
+ // Link Provider to existing account
306
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
307
+
308
+ // Optional: Update profile info from external provider if empty
309
+ await authRepo.updateUser(user.id, {
310
+ displayName: user.displayName || externalUser.displayName || undefined,
311
+ photoUrl: user.photoUrl || externalUser.photoUrl || undefined
312
+ });
313
+ } else {
314
+ // Create new user
315
+ user = await authRepo.createUser({
316
+ email: externalUser.email.toLowerCase(),
317
+ displayName: externalUser.displayName || undefined,
318
+ photoUrl: externalUser.photoUrl || undefined
319
+ });
320
+
321
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
322
+
323
+ // Assign configured default role (never auto-assign admin via registration)
324
+ if (config.defaultRole) {
325
+ await authRepo.assignDefaultRole(user.id, config.defaultRole);
326
+ }
327
+
328
+ // Send welcome email for new OAuth users (fire-and-forget)
329
+ sendWelcomeEmail({ email: user.email,
330
+ displayName: user.displayName });
331
+ }
332
+ } else {
333
+ // Update profile info from external provider
334
+ await authRepo.updateUser(user.id, {
335
+ displayName: externalUser.displayName || user.displayName || undefined,
336
+ photoUrl: externalUser.photoUrl || user.photoUrl || undefined
337
+ });
338
+ }
263
339
 
264
- // Find or create user
265
- let user = await authRepo.getUserByGoogleId(googleUser.googleId);
340
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
341
+ user.id,
342
+ c.req.header("user-agent") || "unknown",
343
+ c.req.header("x-forwarded-for") || "unknown"
344
+ );
266
345
 
267
- if (!user) {
268
- // Check if email exists (link accounts)
269
- user = await authRepo.getUserByEmail(googleUser.email);
270
-
271
- if (user) {
272
- // Link Google to existing account
273
- await authRepo.updateUser(user.id, { googleId: googleUser.googleId });
274
- } else {
275
- // Create new user
276
- user = await authRepo.createUser({
277
- email: googleUser.email.toLowerCase(),
278
- displayName: googleUser.displayName || undefined,
279
- photoUrl: googleUser.photoUrl || undefined,
280
- provider: "google",
281
- googleId: googleUser.googleId
282
- });
283
- // Check if this is the first user - make them admin
284
- const allUsers = await authRepo.listUsers();
285
- const isFirstUser = allUsers.length === 1;
286
- if (isFirstUser) {
287
- await authRepo.assignDefaultRole(user.id, "admin");
288
- } else if (config.defaultRole) {
289
- await authRepo.assignDefaultRole(user.id, config.defaultRole);
290
- }
291
- }
292
- } else {
293
- // Update profile info from Google
294
- await authRepo.updateUser(user.id, {
295
- displayName: googleUser.displayName || user.displayName || undefined,
296
- photoUrl: googleUser.photoUrl || user.photoUrl || undefined
346
+ return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
297
347
  });
298
348
  }
299
-
300
- // Generate tokens
301
- const roles = await authRepo.getUserRoles(user.id);
302
- const roleIds = roles.map(r => r.id);
303
- const accessToken = generateAccessToken(user.id, roleIds);
304
- const refreshToken = generateRefreshToken();
305
-
306
- // Store refresh token
307
- const userAgent = c.req.header("user-agent") || "unknown";
308
- const ipAddress = c.req.header("x-forwarded-for") || "unknown";
309
-
310
- await authRepo.createRefreshToken(
311
- user.id,
312
- hashRefreshToken(refreshToken),
313
- getRefreshTokenExpiry(),
314
- userAgent,
315
- ipAddress
316
- );
317
-
318
- return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
319
- });
349
+ }
320
350
 
321
351
  /**
322
352
  * POST /auth/forgot-password
@@ -350,8 +380,10 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
350
380
  const appName = emailConfig?.appName || "Rebase";
351
381
  const templateFn = emailConfig?.templates?.passwordReset;
352
382
  const emailContent = templateFn
353
- ? templateFn(resetUrl, { email: user.email, displayName: user.displayName })
354
- : getPasswordResetTemplate(resetUrl, { email: user.email, displayName: user.displayName }, appName);
383
+ ? templateFn(resetUrl, { email: user.email,
384
+ displayName: user.displayName })
385
+ : getPasswordResetTemplate(resetUrl, { email: user.email,
386
+ displayName: user.displayName }, appName);
355
387
 
356
388
  // Send email
357
389
  try {
@@ -405,7 +437,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
405
437
  // Invalidate all refresh tokens (security: log out all sessions)
406
438
  await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
407
439
 
408
- return c.json({ success: true, message: "Password has been reset successfully" });
440
+ return c.json({ success: true,
441
+ message: "Password has been reset successfully" });
409
442
  });
410
443
 
411
444
  /**
@@ -445,7 +478,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
445
478
  // Invalidate all refresh tokens (security: log out all sessions)
446
479
  await authRepo.deleteAllRefreshTokensForUser(user.id);
447
480
 
448
- return c.json({ success: true, message: "Password has been changed successfully" });
481
+ return c.json({ success: true,
482
+ message: "Password has been changed successfully" });
449
483
  });
450
484
 
451
485
  /**
@@ -486,8 +520,10 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
486
520
  const appName = emailConfig?.appName || "Rebase";
487
521
  const templateFn = emailConfig?.templates?.emailVerification;
488
522
  const emailContent = templateFn
489
- ? templateFn(verifyUrl, { email: user.email, displayName: user.displayName })
490
- : getEmailVerificationTemplate(verifyUrl, { email: user.email, displayName: user.displayName }, appName);
523
+ ? templateFn(verifyUrl, { email: user.email,
524
+ displayName: user.displayName })
525
+ : getEmailVerificationTemplate(verifyUrl, { email: user.email,
526
+ displayName: user.displayName }, appName);
491
527
 
492
528
  // Send email
493
529
  await emailService!.send({
@@ -497,7 +533,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
497
533
  text: emailContent.text
498
534
  });
499
535
 
500
- return c.json({ success: true, message: "Verification email sent" });
536
+ return c.json({ success: true,
537
+ message: "Verification email sent" });
501
538
  });
502
539
 
503
540
  /**
@@ -520,7 +557,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
520
557
  // Mark email as verified
521
558
  await authRepo.setEmailVerified(user.id, true);
522
559
 
523
- return c.json({ success: true, message: "Email verified successfully" });
560
+ return c.json({ success: true,
561
+ message: "Email verified successfully" });
524
562
  });
525
563
 
526
564
  /**
@@ -552,7 +590,7 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
552
590
  // Rotate refresh token (delete old, create new)
553
591
  const userAgent = c.req.header("user-agent") || "unknown";
554
592
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
555
-
593
+
556
594
  await authRepo.deleteRefreshToken(tokenHash);
557
595
  await authRepo.createRefreshToken(
558
596
  storedToken.userId,
@@ -623,7 +661,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
623
661
  }
624
662
 
625
663
  await authRepo.deleteAllRefreshTokensForUser(userCtx.userId);
626
- return c.json({ success: true, message: "All sessions revoked successfully" });
664
+ return c.json({ success: true,
665
+ message: "All sessions revoked successfully" });
627
666
  });
628
667
 
629
668
  /**
@@ -642,7 +681,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
642
681
  }
643
682
 
644
683
  await authRepo.deleteRefreshTokenById(id, userCtx.userId);
645
- return c.json({ success: true, message: "Session revoked successfully" });
684
+ return c.json({ success: true,
685
+ message: "Session revoked successfully" });
646
686
  });
647
687
 
648
688
  /**
@@ -686,7 +726,7 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
686
726
 
687
727
  const updatedUser = await authRepo.updateUser(userCtx.userId, {
688
728
  displayName: displayName !== undefined ? displayName : undefined,
689
- photoUrl: photoURL !== undefined ? photoURL : undefined,
729
+ photoUrl: photoURL !== undefined ? photoURL : undefined
690
730
  });
691
731
 
692
732
  if (!updatedUser) {
@@ -715,14 +755,32 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
715
755
  * Get public auth configuration (for frontend to know what's available)
716
756
  */
717
757
  router.get("/config", defaultAuthLimiter, async (c) => {
718
- const allUsers = await authRepo.listUsers();
719
- const needsSetup = allUsers.length === 0;
720
- const registrationAllowed = needsSetup || allowRegistration;
758
+ // Determine if setup is needed using the persistent bootstrap flag
759
+ // when available, falling back to user-count check for backward compat.
760
+ let needsSetup: boolean;
761
+ if (config.isBootstrapCompleted) {
762
+ needsSetup = !(await config.isBootstrapCompleted());
763
+ } else {
764
+ const allUsers = await authRepo.listUsers();
765
+ needsSetup = allUsers.length === 0;
766
+ }
767
+
768
+ // Registration is allowed when explicitly enabled OR during initial setup
769
+ const registrationAllowed = needsSetup || !!allowRegistration;
770
+
771
+ // Build a dynamic map of enabled providers for frontend discovery.
772
+ // Also maintain legacy boolean fields for backward compatibility.
773
+ const enabledProviders = (config.oauthProviders || []).map(p => p.id);
774
+
721
775
  return c.json({
722
776
  needsSetup,
723
777
  registrationEnabled: registrationAllowed,
724
- googleEnabled: isGoogleOAuthConfigured(),
725
- emailServiceEnabled: isEmailConfigured()
778
+ // Legacy fields (kept for backward compat)
779
+ googleEnabled: enabledProviders.includes("google"),
780
+ linkedinEnabled: enabledProviders.includes("linkedin"),
781
+ emailServiceEnabled: isEmailConfigured(),
782
+ // New: complete list of available OAuth providers
783
+ enabledProviders
726
784
  });
727
785
  });
728
786
 
@@ -0,0 +1,71 @@
1
+ import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Creates a Slack OAuth Provider integration (OAuth 2.0 / "Sign in with Slack").
6
+ * Uses the OpenID Connect flow with the "openid,email,profile" scopes.
7
+ */
8
+ export function createSlackProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
9
+ return {
10
+ id: "slack",
11
+ schema: z.object({
12
+ code: z.string().min(1, "Auth code is required"),
13
+ redirectUri: z.string().url("Valid redirect URI is required")
14
+ }),
15
+ verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
16
+ try {
17
+ const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
20
+ body: new URLSearchParams({
21
+ client_id: config.clientId,
22
+ client_secret: config.clientSecret,
23
+ code: payload.code,
24
+ redirect_uri: payload.redirectUri,
25
+ grant_type: "authorization_code"
26
+ })
27
+ });
28
+
29
+ if (!tokenResponse.ok) {
30
+ console.error("Failed to get Slack access token:", await tokenResponse.text());
31
+ return null;
32
+ }
33
+
34
+ const tokenData = await tokenResponse.json() as { ok: boolean; access_token?: string; error?: string };
35
+ if (!tokenData.ok || !tokenData.access_token) {
36
+ console.error("Slack token exchange failed:", tokenData.error);
37
+ return null;
38
+ }
39
+
40
+ const profileResponse = await fetch("https://slack.com/api/openid.connect.userInfo", {
41
+ headers: { "Authorization": `Bearer ${tokenData.access_token}` }
42
+ });
43
+
44
+ if (!profileResponse.ok) {
45
+ console.error("Failed to get Slack user info:", await profileResponse.text());
46
+ return null;
47
+ }
48
+
49
+ const p = await profileResponse.json() as {
50
+ ok: boolean; sub: string; name?: string;
51
+ email?: string; picture?: string; email_verified?: boolean;
52
+ };
53
+
54
+ if (!p.ok || !p.email) {
55
+ console.error("Slack user has no email");
56
+ return null;
57
+ }
58
+
59
+ return {
60
+ providerId: p.sub,
61
+ email: p.email,
62
+ displayName: p.name || null,
63
+ photoUrl: p.picture || null
64
+ };
65
+ } catch (error) {
66
+ console.error("Slack OAuth error:", error);
67
+ return null;
68
+ }
69
+ }
70
+ };
71
+ }
@@ -0,0 +1,67 @@
1
+ import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Creates a Spotify OAuth Provider integration.
6
+ * Uses the authorization code flow with the "user-read-email" scope.
7
+ */
8
+ export function createSpotifyProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
9
+ return {
10
+ id: "spotify",
11
+ schema: z.object({
12
+ code: z.string().min(1, "Auth code is required"),
13
+ redirectUri: z.string().url("Valid redirect URI is required")
14
+ }),
15
+ verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
16
+ try {
17
+ const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
18
+
19
+ const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
20
+ method: "POST",
21
+ headers: {
22
+ "Content-Type": "application/x-www-form-urlencoded",
23
+ "Authorization": `Basic ${basicAuth}`
24
+ },
25
+ body: new URLSearchParams({
26
+ grant_type: "authorization_code",
27
+ code: payload.code,
28
+ redirect_uri: payload.redirectUri
29
+ })
30
+ });
31
+
32
+ if (!tokenResponse.ok) {
33
+ console.error("Failed to get Spotify access token:", await tokenResponse.text());
34
+ return null;
35
+ }
36
+
37
+ const tokenData = await tokenResponse.json() as { access_token: string };
38
+
39
+ const profileResponse = await fetch("https://api.spotify.com/v1/me", {
40
+ headers: { "Authorization": `Bearer ${tokenData.access_token}` }
41
+ });
42
+
43
+ if (!profileResponse.ok) {
44
+ console.error("Failed to get Spotify user info:", await profileResponse.text());
45
+ return null;
46
+ }
47
+
48
+ const p = await profileResponse.json() as {
49
+ id: string; display_name?: string | null;
50
+ email?: string; images?: Array<{ url: string }>;
51
+ };
52
+
53
+ if (!p.email) { console.error("Spotify user has no email"); return null; }
54
+
55
+ return {
56
+ providerId: p.id,
57
+ email: p.email,
58
+ displayName: p.display_name || null,
59
+ photoUrl: p.images?.[0]?.url || null
60
+ };
61
+ } catch (error) {
62
+ console.error("Spotify OAuth error:", error);
63
+ return null;
64
+ }
65
+ }
66
+ };
67
+ }