@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
@@ -60,6 +60,7 @@ export class RestApiGenerator {
60
60
  this.router.get(basePath, async (c) => {
61
61
  const queryDict = c.req.query();
62
62
  const queryOptions = parseQueryOptions(queryDict);
63
+ const searchString = queryDict.searchString as string | undefined;
63
64
 
64
65
  const driver = c.get("driver") || this.driver;
65
66
  const fetchService = this.getFetchService(driver);
@@ -72,14 +73,15 @@ export class RestApiGenerator {
72
73
  {
73
74
  filter: queryOptions.where as FetchCollectionProps["filter"],
74
75
  limit: queryOptions.limit,
76
+ offset: queryOptions.offset,
75
77
  orderBy: queryOptions.orderBy?.[0]?.field,
76
78
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
77
- searchString: queryDict.searchString as string | undefined,
79
+ searchString
78
80
  },
79
81
  queryOptions.include
80
82
  );
81
83
 
82
- const total = await this.countRawEntities(driver, resolvedCollection, queryOptions);
84
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
83
85
 
84
86
  return c.json({
85
87
  data: entities,
@@ -92,9 +94,10 @@ export class RestApiGenerator {
92
94
  });
93
95
  }
94
96
 
95
- // Fallback for non-Postgres drivers
96
- const entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions);
97
- const total = await this.countRawEntities(driver, resolvedCollection, queryOptions);
97
+ // Fallback path
98
+ const entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
99
+
100
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
98
101
 
99
102
  return c.json({
100
103
  data: entities,
@@ -146,7 +149,7 @@ export class RestApiGenerator {
146
149
  try {
147
150
  const driver = c.get("driver") || this.driver;
148
151
  const path = collection.slug;
149
-
152
+
150
153
  const body = await c.req.json().catch(() => ({}));
151
154
 
152
155
  const entity = await driver.saveEntity({
@@ -265,7 +268,8 @@ export class RestApiGenerator {
265
268
  return { collectionPath: segments.join("/") };
266
269
  } else {
267
270
  const entityId = segments.pop()!;
268
- return { collectionPath: segments.join("/"), entityId };
271
+ return { collectionPath: segments.join("/"),
272
+ entityId };
269
273
  }
270
274
  };
271
275
 
@@ -426,7 +430,7 @@ export class RestApiGenerator {
426
430
  /**
427
431
  * Fetch raw collection data without Entity wrapper (fallback for non-Postgres)
428
432
  */
429
- private async fetchRawCollection(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions) {
433
+ private async fetchRawCollection(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string) {
430
434
  const entities = await driver.fetchCollection({
431
435
  path: collection.slug,
432
436
  collection,
@@ -434,7 +438,8 @@ export class RestApiGenerator {
434
438
  limit: queryOptions.limit,
435
439
  orderBy: queryOptions.orderBy?.[0]?.field,
436
440
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
437
- startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined
441
+ startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined,
442
+ searchString
438
443
  });
439
444
 
440
445
  return entities.map(entity => this.flattenEntity(entity));
@@ -443,11 +448,12 @@ export class RestApiGenerator {
443
448
  /**
444
449
  * Count raw entities for a collection
445
450
  */
446
- private async countRawEntities(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions): Promise<number> {
451
+ private async countRawEntities(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string): Promise<number> {
447
452
  return driver.countEntities ? await driver.countEntities({
448
453
  path: collection.slug,
449
454
  collection,
450
- filter: queryOptions.where as FetchCollectionProps["filter"]
455
+ filter: queryOptions.where as FetchCollectionProps["filter"],
456
+ searchString
451
457
  }) : 0;
452
458
  }
453
459
 
@@ -57,7 +57,7 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
57
57
  }
58
58
 
59
59
  // PostgREST style filtering
60
- const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "where", "include", "fields"];
60
+ const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "where", "include", "fields", "searchString"];
61
61
  for (const [key, rawValue] of Object.entries(query)) {
62
62
  if (reservedQueryKeys.includes(key)) continue;
63
63
 
@@ -1,9 +1,11 @@
1
1
  import { Hono } from "hono";
2
2
  import { AstSchemaEditor } from "./ast-schema-editor";
3
+ import { errorHandler } from "./errors";
3
4
  import { HonoEnv } from "./types";
4
5
 
5
6
  export function createSchemaEditorRoutes(collectionsDir: string): Hono<HonoEnv> {
6
7
  const router = new Hono<HonoEnv>();
8
+ router.onError(errorHandler);
7
9
  const editor = new AstSchemaEditor(collectionsDir);
8
10
 
9
11
  router.post("/property/save", async (c) => {
package/src/api/server.ts CHANGED
@@ -39,7 +39,7 @@ export class RebaseApiServer {
39
39
 
40
40
  this.app = new Hono<HonoEnv>();
41
41
  this.router = new Hono<HonoEnv>();
42
-
42
+
43
43
  this.setupMiddleware();
44
44
  }
45
45
 
@@ -57,7 +57,7 @@ export class RebaseApiServer {
57
57
  server.setupRoutes();
58
58
  // Since we mount routes directly to router, we can let consumer attach it
59
59
  server.app.route("/", server.router);
60
-
60
+
61
61
  // Hono global error handler on the root app
62
62
  server.app.onError(errorHandler);
63
63
  server.router.onError(errorHandler);
@@ -119,7 +119,7 @@ export class RebaseApiServer {
119
119
  properties: Object.keys(col.properties),
120
120
  relations: (col as EntityCollection & { relations?: Relation[] }).relations?.map((r: Relation) => ({
121
121
  relationName: r.relationName,
122
- target: typeof r.target === 'function' ? r.target().slug : r.target,
122
+ target: typeof r.target === "function" ? r.target().slug : r.target,
123
123
  cardinality: r.cardinality,
124
124
  direction: r.direction
125
125
  })) || []
@@ -187,7 +187,10 @@ export class RebaseApiServer {
187
187
 
188
188
  // OpenAPI endpoint
189
189
  this.router.get(`${basePath}/docs`, (c) => {
190
- const openApiSpec = generateOpenApiSpec(this.config.collections || [], this.config.basePath);
190
+ const openApiSpec = generateOpenApiSpec(this.config.collections || [], {
191
+ basePath: this.config.basePath,
192
+ requireAuth: this.config.requireAuth ?? true
193
+ });
191
194
  return c.json(openApiSpec);
192
195
  });
193
196
 
@@ -234,7 +237,7 @@ export class RebaseApiServer {
234
237
  /**
235
238
  * Start the server (standalone mode) via @hono/node-server
236
239
  */
237
- listen(port: number = 3000, callback?: () => void): void {
240
+ listen(port = 3000, callback?: () => void): void {
238
241
  serve({
239
242
  fetch: this.app.fetch,
240
243
  port
package/src/api/types.ts CHANGED
@@ -74,7 +74,7 @@ export interface QueryOptions {
74
74
  limit?: number;
75
75
  offset?: number;
76
76
  where?: Record<string, unknown>;
77
- orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>;
77
+ orderBy?: Array<{ field: string; direction: "asc" | "desc" }>;
78
78
  include?: string[];
79
79
  /** Columns to return in the response (field-level selection) */
80
80
  fields?: string[];
@@ -1,9 +1,18 @@
1
1
  import { Hono } from "hono";
2
- import { ApiError } from "../api/errors";
2
+ import { ApiError, errorHandler } from "../api/errors";
3
3
  import type { AuthRepository } from "./interfaces";
4
- import { requireAuth, requireAdmin } from "./middleware";
4
+ import { requireAuth, requireAdmin, createRequireAuth } from "./middleware";
5
5
  import { hashPassword, validatePasswordStrength } from "./password";
6
6
  import { AuthModuleConfig } from "./routes";
7
+
8
+ interface AdminRouteOptions extends AuthModuleConfig {
9
+ serviceKey?: string;
10
+ /**
11
+ * Callback to persistently mark bootstrap as completed.
12
+ * Invoked after the first admin user is promoted via POST /admin/bootstrap.
13
+ */
14
+ setBootstrapCompleted?: () => Promise<void>;
15
+ }
7
16
  import { HonoEnv } from "../api/types";
8
17
  import { randomBytes, createHash } from "crypto";
9
18
  import { getUserInvitationTemplate, getPasswordResetTemplate } from "../email/templates";
@@ -51,20 +60,45 @@ function hashToken(token: string): string {
51
60
  /**
52
61
  * Create admin routes for user and role management
53
62
  */
54
- export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
63
+ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
55
64
  const router = new Hono<HonoEnv>();
56
65
  const authRepo = config.authRepo;
57
66
  const { emailService, emailConfig } = config;
58
67
 
59
- // Apply auth middleware to all routes
60
- router.use("/*", requireAuth);
61
-
68
+ // Attach Rebase error handler to ensure exceptions are correctly formatted
69
+ // instead of caught by Hono's default error handler from the sub-router.
70
+ router.onError(errorHandler);
71
+
72
+ // Apply auth middleware to all routes (service-key-aware when configured)
73
+ router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
74
+
75
+ /**
76
+ * POST /admin/bootstrap
77
+ *
78
+ * One-time endpoint to promote the calling user to admin.
79
+ * Guarded by three layers:
80
+ * 1. Authentication (handled by middleware above)
81
+ * 2. Persistent `bootstrap_completed` flag (when `setBootstrapCompleted` is provided)
82
+ * 3. Database check — no existing admin users
83
+ *
84
+ * Once invoked successfully the persistent flag is set, permanently disabling
85
+ * this endpoint even if all admin users are later deleted.
86
+ */
62
87
  router.post("/bootstrap", async (c) => {
63
88
  const user = c.get("user");
64
89
  if (!user || typeof user !== "object") {
65
90
  throw ApiError.unauthorized("Not authenticated");
66
91
  }
67
92
 
93
+ // ── Guard 1: persistent flag ──────────────────────────────────
94
+ if (config.isBootstrapCompleted) {
95
+ const alreadyDone = await config.isBootstrapCompleted();
96
+ if (alreadyDone) {
97
+ throw ApiError.forbidden("Bootstrap has already been completed.", "BOOTSTRAP_COMPLETED");
98
+ }
99
+ }
100
+
101
+ // ── Guard 2: no existing admin users ─────────────────────────
68
102
  const users = await authRepo.listUsers();
69
103
  let hasAdmin = false;
70
104
 
@@ -77,15 +111,21 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
77
111
  }
78
112
 
79
113
  if (hasAdmin) {
80
- throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.");
114
+ throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
81
115
  }
82
116
 
117
+ // ── Promote caller ───────────────────────────────────────────
83
118
  const userId = "userId" in user ? user.userId : undefined;
84
119
  if (!userId) {
85
120
  throw ApiError.unauthorized("User ID not found in auth context");
86
121
  }
87
122
  await authRepo.setUserRoles(userId, ["admin"]);
88
123
 
124
+ // ── Set persistent flag ──────────────────────────────────────
125
+ if (config.setBootstrapCompleted) {
126
+ await config.setBootstrapCompleted();
127
+ }
128
+
89
129
  return c.json({
90
130
  success: true,
91
131
  message: "You are now an admin",
@@ -113,7 +153,8 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
113
153
  offset,
114
154
  search: search || undefined,
115
155
  orderBy: orderBy || undefined,
116
- orderDir: orderDir || undefined
156
+ orderDir: orderDir || undefined,
157
+ roleId: c.req.query("role") || undefined
117
158
  });
118
159
 
119
160
  const usersWithRoles = await Promise.all(
@@ -124,7 +165,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
124
165
  email: u.email,
125
166
  displayName: u.displayName,
126
167
  photoURL: u.photoUrl,
127
- provider: u.provider,
128
168
  roles,
129
169
  createdAt: u.createdAt,
130
170
  updatedAt: u.updatedAt
@@ -150,7 +190,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
150
190
  email: u.email,
151
191
  displayName: u.displayName,
152
192
  photoURL: u.photoUrl,
153
- provider: u.provider,
154
193
  roles,
155
194
  createdAt: u.createdAt,
156
195
  updatedAt: u.updatedAt
@@ -174,7 +213,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
174
213
  email: result.user.email,
175
214
  displayName: result.user.displayName,
176
215
  photoURL: result.user.photoUrl,
177
- provider: result.user.provider,
178
216
  roles: result.roles.map(r => r.id),
179
217
  createdAt: result.user.createdAt,
180
218
  updatedAt: result.user.updatedAt
@@ -207,8 +245,7 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
207
245
  const user = await authRepo.createUser({
208
246
  email: email.toLowerCase(),
209
247
  displayName: displayName || null,
210
- passwordHash,
211
- provider: "email"
248
+ passwordHash
212
249
  });
213
250
 
214
251
  if (roles && Array.isArray(roles) && roles.length > 0) {
@@ -239,8 +276,10 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
239
276
  const appName = emailConfig?.appName || "Rebase";
240
277
  const templateFn = emailConfig?.templates?.userInvitation;
241
278
  const emailContent = templateFn
242
- ? templateFn(setPasswordUrl, { email: user.email, displayName: user.displayName })
243
- : getUserInvitationTemplate(setPasswordUrl, { email: user.email, displayName: user.displayName }, appName);
279
+ ? templateFn(setPasswordUrl, { email: user.email,
280
+ displayName: user.displayName })
281
+ : getUserInvitationTemplate(setPasswordUrl, { email: user.email,
282
+ displayName: user.displayName }, appName);
244
283
 
245
284
  await emailService!.send({
246
285
  to: user.email,
@@ -297,8 +336,10 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
297
336
  const appName = emailConfig?.appName || "Rebase";
298
337
  const templateFn = emailConfig?.templates?.passwordReset;
299
338
  const emailContent = templateFn
300
- ? templateFn(setPasswordUrl, { email: existing.email, displayName: existing.displayName })
301
- : getPasswordResetTemplate(setPasswordUrl, { email: existing.email, displayName: existing.displayName }, appName);
339
+ ? templateFn(setPasswordUrl, { email: existing.email,
340
+ displayName: existing.displayName })
341
+ : getPasswordResetTemplate(setPasswordUrl, { email: existing.email,
342
+ displayName: existing.displayName }, appName);
302
343
 
303
344
  await emailService!.send({
304
345
  to: existing.email,
@@ -0,0 +1,130 @@
1
+ import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
+ import { z } from "zod";
3
+ import { createPrivateKey } from "crypto";
4
+ import { SignJWT } from "jose";
5
+
6
+ /**
7
+ * Creates an Apple Sign In OAuth Provider integration.
8
+ *
9
+ * Apple requires a client secret that is a signed JWT, regenerated on each
10
+ * token exchange (valid up to 6 months). This provider handles that automatically.
11
+ *
12
+ * Required Apple Developer configuration:
13
+ * - Services ID (clientId)
14
+ * - Key ID from the private key registered with Apple
15
+ * - Team ID from Apple Developer account
16
+ * - Private key (.p8 file contents) downloaded from Apple Developer portal
17
+ */
18
+ export function createAppleProvider(config: {
19
+ clientId: string;
20
+ teamId: string;
21
+ keyId: string;
22
+ /** The raw PEM contents of the .p8 private key file */
23
+ privateKey: string;
24
+ }): OAuthProvider<{
25
+ code: string;
26
+ redirectUri: string;
27
+ user?: { name?: { firstName?: string; lastName?: string }; email?: string };
28
+ }> {
29
+ /**
30
+ * Generate a client_secret JWT signed with the Apple private key.
31
+ * Apple requires this instead of a static client_secret.
32
+ */
33
+ async function generateClientSecret(): Promise<string> {
34
+ const key = createPrivateKey({
35
+ key: config.privateKey,
36
+ format: "pem"
37
+ });
38
+
39
+ const now = Math.floor(Date.now() / 1000);
40
+
41
+ return new SignJWT({})
42
+ .setProtectedHeader({ alg: "ES256",
43
+ kid: config.keyId })
44
+ .setIssuer(config.teamId)
45
+ .setIssuedAt(now)
46
+ .setExpirationTime(now + 86400 * 180) // 6 months max
47
+ .setAudience("https://appleid.apple.com")
48
+ .setSubject(config.clientId)
49
+ .sign(key);
50
+ }
51
+
52
+ return {
53
+ id: "apple",
54
+ schema: z.object({
55
+ code: z.string().min(1, "Auth code is required"),
56
+ redirectUri: z.string().url("Valid redirect URI is required"),
57
+ /** Apple sends user info only on first authorization; the frontend must forward it. */
58
+ user: z.object({
59
+ name: z.object({
60
+ firstName: z.string().optional(),
61
+ lastName: z.string().optional()
62
+ }).optional(),
63
+ email: z.string().email().optional()
64
+ }).optional()
65
+ }),
66
+ verify: async (payload: {
67
+ code: string;
68
+ redirectUri: string;
69
+ user?: { name?: { firstName?: string; lastName?: string }; email?: string };
70
+ }): Promise<OAuthProviderProfile | null> => {
71
+ try {
72
+ const clientSecret = await generateClientSecret();
73
+
74
+ // Exchange code for tokens
75
+ const tokenResponse = await fetch("https://appleid.apple.com/auth/token", {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
78
+ body: new URLSearchParams({
79
+ client_id: config.clientId,
80
+ client_secret: clientSecret,
81
+ code: payload.code,
82
+ grant_type: "authorization_code",
83
+ redirect_uri: payload.redirectUri
84
+ })
85
+ });
86
+
87
+ if (!tokenResponse.ok) {
88
+ console.error("Failed to get Apple access token:", await tokenResponse.text());
89
+ return null;
90
+ }
91
+
92
+ const tokenData = await tokenResponse.json() as { id_token: string };
93
+
94
+ // Decode the id_token (JWT) to get user info.
95
+ // Apple's id_token is a standard JWT — we only need the payload.
96
+ const [, payloadB64] = tokenData.id_token.split(".");
97
+ const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")) as {
98
+ sub: string;
99
+ email?: string;
100
+ email_verified?: string | boolean;
101
+ };
102
+
103
+ // Apple only sends the user's name on the FIRST authorization.
104
+ // Subsequent logins only have the id_token. The frontend should pass
105
+ // the user object from the first auth for us to capture the name.
106
+ const email = decoded.email || payload.user?.email;
107
+ if (!email) {
108
+ console.error("Apple user has no email");
109
+ return null;
110
+ }
111
+
112
+ let displayName: string | null = null;
113
+ if (payload.user?.name) {
114
+ const parts = [payload.user.name.firstName, payload.user.name.lastName].filter(Boolean);
115
+ displayName = parts.length > 0 ? parts.join(" ") : null;
116
+ }
117
+
118
+ return {
119
+ providerId: decoded.sub,
120
+ email,
121
+ displayName,
122
+ photoUrl: null // Apple does not provide a profile photo
123
+ };
124
+ } catch (error) {
125
+ console.error("Apple OAuth error:", error);
126
+ return null;
127
+ }
128
+ }
129
+ };
130
+ }
@@ -0,0 +1,82 @@
1
+ import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Creates a Bitbucket OAuth Provider integration (OAuth 2.0 consumer).
6
+ */
7
+ export function createBitbucketProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
8
+ return {
9
+ id: "bitbucket",
10
+ schema: z.object({
11
+ code: z.string().min(1, "Auth code is required"),
12
+ redirectUri: z.string().url("Valid redirect URI is required")
13
+ }),
14
+ verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
15
+ try {
16
+ const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
17
+
18
+ const tokenResponse = await fetch("https://bitbucket.org/site/oauth2/access_token", {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/x-www-form-urlencoded",
22
+ "Authorization": `Basic ${basicAuth}`
23
+ },
24
+ body: new URLSearchParams({
25
+ grant_type: "authorization_code",
26
+ code: payload.code,
27
+ redirect_uri: payload.redirectUri
28
+ })
29
+ });
30
+
31
+ if (!tokenResponse.ok) {
32
+ console.error("Failed to get Bitbucket access token:", await tokenResponse.text());
33
+ return null;
34
+ }
35
+
36
+ const tokenData = await tokenResponse.json() as { access_token: string };
37
+ const accessToken = tokenData.access_token;
38
+
39
+ const profileResponse = await fetch("https://api.bitbucket.org/2.0/user", {
40
+ headers: { "Authorization": `Bearer ${accessToken}` }
41
+ });
42
+
43
+ if (!profileResponse.ok) {
44
+ console.error("Failed to get Bitbucket user info:", await profileResponse.text());
45
+ return null;
46
+ }
47
+
48
+ const p = await profileResponse.json() as {
49
+ uuid: string; display_name?: string; username?: string;
50
+ links?: { avatar?: { href?: string } };
51
+ };
52
+
53
+ // Bitbucket doesn't return email in user profile — fetch from /emails endpoint
54
+ const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", {
55
+ headers: { "Authorization": `Bearer ${accessToken}` }
56
+ });
57
+
58
+ let email: string | null = null;
59
+ if (emailsResponse.ok) {
60
+ const emailData = await emailsResponse.json() as {
61
+ values: Array<{ email: string; is_primary: boolean; is_confirmed: boolean }>;
62
+ };
63
+ const primary = emailData.values.find(e => e.is_primary && e.is_confirmed)
64
+ || emailData.values.find(e => e.is_confirmed);
65
+ email = primary?.email || null;
66
+ }
67
+
68
+ if (!email) { console.error("Bitbucket user has no verified email"); return null; }
69
+
70
+ return {
71
+ providerId: p.uuid,
72
+ email,
73
+ displayName: p.display_name || p.username || null,
74
+ photoUrl: p.links?.avatar?.href || null
75
+ };
76
+ } catch (error) {
77
+ console.error("Bitbucket OAuth error:", error);
78
+ return null;
79
+ }
80
+ }
81
+ };
82
+ }
@@ -0,0 +1,83 @@
1
+ import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Creates a Discord OAuth2 Provider integration.
6
+ *
7
+ * Uses the authorization code flow. Requires the "identify" and "email"
8
+ * scopes to retrieve the user's email and profile information.
9
+ */
10
+ export function createDiscordProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
11
+ return {
12
+ id: "discord",
13
+ schema: z.object({
14
+ code: z.string().min(1, "Auth code is required"),
15
+ redirectUri: z.string().url("Valid redirect URI is required")
16
+ }),
17
+ verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
18
+ try {
19
+ // Exchange code for access token
20
+ const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
23
+ body: new URLSearchParams({
24
+ client_id: config.clientId,
25
+ client_secret: config.clientSecret,
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 Discord access token:", await tokenResponse.text());
34
+ return null;
35
+ }
36
+
37
+ const tokenData = await tokenResponse.json() as { access_token: string };
38
+ const accessToken = tokenData.access_token;
39
+
40
+ // Fetch user profile
41
+ const profileResponse = await fetch("https://discord.com/api/v10/users/@me", {
42
+ headers: { "Authorization": `Bearer ${accessToken}` }
43
+ });
44
+
45
+ if (!profileResponse.ok) {
46
+ console.error("Failed to get Discord user info:", await profileResponse.text());
47
+ return null;
48
+ }
49
+
50
+ const profileData = await profileResponse.json() as {
51
+ id: string;
52
+ username: string;
53
+ global_name?: string | null;
54
+ avatar?: string | null;
55
+ email?: string | null;
56
+ verified?: boolean;
57
+ };
58
+
59
+ if (!profileData.email) {
60
+ console.error("Discord user has no email (email scope may not have been granted)");
61
+ return null;
62
+ }
63
+
64
+ // Build avatar URL
65
+ let photoUrl: string | null = null;
66
+ if (profileData.avatar) {
67
+ const ext = profileData.avatar.startsWith("a_") ? "gif" : "png";
68
+ photoUrl = `https://cdn.discordapp.com/avatars/${profileData.id}/${profileData.avatar}.${ext}?size=256`;
69
+ }
70
+
71
+ return {
72
+ providerId: profileData.id,
73
+ email: profileData.email,
74
+ displayName: profileData.global_name || profileData.username || null,
75
+ photoUrl
76
+ };
77
+ } catch (error) {
78
+ console.error("Discord OAuth error:", error);
79
+ return null;
80
+ }
81
+ }
82
+ };
83
+ }